diff --git a/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/README.md b/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/README.md new file mode 100644 index 0000000..10ed78b --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/README.md @@ -0,0 +1,208 @@ +# Copilot Dependency Advisory + +## Overview + +This worker listens to the `AFTER_DOWNLOAD` event in JFrog Artifactory. It only acts on artifacts +that are explicitly tagged with two Artifactory **properties**: + +- `copilot.advisor.repo` — the `owner/repo` where advisory issues should be opened +- `copilot.advisor.repoBaseBranch` — the base branch Copilot's coding agent should work from + +An artifact missing either property is a **NOP** — the worker never guesses a target repo. The +first time a *tagged* artifact is downloaded, the worker opens a GitHub issue in the repo named by +its properties and assigns that issue to **GitHub Copilot's coding agent**, so Copilot autonomously +investigates the new dependency (what it is, whether it's approved, whether usage notes need +updating) instead of a human having to notice and file that ticket manually. + +Every later download of the same artifact is also a no-op — once an issue is opened, the worker +writes a third property, `copilot.advisor.notified`, back onto the artifact, so a package pulled +500 times a day only opens one issue, not 500. + +This dedupe intentionally does **not** use the Worker's built-in `context.state`: that state is +capped at a handful of small key-value pairs (10 items, 40-char keys, 250-char values, by the +current platform default) with no eviction — it throws once full, and isn't a fit for "remember +every artifact ever downloaded" at any real scale. Recording the flag on the artifact itself has no +such ceiling; each artifact carries its own dedupe bit. + +## Why this design + +An `AFTER_DOWNLOAD` event only tells you *what* was downloaded (repo key, path) and *who* requested +it (a JFrog user or token identity) — Artifactory has no field for "which GitHub repo is consuming +this," so this sample deliberately doesn't try to invent one. Instead, whoever owns a package opts +it in explicitly by tagging it with the two `copilot.advisor.*` properties (e.g. via `jf rt sp` at +publish time, or manually in the UI) — the worker just reads what's already there. This keeps the +worker itself simple, and puts the "which repo cares about this dependency" decision where it +belongs: with the artifact's owner, per artifact, not in one global worker setting. + +It's the informational counterpart to `ai-download-risk-advisor` (which reacts to Xray findings on +every qualifying download): this one reacts to novelty (a dependency nobody has flagged before) and +hands the follow-up off to an autonomous GitHub agent rather than a Slack message a human has to +act on. + +## Functionality + +1. **Trigger:** runs after a download completes in Artifactory, for repositories listed in + `manifest.json`'s `filterCriteria`. +2. **Opt-in + dedupe check:** reads the `copilot.advisor.repo`, `copilot.advisor.repoBaseBranch`, + and `copilot.advisor.notified` properties off the downloaded artifact in one call. Missing + either of the first two, or `notified` already `true`, and the worker stops here. +3. **Open an issue:** opens a GitHub issue in the repo named by `copilot.advisor.repo`, describing + the artifact, its source repo, and who downloaded it. +4. **Mark as notified:** writes `copilot.advisor.notified=true` back onto the artifact right after + the issue is created, so a failure in the next step never causes a duplicate issue on a later + download. +5. **Assign to Copilot:** calls GitHub's dedicated assignment endpoint to hand the issue to + Copilot's coding agent (`copilot-swe-agent[bot]`), targeting the branch named by + `copilot.advisor.repoBaseBranch`. If this step fails (e.g. Copilot coding agent isn't enabled on + that repo), the issue still exists for a human to pick up manually. + +## Required secrets + +| Secret Name | Description | Required | +| -------------- | ------------------------------------------------------------------------------------------------- | -------- | +| `GitHubToken` | A GitHub **personal access token** (classic, `repo` scope) belonging to a real user. Copilot coding agent usage is billed per-user, so a GitHub App / installation token cannot be used to assign issues to it. | Yes — without it a tagged/new dependency is detected but no issue is opened | + +Add it with the JFrog CLI: + +```sh +jf worker add-secret GitHubToken +``` + +## Configuration + +### Artifact properties (per artifact, required to opt in) + +Set these on any artifact you want this worker to watch — for example at publish time: + +```sh +jf rt sp / "copilot.advisor.repo=myorg/dependency-catalog;copilot.advisor.repoBaseBranch=main" +``` + +| Property | Example | Purpose | +| ---------------------------------- | ------------------------------ | --------------------------------------------------------- | +| `copilot.advisor.repo` | `myorg/dependency-catalog` | `owner/repo` where the advisory issue is opened | +| `copilot.advisor.repoBaseBranch` | `main` | Base branch passed to Copilot's coding agent | + +Both must be set, or the worker treats the download as a NOP. Don't set `copilot.advisor.notified` +yourself — the worker manages it, writing `true` once an issue has been opened for the artifact. + +### Worker properties (global, optional) + +Declared in `manifest.json` (`context.properties.get(...)` in `worker.ts`): + +| Property | Default | Purpose | +| -------------------- | ------------------------------- | ----------------------------------------- | +| `githubApiUrl` | `https://api.github.com` | Override for GitHub Enterprise Server | +| `githubApiVersion` | `2022-11-28` | Value sent as the `X-GitHub-Api-Version` header | + +Edit the value directly in `manifest.json`'s `"properties"` object and redeploy (`jf worker deploy`). + +The worker is filtered to the repositories listed in `manifest.json` under +`filterCriteria.artifactFilterCriteria.repoKeys` — update that list to match the repositories whose +new dependencies you want tracked (e.g. your `npm-local`/`pypi-local`/`maven-local` repos, not every +repo in the platform). Note this filter is still just a coarse net; the actual opt-in is the two +artifact properties above. + +## How it works + +1. **Download event:** the worker receives the downloaded artifact's repo key, path, and the + user/token that downloaded it. +2. **Property lookup:** `GET /api/storage/{repoKey}/{path}?properties=copilot.advisor.repo,copilot.advisor.repoBaseBranch,copilot.advisor.notified` + ([Item Properties API](https://jfrog.com/help/r/jfrog-rest-apis/item-properties)). Artifactory + returns `404` when none of the three are set — treated as "not opted in", not an error. +3. **Create the issue:** `POST /repos/{copilot.advisor.repo}/issues` with a title/body naming the + artifact, its source repository, and the downloader. +4. **Record notified:** `PUT /api/storage/{repoKey}/{path}?properties=copilot.advisor.notified=true` + (same API, a write this time). +5. **Assign to Copilot:** `POST /repos/{copilot.advisor.repo}/issues/{number}/assignees` with + `{ assignees: ["copilot-swe-agent[bot]"], agent_assignment: { target_repo, base_branch } }` — + see [Using Copilot cloud agent via the API](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/use-cloud-agent-via-the-api). + This is a separate call from issue creation; GitHub doesn't accept this assignee on the + create-issue call itself. +6. **Log the outcome:** the worker's return `message` always reflects what actually happened + (not opted in / skipped / issue created / issue created but not assigned / failed), visible in + the Workers execution log. + +## Running this sample end-to-end + +### Prerequisites + +- A JFrog Platform instance with **Artifactory** enabled. +- [JFrog CLI](https://jfrog.com/getcli/) installed and configured against that instance. +- A GitHub repository to act as the dependency catalog, with + [Copilot coding agent](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/coding-agent) enabled. +- A GitHub personal access token (classic, `repo` scope) for a user who has Copilot access on that repo. + +### Steps + +1. Configure the JFrog CLI server if you haven't already: + ```sh + jf config add + ``` +2. From this directory, deploy the worker (or copy this folder's files into a worker created via + `jf worker init AFTER_DOWNLOAD copilot-dependency-advisory` if you're starting fresh): + ```sh + jf worker deploy + ``` +3. Add the secret: + ```sh + jf worker add-secret GitHubToken + ``` +4. Edit `manifest.json`'s `filterCriteria.artifactFilterCriteria.repoKeys` to the repos you want + tracked, then tag a test artifact with both opt-in properties: + ```sh + jf rt sp / "copilot.advisor.repo=/;copilot.advisor.repoBaseBranch=main" + ``` +5. Set `manifest.json`'s `"enabled"` to `true` (samples ship disabled by default) and deploy again. +6. Download the tagged artifact (e.g. `jf rt download /` or via the UI). +7. Check the result: + - Workers execution log (Platform UI → Administration → Workers → this worker → Logs), or + `jf worker exec-history `, should show the `message` the worker returned. + - The repo named by `copilot.advisor.repo` should have a new issue, assigned to Copilot. +8. Download the same artifact again — the worker should log + `"Artifact already notified before; skipped Copilot advisory."` and no second issue should + appear (check the artifact's properties in the UI — `copilot.advisor.notified` should now be + `true`). +9. To see the opt-in gate, download a different, untagged artifact — the worker should log + the "Artifact is not opted in..." message and make no external calls. + +## Response messages + +- `"Missing repoPath metadata on the download request."` +- `"Artifact already notified before; skipped Copilot advisory."` +- `"Artifact is not opted in (missing 'copilot.advisor.repo' and/or 'copilot.advisor.repoBaseBranch' property); skipped Copilot advisory."` +- `"Failed to read artifact properties: "` +- `"New dependency detected but GitHubToken secret is not configured; skipped Copilot advisory."` +- `"New dependency detected but failed to open a GitHub issue: "` +- `"New dependency detected: opened issue # in , but failed to assign it to Copilot."` +- `"New dependency detected: opened issue # in and assigned it to Copilot."` + +## Error scenarios + +- **Artifact not tagged (one or both properties missing):** the worker no-ops; nothing is created + anywhere. This is the expected, common case for most downloads. +- **Artifact properties lookup fails for a reason other than "not found"** (Artifactory + unreachable, permissions): logged as an error and surfaced in the return message; the artifact is + *not* marked as notified, so the next download will retry the lookup. +- **GitHub issue creation fails** (bad token, rate limit, network error): logged as an error; the + artifact is *not* marked as notified, so the next download of the same artifact will retry. +- **Writing the `notified` property back fails** (Artifactory unreachable, permissions): logged as + a warning; the issue still exists, but the next download of this same artifact will open a + duplicate issue since the dedupe write never landed. This is a narrow, per-artifact failure mode + (unlike a global dedupe store, one artifact's write failure can't affect any other artifact). +- **Copilot assignment fails** (Copilot coding agent not enabled on the repo, token isn't a PAT, + Copilot access not granted to the token's user): logged as a warning; the issue still exists for + manual triage, and the artifact is already marked as notified (no duplicate issue on retry). + +## Dependencies + +This worker relies on: +- `PlatformContext` (`context.clients.platformHttp`, `context.clients.axios`, `context.secrets`, + `context.properties`) from the `jfrog-workers` package. Deliberately **not** `context.state` — + see "Why this design" above. +- `AfterDownloadRequest` / `AfterDownloadResponse` for the event payload and response shape. +- Artifactory's [Item Properties API](https://jfrog.com/help/r/jfrog-rest-apis/item-properties) + to read the artifact's opt-in properties and write back the notified marker. +- GitHub's [Issues API](https://docs.github.com/en/rest/issues/issues) and + [Copilot coding agent assignment API](https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/use-cloud-agent-via-the-api) + (called directly over HTTPS via `context.clients.axios`; no GitHub SDK dependency needed). diff --git a/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/manifest.json b/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/manifest.json new file mode 100644 index 0000000..95d425b --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/manifest.json @@ -0,0 +1,20 @@ +{ + "name": "copilot-dependency-advisory", + "description": "This worker is triggered by the `AFTER_DOWNLOAD` event of Artifactory. The first time an artifact tagged with the `copilot.advisor.repo` and `copilot.advisor.repoBaseBranch` properties is downloaded, it opens a GitHub issue in that repo and assigns it to GitHub Copilot's coding agent to vet the new dependency. Artifacts without both properties are a NOP.", + "sourceCodePath": "./worker.ts", + "action": "AFTER_DOWNLOAD", + "enabled": false, + "debug": true, + "secrets": {}, + "properties": { + "githubApiUrl": "https://api.github.com", + "githubApiVersion": "2022-11-28" + }, + "filterCriteria": { + "artifactFilterCriteria": { + "repoKeys": [ + "example-repo-local" + ] + } + } +} diff --git a/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/package.json b/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/package.json new file mode 100644 index 0000000..4ed11a0 --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/package.json @@ -0,0 +1,43 @@ +{ + "name": "copilot-dependency-advisory", + "description": "Run a script on AFTER_DOWNLOAD", + "version": "1.0.0", + "scripts": { + "deploy": "jf worker deploy", + "undeploy": "jf worker rm \"copilot-dependency-advisory\"", + "test": "jest" + }, + "license": "ISC", + "devDependencies": { + "jfrog-workers": "^0.19.0", + "@golevelup/ts-jest": "^0.4.0", + "@types/jest": "^29.5.12", + "jest": "^29.7.0", + "jest-jasmine2": "^29.7.0", + "ts-jest": "^29.1.2", + "typescript": "^5.4.0" + }, + "jest": { + "moduleFileExtensions": [ + "ts", + "js" + ], + "rootDir": ".", + "testEnvironment": "node", + "clearMocks": true, + "maxConcurrency": 1, + "testRegex": "\\.spec\\.ts$", + "moduleDirectories": [ + "node_modules" + ], + "collectCoverageFrom": [ + "**/*.ts" + ], + "coverageDirectory": "../coverage", + "transform": { + "^.+\\.(t|j)s$": "ts-jest" + }, + "testRunner": "jest-jasmine2", + "verbose": true + } +} diff --git a/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/sample-payload.json b/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/sample-payload.json new file mode 100644 index 0000000..ed3e782 --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/sample-payload.json @@ -0,0 +1,17 @@ +{ + "metadata": { + "repoPath": { + "key": "npm-local", + "path": "@acme/left-pad-fork/1.0.0/left-pad-fork-1.0.0.tgz", + "id": "npm-local:@acme/left-pad-fork/1.0.0/left-pad-fork-1.0.0.tgz", + "isRoot": false, + "isFolder": false + }, + "repoType": 1 + }, + "userContext": { + "id": "ci-payments-service", + "isToken": true, + "realm": "" + } +} diff --git a/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/tsconfig.json b/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/tsconfig.json new file mode 100644 index 0000000..8005cfb --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "module": "commonjs", + "declaration": true, + "target": "es2017", + "skipLibCheck": true, + "forceConsistentCasingInFileNames": false, + "noFallthroughCasesInSwitch": false, + "allowJs": true + }, + "include": [ + "**/*.ts", + "node_modules/@types/**/*.d.ts" + ] +} diff --git a/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/types.ts b/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/types.ts new file mode 100644 index 0000000..5646037 --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/types.ts @@ -0,0 +1,93 @@ + +export interface AfterDownloadRequest { + /** Various immutable download metadata */ + metadata: + | DownloadMetadata + | undefined; + /** The immutable request headers */ + headers: { [key: string]: Header }; + /** The user context which sends the request */ + userContext: UserContext | undefined; +} + +export interface AfterDownloadResponse { + /** Message to print to the log, in case of an error it will be printed as a warning */ + message: string; +} + +export interface DownloadMetadata { + /** The repoPath object of the request */ + repoPath: + | RepoPath + | undefined; + /** The original repo path in case a virtual repo is involved */ + originalRepoPath: + | RepoPath + | undefined; + /** The file name from path */ + name: string; + /** Is it a head request */ + headOnly: boolean; + /** Is it a checksum request */ + checksum: boolean; + /** Is it a recursive request */ + recursive: boolean; + /** When a modification has occurred */ + modificationTime: number; + /** Is it a directory request */ + directoryRequest: boolean; + /** Is it a metadata request */ + metadata: boolean; + /** Last modification time that occurred */ + lastModified: number; + /** If a modification happened since the last modification time */ + ifModifiedSince: number; + /** The url that points to artifactory */ + servletContextUrl: string; + /** The request URI */ + uri: string; + /** The client address */ + clientAddress: string; + /** The resource path of the requested zip */ + zipResourcePath: string; + /** Is the request a zip resource request */ + zipResourceRequest: boolean; + /** should replace the head request with get */ + replaceHeadRequestWithGet: boolean; + /** Repository type */ + repoType: RepoType; +} + +export interface Header { + value: string[]; +} + +export interface UserContext { + /** The username or subject */ + id: string; + /** Is the context an accessToken */ + isToken: boolean; + /** The realm of the user */ + realm: string; +} + +export enum RepoType { + REPO_TYPE_UNSPECIFIED = 0, + REPO_TYPE_LOCAL = 1, + REPO_TYPE_REMOTE = 2, + REPO_TYPE_FEDERATED = 3, + UNRECOGNIZED = -1, +} + +export interface RepoPath { + /** The repo key */ + key: string; + /** The path itself */ + path: string; + /** The key:path combination */ + id: string; + /** Is the path the root */ + isRoot: boolean; + /** Is the path a folder */ + isFolder: boolean; +} diff --git a/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/worker.spec.ts b/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/worker.spec.ts new file mode 100644 index 0000000..b7122e0 --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/worker.spec.ts @@ -0,0 +1,222 @@ +import { PlatformContext, PlatformClients, PlatformHttpClient, PlatformSecrets, PlatformProperties } from 'jfrog-workers'; +import { createMock, DeepMocked } from '@golevelup/ts-jest'; +import { AfterDownloadRequest } from './types'; +import runWorker from './worker'; + +const OPTED_IN_PROPERTIES = { + status: 200, + data: { + properties: { + 'copilot.advisor.repo': ['myorg/dependency-catalog'], + 'copilot.advisor.repoBaseBranch': ['main'] + } + } +}; + +function buildRequest(): DeepMocked { + return createMock({ + metadata: { + repoPath: { + key: 'npm-local', + path: '@acme/left-pad-fork/1.0.0/left-pad-fork-1.0.0.tgz', + id: 'npm-local:@acme/left-pad-fork/1.0.0/left-pad-fork-1.0.0.tgz' + } + } as any, + userContext: { id: 'ci-payments-service', isToken: true } as any + }); +} + +function buildContext(): DeepMocked { + return createMock({ + clients: createMock({ + platformHttp: createMock({ + get: jest.fn().mockResolvedValue(OPTED_IN_PROPERTIES), + put: jest.fn().mockResolvedValue({ status: 204, data: {} }) + }), + axios: createMock({ + post: jest.fn().mockResolvedValue({ status: 200, data: {} }) + }) + }), + secrets: createMock({ + get: jest.fn().mockReturnValue(undefined) + }), + properties: createMock({ + get: jest.fn().mockReturnValue(undefined) + }) + }); +} + +describe('copilot-dependency-advisory tests', () => { + let context: DeepMocked; + let request: DeepMocked; + + beforeEach(() => { + context = buildContext(); + request = buildRequest(); + }); + + it('skips when repoPath metadata is missing', async () => { + request.metadata = undefined as any; + + await expect(runWorker(context, request)).resolves.toEqual({ + message: 'Missing repoPath metadata on the download request.' + }); + expect(context.clients.platformHttp.get).not.toHaveBeenCalled(); + }); + + it('is a NOP when the artifact is not tagged with the advisor properties', async () => { + context.clients.platformHttp.get = jest.fn().mockRejectedValue({ status: 404, message: 'Not Found' }); + + await expect(runWorker(context, request)).resolves.toEqual({ + message: "Artifact is not opted in (missing 'copilot.advisor.repo' and/or 'copilot.advisor.repoBaseBranch' property); skipped Copilot advisory." + }); + expect(context.clients.axios.post).not.toHaveBeenCalled(); + expect(context.clients.platformHttp.put).not.toHaveBeenCalled(); + }); + + it('is a NOP when only one of the two advisor properties is set', async () => { + context.clients.platformHttp.get = jest.fn().mockResolvedValue({ + status: 200, + data: { properties: { 'copilot.advisor.repo': ['myorg/dependency-catalog'] } } + }); + + await expect(runWorker(context, request)).resolves.toEqual({ + message: "Artifact is not opted in (missing 'copilot.advisor.repo' and/or 'copilot.advisor.repoBaseBranch' property); skipped Copilot advisory." + }); + expect(context.clients.axios.post).not.toHaveBeenCalled(); + }); + + it('is a NOP when the artifact was already notified', async () => { + context.clients.platformHttp.get = jest.fn().mockResolvedValue({ + status: 200, + data: { + properties: { + 'copilot.advisor.repo': ['myorg/dependency-catalog'], + 'copilot.advisor.repoBaseBranch': ['main'], + 'copilot.advisor.notified': ['true'] + } + } + }); + + await expect(runWorker(context, request)).resolves.toEqual({ + message: 'Artifact already notified before; skipped Copilot advisory.' + }); + expect(context.clients.axios.post).not.toHaveBeenCalled(); + }); + + it('reports an error when the artifact properties lookup fails for a reason other than 404', async () => { + context.clients.platformHttp.get = jest.fn().mockRejectedValue({ status: 500, message: 'Artifactory unreachable' }); + + await expect(runWorker(context, request)).resolves.toEqual({ + message: 'Failed to read artifact properties: Artifactory unreachable' + }); + }); + + it('skips when GitHubToken secret is not configured', async () => { + context.secrets.get = jest.fn().mockReturnValue(undefined); + + await expect(runWorker(context, request)).resolves.toEqual({ + message: 'New dependency detected but GitHubToken secret is not configured; skipped Copilot advisory.' + }); + expect(context.clients.platformHttp.put).not.toHaveBeenCalled(); + }); + + it('opens a GitHub issue, marks the artifact notified, and assigns it to Copilot for a new, opted-in artifact', async () => { + context.secrets.get = jest.fn().mockReturnValue('ghp_test-token'); + context.clients.axios.post = jest.fn().mockImplementation((url: string) => { + if (url === 'https://api.github.com/repos/myorg/dependency-catalog/issues') { + return Promise.resolve({ status: 201, data: { number: 42 } }); + } + return Promise.resolve({ status: 201, data: {} }); + }); + + await expect(runWorker(context, request)).resolves.toEqual({ + message: 'New dependency detected: opened issue #42 in myorg/dependency-catalog and assigned it to Copilot.' + }); + + expect(context.clients.platformHttp.get).toHaveBeenCalledWith( + '/artifactory/api/storage/npm-local/@acme/left-pad-fork/1.0.0/left-pad-fork-1.0.0.tgz?properties=copilot.advisor.repo,copilot.advisor.repoBaseBranch,copilot.advisor.notified' + ); + expect(context.clients.platformHttp.put).toHaveBeenCalledWith( + '/artifactory/api/storage/npm-local/@acme/left-pad-fork/1.0.0/left-pad-fork-1.0.0.tgz?properties=copilot.advisor.notified=true' + ); + expect(context.clients.axios.post).toHaveBeenCalledWith( + 'https://api.github.com/repos/myorg/dependency-catalog/issues', + expect.objectContaining({ title: expect.stringContaining('left-pad-fork-1.0.0.tgz') }), + expect.objectContaining({ + headers: expect.objectContaining({ Authorization: 'Bearer ghp_test-token', 'X-GitHub-Api-Version': '2022-11-28' }) + }) + ); + expect(context.clients.axios.post).toHaveBeenCalledWith( + 'https://api.github.com/repos/myorg/dependency-catalog/issues/42/assignees', + expect.objectContaining({ + assignees: ['copilot-swe-agent[bot]'], + agent_assignment: expect.objectContaining({ target_repo: 'myorg/dependency-catalog', base_branch: 'main' }) + }), + expect.anything() + ); + }); + + it('uses the configured githubApiUrl/githubApiVersion properties for the GitHub calls', async () => { + context.secrets.get = jest.fn().mockReturnValue('ghp_test-token'); + context.properties.get = jest.fn().mockImplementation((key: string) => { + if (key === 'githubApiUrl') return 'https://ghe.internal/api/v3'; + if (key === 'githubApiVersion') return '2023-07-01'; + return undefined; + }); + context.clients.axios.post = jest.fn().mockImplementation((url: string) => { + if (url === 'https://ghe.internal/api/v3/repos/myorg/dependency-catalog/issues') { + return Promise.resolve({ status: 201, data: { number: 1 } }); + } + return Promise.resolve({ status: 201, data: {} }); + }); + + await runWorker(context, request); + + expect(context.clients.axios.post).toHaveBeenCalledWith( + 'https://ghe.internal/api/v3/repos/myorg/dependency-catalog/issues', + expect.anything(), + expect.objectContaining({ headers: expect.objectContaining({ 'X-GitHub-Api-Version': '2023-07-01' }) }) + ); + }); + + it('does not mark the artifact as notified when issue creation fails', async () => { + context.secrets.get = jest.fn().mockReturnValue('ghp_test-token'); + context.clients.axios.post = jest.fn().mockRejectedValue(new Error('GitHub unreachable')); + + await expect(runWorker(context, request)).resolves.toEqual({ + message: 'New dependency detected but failed to open a GitHub issue: GitHub unreachable' + }); + expect(context.clients.platformHttp.put).not.toHaveBeenCalled(); + }); + + it('still assigns to Copilot even when marking the artifact as notified fails', async () => { + context.secrets.get = jest.fn().mockReturnValue('ghp_test-token'); + context.clients.platformHttp.put = jest.fn().mockRejectedValue(new Error('Artifactory unreachable')); + context.clients.axios.post = jest.fn().mockImplementation((url: string) => { + if (url.endsWith('/issues')) { + return Promise.resolve({ status: 201, data: { number: 7 } }); + } + return Promise.resolve({ status: 201, data: {} }); + }); + + await expect(runWorker(context, request)).resolves.toEqual({ + message: 'New dependency detected: opened issue #7 in myorg/dependency-catalog and assigned it to Copilot.' + }); + }); + + it('still reports success on the issue when Copilot assignment fails after notifying', async () => { + context.secrets.get = jest.fn().mockReturnValue('ghp_test-token'); + context.clients.axios.post = jest.fn().mockImplementation((url: string) => { + if (url.endsWith('/issues')) { + return Promise.resolve({ status: 201, data: { number: 7 } }); + } + return Promise.reject(new Error('Copilot coding agent not enabled for this repo')); + }); + + await expect(runWorker(context, request)).resolves.toEqual({ + message: 'New dependency detected: opened issue #7 in myorg/dependency-catalog, but failed to assign it to Copilot.' + }); + expect(context.clients.platformHttp.put).toHaveBeenCalled(); + }); +}); diff --git a/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/worker.ts b/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/worker.ts new file mode 100644 index 0000000..0213e8c --- /dev/null +++ b/samples/artifactory/AFTER_DOWNLOAD/copilot-dependency-advisory/worker.ts @@ -0,0 +1,167 @@ +import { PlatformContext } from 'jfrog-workers'; +import { AfterDownloadRequest, AfterDownloadResponse } from './types'; + +// Artifact properties (set on the artifact itself in Artifactory, e.g. via `jf rt sp` or the UI) +// that opt an artifact into this advisory. An artifact without both is a NOP - this worker never +// picks a target repo on its own. +const REPO_PROPERTY = 'copilot.advisor.repo'; // e.g. "myorg/dependency-catalog" +const BASE_BRANCH_PROPERTY = 'copilot.advisor.repoBaseBranch'; // e.g. "main" +// Written back onto the artifact once an issue has been opened for it, so dedupe lives on the +// artifact itself rather than in the worker's own state (which is capped at a handful of small +// key-value pairs - not a fit for "remember every artifact ever downloaded"). +const NOTIFIED_PROPERTY = 'copilot.advisor.notified'; + +export default async (context: PlatformContext, data: AfterDownloadRequest): Promise => { + // Configurable via the worker's "properties" (manifest.json / Platform UI); fall back to this default if unset + // or if the platform errors out on an unknown property key instead of returning undefined. + const githubApiUrl = getProperty(context, 'githubApiUrl', 'https://api.github.com'); + const githubApiVersion = getProperty(context, 'githubApiVersion', '2022-11-28'); + + const repoPath = data.metadata?.repoPath; + if (!repoPath) { + return { message: 'Missing repoPath metadata on the download request.' }; + } + + let artifactProperties: Record; + try { + artifactProperties = await getArtifactProperties(context, repoPath.key, repoPath.path, [REPO_PROPERTY, BASE_BRANCH_PROPERTY, NOTIFIED_PROPERTY]); + } catch (error: any) { + console.error(`Failed to read artifact properties: ${error.message}`); + return { message: `Failed to read artifact properties: ${error.message}` }; + } + + const githubRepo = artifactProperties[REPO_PROPERTY]?.[0]; + const githubBaseBranch = artifactProperties[BASE_BRANCH_PROPERTY]?.[0]; + if (!githubRepo || !githubBaseBranch) { + return { message: `Artifact is not opted in (missing '${REPO_PROPERTY}' and/or '${BASE_BRANCH_PROPERTY}' property); skipped Copilot advisory.` }; + } + + // ponytail: no locking, so two downloads of the same never-before-notified artifact within the + // same instant could both pass this check and each open an issue. Narrow, per-artifact race - + // add a conditional/atomic property write if that duplicate is ever a real problem. + if (artifactProperties[NOTIFIED_PROPERTY]?.[0] === 'true') { + return { message: 'Artifact already notified before; skipped Copilot advisory.' }; + } + + const githubToken = context.secrets.get('GitHubToken'); + if (!githubToken) { + return { message: 'New dependency detected but GitHubToken secret is not configured; skipped Copilot advisory.' }; + } + + const artifactName = repoPath.path.slice(repoPath.path.lastIndexOf('/') + 1); + const downloadedBy = `${data.userContext?.isToken ? 'token' : 'user'} ${data.userContext?.id}`; + + let issueNumber: number; + try { + issueNumber = await createGithubIssue(context, githubApiUrl, githubApiVersion, githubToken, githubRepo, artifactName, repoPath.key, downloadedBy); + } catch (error: any) { + console.error(`Failed to open a GitHub issue: ${error.message}`); + return { message: `New dependency detected but failed to open a GitHub issue: ${error.message}` }; + } + + // The issue exists at this point regardless of what happens next, so record that on the + // artifact now - a failed Copilot assignment (or a failure here) shouldn't be retried as a + // brand new issue on the next download of this same artifact. + try { + await setNotifiedProperty(context, repoPath.key, repoPath.path); + } catch (error: any) { + console.warn(`Failed to record '${NOTIFIED_PROPERTY}' on the artifact: ${error.message}`); + } + + try { + await assignToCopilot(context, githubApiUrl, githubApiVersion, githubToken, githubRepo, issueNumber, githubBaseBranch); + return { message: `New dependency detected: opened issue #${issueNumber} in ${githubRepo} and assigned it to Copilot.` }; + } catch (error: any) { + console.warn(`Failed to assign issue #${issueNumber} to Copilot: ${error.message}`); + return { message: `New dependency detected: opened issue #${issueNumber} in ${githubRepo}, but failed to assign it to Copilot.` }; + } +} + +// Some platform versions throw instead of returning undefined when a property key isn't set yet +// (server-side bug, fixed but not deployed everywhere) - treat that the same as "unset". +function getProperty(context: PlatformContext, key: string, defaultValue: string): string { + try { + return context.properties.get(key) || defaultValue; + } catch (error: any) { + console.warn(`Failed to read property '${key}', using default '${defaultValue}': ${error.message}`); + return defaultValue; + } +} + +// Reads the given properties off the downloaded artifact via Artifactory's Item Properties API: +// https://jfrog.com/help/r/jfrog-rest-apis/item-properties +// Artifactory returns 404 when none of the requested properties are set on this item - that's a +// normal "not opted in" outcome here, not an error. +async function getArtifactProperties(context: PlatformContext, repoKey: string, path: string, keys: string[]): Promise> { + try { + const res = await context.clients.platformHttp.get(`/artifactory/api/storage/${repoKey}/${path}?properties=${keys.join(',')}`); + return res.data?.properties || {}; + } catch (error: any) { + if (error.status === 404) { + return {}; + } + throw error; + } +} + +// Writes the notified marker back onto the artifact via Artifactory's Set Item Properties API +// (same endpoint family as the read above): https://jfrog.com/help/r/jfrog-rest-apis/item-properties +async function setNotifiedProperty(context: PlatformContext, repoKey: string, path: string): Promise { + await context.clients.platformHttp.put(`/artifactory/api/storage/${repoKey}/${path}?properties=${NOTIFIED_PROPERTY}=true`); +} + +async function createGithubIssue( + context: PlatformContext, + githubApiUrl: string, + githubApiVersion: string, + githubToken: string, + githubRepo: string, + artifactName: string, + repoKey: string, + downloadedBy: string +): Promise { + const res = await context.clients.axios.post(`${githubApiUrl}/repos/${githubRepo}/issues`, { + title: `New dependency pulled: ${artifactName}`, + body: `\`${artifactName}\` was downloaded from Artifactory repository \`${repoKey}\` for the first time (by ${downloadedBy}).\n\n` + + `Could you vet this dependency - confirm it's an approved package, and add or update its usage notes in this repo if needed?` + }, { + headers: githubHeaders(githubToken, githubApiVersion) + }); + + const issueNumber = res.data?.number; + if (!issueNumber) { + throw new Error('GitHub response did not contain an issue number.'); + } + return issueNumber; +} + +// Assigning an issue to Copilot's coding agent is a dedicated endpoint, not a field on issue +// creation - see https://docs.github.com/en/copilot/how-tos/use-copilot-agents/cloud-agent/use-cloud-agent-via-the-api. +// Requires a personal access token (Copilot usage is billed per-user); a GitHub App token won't work. +async function assignToCopilot( + context: PlatformContext, + githubApiUrl: string, + githubApiVersion: string, + githubToken: string, + githubRepo: string, + issueNumber: number, + githubBaseBranch: string +): Promise { + await context.clients.axios.post(`${githubApiUrl}/repos/${githubRepo}/issues/${issueNumber}/assignees`, { + assignees: ['copilot-swe-agent[bot]'], + agent_assignment: { + target_repo: githubRepo, + base_branch: githubBaseBranch + } + }, { + headers: githubHeaders(githubToken, githubApiVersion) + }); +} + +function githubHeaders(githubToken: string, githubApiVersion: string): Record { + return { + 'Authorization': `Bearer ${githubToken}`, + 'Accept': 'application/vnd.github+json', + 'X-GitHub-Api-Version': githubApiVersion + }; +}