diff --git a/.github/workflows/semantic-pull-requests.yml b/.github/workflows/semantic-pull-requests.yml index 89926a2..50bd3b2 100644 --- a/.github/workflows/semantic-pull-requests.yml +++ b/.github/workflows/semantic-pull-requests.yml @@ -42,6 +42,7 @@ jobs: safety server shared + source-control test subjectPattern: ^(?![A-Z]).+$ subjectPatternError: | diff --git a/.skills/agent-zero-architecture/SKILL.md b/.skills/agent-zero-architecture/SKILL.md index c4daa9a..6f8c827 100644 --- a/.skills/agent-zero-architecture/SKILL.md +++ b/.skills/agent-zero-architecture/SKILL.md @@ -12,7 +12,7 @@ Keep dependency direction explicit while changing the monorepo. - `shared`: stable contracts only, plus pure functions over them (evidence rendering, redaction, path predicates). - `config`: configuration, repository policy, and check discovery. Pure; the agent supplies what it read through the runner. - `models`: provider-independent model contracts and provider adapters. -- `github`: GitHub-specific translation, event parsing, Checks API behavior, and issue-to-PR publication (branch and pull-request creation through the Git data API). +- `source-control`: provider-neutral source-control contracts, webhook normalization, capability detection, and the GitHub, GitLab, Bitbucket, and Gitea adapters, including GitHub's issue-to-PR publication (branch and pull-request creation through the Git data API). - `runner`: command execution and checkout mutation boundary, plus the policy-to-boundary factory. - `agent`: orchestration, the lifecycle machine, and the validation policy. - `cli`: argument parsing and terminal presentation. @@ -23,7 +23,7 @@ Keep dependency direction explicit while changing the monorepo. 1. Read `AGENTS.md` and `docs/architecture.md`. 2. Identify the narrowest package that owns the behavior. -3. Check imports before adding a dependency. Core packages must not import CLI, HTTP, or GitHub adapters. +3. Check imports before adding a dependency. Core packages must not import CLI, HTTP, or source-control adapters. 4. Put a contract in `shared` only when at least two packages need a stable common type. 5. Keep SDK-specific types inside their adapter. 6. Add deterministic tests beside the changed source. @@ -31,9 +31,9 @@ Keep dependency direction explicit while changing the monorepo. ## Reject these designs -- Shell execution in a transport adapter, CLI presentation, GitHub adapter, model adapter, or agent state machine. +- Shell execution in a transport adapter, CLI presentation, source-control adapter, model adapter, or agent state machine. - HTTP request/response types inside the runtime. -- GitHub SDK objects passed through shared contracts. +- Provider SDK or payload objects passed through shared contracts. - A generic `utils` package used to bypass ownership decisions. - Cross-package imports from another package's `src/` directory. - A capability package importing another capability package. When `runner` needs policy, it declares the fields it needs structurally instead of importing `config`. diff --git a/AGENTS.md b/AGENTS.md index 2ef0ad9..b7573ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ These instructions apply to humans and coding agents working in this repository. - `packages/agent`: orchestration and state transitions only. - `packages/runner`: the only boundary allowed to execute repository commands or mutate a checkout. - `packages/models`: model-provider abstractions. -- `packages/github`: GitHub event and API adapters. +- `packages/source-control`: provider-neutral source-control contracts, with GitHub, GitLab, Bitbucket, and Gitea adapters underneath. - `packages/config`: configuration parsing and policy. - `packages/shared`: stable cross-package contracts. - `packages/cli`: argument parsing and terminal presentation. @@ -33,7 +33,7 @@ These instructions apply to humans and coding agents working in this repository. - `apps/auth-server`: the only component that owns a persistence layer. Serves the Better Auth handler and nothing else. - `apps/dashboard`: frontend-only Nuxt operational dashboard. Presentation plus an authenticated client of `apps/auth-server`. No Nitro server routes, no persistence, no runtime-package imports. -The runtime must remain independent from HTTP, GitHub, terminal UI, and specific model providers. Adapters depend on the runtime; the runtime must not depend on adapters. Authentication is an adapter concern: neither `packages/auth` nor `apps/auth-server` may import a runtime package, and neither may execute repository work. +The runtime must remain independent from HTTP, source-control platforms, terminal UI, and specific model providers. Adapters depend on the runtime; the runtime must not depend on adapters. Authentication is an adapter concern: neither `packages/auth` nor `apps/auth-server` may import a runtime package, and neither may execute repository work. ## Safety and determinism diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2d1cf38..b371735 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,18 +32,18 @@ aube test ## Choose the right package -| Change | Location | -| -------------------------------- | ------------------ | -| Agent lifecycle and decisions | `packages/agent` | -| Commands and repository mutation | `packages/runner` | -| LLM providers | `packages/models` | -| GitHub adapters | `packages/github` | -| Configuration and policy | `packages/config` | -| Shared contracts | `packages/shared` | -| CLI parsing and presentation | `packages/cli` | -| Authentication policy | `packages/auth` | -| Authentication HTTP adapter | `apps/auth-server` | -| Dashboard frontend | `apps/dashboard` | +| Change | Location | +| -------------------------------- | ------------------------- | +| Agent lifecycle and decisions | `packages/agent` | +| Commands and repository mutation | `packages/runner` | +| LLM providers | `packages/models` | +| Source-control provider adapters | `packages/source-control` | +| Configuration and policy | `packages/config` | +| Shared contracts | `packages/shared` | +| CLI parsing and presentation | `packages/cli` | +| Authentication policy | `packages/auth` | +| Authentication HTTP adapter | `apps/auth-server` | +| Dashboard frontend | `apps/dashboard` | Read [AGENTS.md](AGENTS.md) and the matching files in `.agents/skills/` before making architectural or safety-sensitive changes. diff --git a/README.md b/README.md index 44e16ed..f9e6414 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Feedback is never treated as truth merely because it came from a human or an AI ## Architecture ```text -GitHub adapter / CLI +Source-control adapters (GitHub, GitLab, Bitbucket, Gitea) / CLI │ ▼ Agent state machine @@ -49,19 +49,19 @@ oRPC control plane ─── typed task API, persistence, and scheduling Nuxt dashboard ─── frontend-only operational interface ─── auth adapter ─── session store ``` -| Package | Responsibility | -| ---------------------------------------- | --------------------------------------------------------------- | -| [`packages/agent`](./packages/agent) | Orchestration and state transitions | -| [`packages/runner`](./packages/runner) | The only boundary that executes commands or mutates a checkout | -| [`packages/models`](./packages/models) | Model-provider abstractions | -| [`packages/github`](./packages/github) | GitHub event and API adapters | -| [`packages/config`](./packages/config) | Configuration parsing and policy | -| [`packages/shared`](./packages/shared) | Stable cross-package contracts | -| [`packages/cli`](./packages/cli) | Argument parsing and terminal presentation | -| [`packages/auth`](./packages/auth) | Authentication policy and the Better Auth instance | -| [`apps/server`](./apps/server) | oRPC control-plane transport and composition root | -| [`apps/auth-server`](./apps/auth-server) | Standalone auth adapter; the only component with a database | -| [`apps/dashboard`](./apps/dashboard) | Nuxt operational dashboard, authenticated client of the adapter | +| Package | Responsibility | +| ------------------------------------------------------ | ----------------------------------------------------------------------------------------- | +| [`packages/agent`](./packages/agent) | Orchestration and state transitions | +| [`packages/runner`](./packages/runner) | The only boundary that executes commands or mutates a checkout | +| [`packages/models`](./packages/models) | Model-provider abstractions | +| [`packages/source-control`](./packages/source-control) | Provider-neutral source-control contracts and adapters (GitHub, GitLab, Bitbucket, Gitea) | +| [`packages/config`](./packages/config) | Configuration parsing and policy | +| [`packages/shared`](./packages/shared) | Stable cross-package contracts | +| [`packages/cli`](./packages/cli) | Argument parsing and terminal presentation | +| [`packages/auth`](./packages/auth) | Authentication policy and the Better Auth instance | +| [`apps/server`](./apps/server) | oRPC control-plane transport and composition root | +| [`apps/auth-server`](./apps/auth-server) | Standalone auth adapter; the only component with a database | +| [`apps/dashboard`](./apps/dashboard) | Nuxt operational dashboard, authenticated client of the adapter | Adapters depend on the runtime; the runtime never depends on adapters. See [docs/architecture.md](./docs/architecture.md) for the full dependency rules. diff --git a/apps/server/package.json b/apps/server/package.json index 58ccd8d..ca6c099 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -14,10 +14,10 @@ "dependencies": { "@agent-zero/agent": "workspace:*", "@agent-zero/config": "workspace:*", - "@agent-zero/github": "workspace:*", "@agent-zero/models": "workspace:*", "@agent-zero/runner": "workspace:*", "@agent-zero/shared": "workspace:*", + "@agent-zero/source-control": "workspace:*", "@orpc/server": "2.0.0-beta.26", "nitro": "^3.0.260610-beta", "vite-hub": "^0.0.3", diff --git a/apps/server/server/routes/webhooks/github.post.ts b/apps/server/server/routes/webhooks/github.post.ts index 73b22e4..e2cdccb 100644 --- a/apps/server/server/routes/webhooks/github.post.ts +++ b/apps/server/server/routes/webhooks/github.post.ts @@ -9,12 +9,13 @@ import { deliveryClaimStore, taskStore } from '../../utils/store.js'; /** * The production GitHub webhook entry point at `POST /webhooks/github`. * - * This route only adapts transport: it maps headers and body onto the webhook contract and - * injects the deployment's durable stores. Signature verification, policy checks, delivery - * claims, and everything that can execute repository work stay behind `ingestWebhook`, and the - * durable `deliveryClaimStore` is what lets a redelivered issue event observe the recorded - * outcome across restarts and other instances instead of starting a duplicate run. Without a - * configured secret or checkout the route fails closed and ingests nothing. + * This route only adapts transport: it maps headers and body onto the provider-neutral webhook + * contract and injects the deployment's durable stores. Signature verification, provider + * routing, policy checks, delivery claims, and everything that can execute repository work stay + * behind `ingestWebhook`, and the durable `deliveryClaimStore` is what lets a redelivered issue + * event observe the recorded outcome across restarts and other instances instead of starting a + * duplicate run. Without a configured secret or checkout the route fails closed and ingests + * nothing. */ const route: EventHandlerWithFetch = defineHandler(async (event) => { try { @@ -23,16 +24,13 @@ const route: EventHandlerWithFetch = defineHandler(async (event) => { const checkoutPath = process.env.AGENT_ZERO_CHECKOUT_PATH; if (!checkoutPath) return json(503, { error: 'AGENT_ZERO_CHECKOUT_PATH is not configured' }); - const delivery = event.req.headers.get('x-github-delivery'); const outcome = await ingestWebhook( { - event: event.req.headers.get('x-github-event') ?? '', body: await event.req.text(), - signature: event.req.headers.get('x-hub-signature-256') ?? undefined, - ...(delivery ? { delivery } : {}), + headers: Object.fromEntries(event.req.headers.entries()), }, { - secret, + providers: [{ kind: 'github', secret }], checkoutPath, store: taskStore, deliveryClaims: deliveryClaimStore, diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 6b1876c..21a96c4 100644 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -12,11 +12,13 @@ export { publishEvidence, publishIssueValidation, runTask, + statusTokenFromEnvironment, taskInput, tasks, type IssuePullRequestOutcome, type IssueValidationOutcome, type OpenIssuePullRequestOptions, + type ProviderWebhookConfig, type PublishIssueValidationOptions, type PublishOptions, type RunTaskOptions, diff --git a/apps/server/src/router.test.ts b/apps/server/src/router.test.ts index 9adfc61..bb8e55b 100644 --- a/apps/server/src/router.test.ts +++ b/apps/server/src/router.test.ts @@ -25,6 +25,7 @@ import { publishEvidence, publishIssueValidation, runTask, + statusTokenFromEnvironment, taskInput, tasks, type WebhookOutcome, @@ -37,6 +38,14 @@ function sign(body: string): string { return `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`; } +/** A GitHub delivery for the router: raw body plus the headers GitHub would send. */ +function githubDelivery(event: string, body: string, signature = sign(body)) { + return { + body, + headers: { 'x-github-event': event, 'x-hub-signature-256': signature }, + }; +} + function reviewPayload(overrides: Record = {}): string { return JSON.stringify({ action: 'submitted', @@ -163,32 +172,41 @@ describe('runTask', () => { }); describe('ingestWebhook', () => { - const options = () => ({ secret, checkoutPath: checkout }); + const options = () => ({ + providers: [{ kind: 'github' as const, secret }], + checkoutPath: checkout, + }); + + it('rejects a delivery no configured provider recognizes', async () => { + const body = reviewPayload(); + await expect( + ingestWebhook({ body, headers: { 'x-gitlab-event': 'Note Hook' } }, options()), + ).resolves.toEqual({ + status: 'rejected', + reason: 'No configured provider recognizes this delivery', + }); + expect(tasks.size).toBe(0); + }); it('rejects a forged signature before parsing the payload', async () => { const body = reviewPayload(); await expect( - ingestWebhook( - { event: 'pull_request_review', body, signature: 'sha256=deadbeef' }, - options(), - ), + ingestWebhook(githubDelivery('pull_request_review', body, 'sha256=deadbeef'), options()), ).resolves.toEqual({ status: 'rejected', reason: 'Invalid webhook signature' }); expect(tasks.size).toBe(0); }); it('rejects a body that is not JSON', async () => { - const body = 'not json'; const outcome = await ingestWebhook( - { event: 'pull_request_review', body, signature: sign(body) }, + githubDelivery('pull_request_review', 'not json'), options(), ); expect(outcome).toEqual({ status: 'rejected', reason: 'Webhook body is not valid JSON' }); }); it('ignores an event that carries no claim to validate', async () => { - const body = reviewPayload({ state: 'approved' }); const outcome = await ingestWebhook( - { event: 'pull_request_review', body, signature: sign(body) }, + githubDelivery('pull_request_review', reviewPayload({ state: 'approved' })), options(), ); expect(outcome.status).toBe('ignored'); @@ -196,23 +214,23 @@ describe('ingestWebhook', () => { }); it('ignores its own account so a run cannot answer itself', async () => { - const body = reviewPayload({ user: { login: 'agent-zero[bot]' } }); const outcome = await ingestWebhook( - { event: 'pull_request_review', body, signature: sign(body) }, + githubDelivery('pull_request_review', reviewPayload({ user: { login: 'agent-zero[bot]' } })), { ...options(), ignoreAuthors: ['agent-zero[bot]'] }, ); expect(outcome.status).toBe('ignored'); }); it('runs an authenticated review in observe mode and never writes', async () => { - const body = reviewPayload(); const outcome = await ingestWebhook( - { event: 'pull_request_review', body, signature: sign(body) }, + githubDelivery('pull_request_review', reviewPayload()), options(), ); expect(outcome.status).toBe('accepted'); - if (outcome.status !== 'accepted' || !('pullRequest' in outcome)) return; - expect(outcome.pullRequest).toEqual({ + if (outcome.status !== 'accepted' || !('changeRequest' in outcome)) return; + expect(outcome.provider).toBe('github'); + expect(outcome.changeRequest).toEqual({ + provider: 'github', owner: 'acme', repo: 'app', number: 7, @@ -224,6 +242,29 @@ describe('ingestWebhook', () => { expect(outcome.result.summary).toContain('github:acme/app#7'); }); + it('accepts deliveries from a second configured provider in the same deployment', async () => { + const body = JSON.stringify({ + object_kind: 'note', + user: { username: 'alice' }, + project: { path_with_namespace: 'acme/app' }, + object_attributes: { + id: 11, + note: 'load() can return null', + noteable_type: 'MergeRequest', + }, + merge_request: { iid: 7, last_commit: { id: 'a'.repeat(40) } }, + }); + const outcome = await ingestWebhook( + { body, headers: { 'x-gitlab-event': 'Note Hook', 'x-gitlab-token': secret } }, + { ...options(), providers: [...options().providers, { kind: 'gitlab' as const, secret }] }, + ); + expect(outcome.status).toBe('accepted'); + if (outcome.status !== 'accepted' || !('changeRequest' in outcome)) return; + expect(outcome.provider).toBe('gitlab'); + expect(outcome.changeRequest.baseSha).toBeUndefined(); + expect(outcome.result.summary).toContain('gitlab:acme/app!7'); + }); + it('ignores proactive pull-request events until repository policy enables them', async () => { const body = JSON.stringify({ action: 'synchronize', @@ -234,9 +275,7 @@ describe('ingestWebhook', () => { head: { sha: 'a'.repeat(40) }, }, }); - await expect( - ingestWebhook({ event: 'pull_request', body, signature: sign(body) }, options()), - ).resolves.toEqual({ + await expect(ingestWebhook(githubDelivery('pull_request', body), options())).resolves.toEqual({ status: 'ignored', reason: 'Proactive review is disabled by repository policy', }); @@ -257,10 +296,7 @@ describe('ingestWebhook', () => { head: { sha: 'a'.repeat(40) }, }, }); - const outcome = await ingestWebhook( - { event: 'pull_request', body, signature: sign(body) }, - options(), - ); + const outcome = await ingestWebhook(githubDelivery('pull_request', body), options()); expect(outcome.status).toBe('accepted'); if (outcome.status !== 'accepted') return; expect(outcome.result.runner.writable).toBe(false); @@ -284,18 +320,28 @@ function issuePayload(overrides: Record = {}): string { }); } +/** A GitHub `issues` delivery, optionally carrying a delivery identifier for dedup tests. */ +function issuesDelivery(body: string, delivery?: string) { + return { + body, + headers: { + 'x-github-event': 'issues', + 'x-hub-signature-256': sign(body), + ...(delivery ? { 'x-github-delivery': delivery } : {}), + }, + }; +} + describe('ingestWebhook issue tasks', () => { const options = () => ({ - secret, + providers: [{ kind: 'github' as const, secret }], checkoutPath: checkout, deliveries: new Map>(), }); it('ignores issue events until repository policy enables them', async () => { const body = issuePayload(); - await expect( - ingestWebhook({ event: 'issues', body, signature: sign(body) }, options()), - ).resolves.toEqual({ + await expect(ingestWebhook(issuesDelivery(body), options())).resolves.toEqual({ status: 'ignored', reason: 'Issue tasks are disabled by repository policy', }); @@ -309,9 +355,10 @@ describe('ingestWebhook issue tasks', () => { 'utf8', ); const body = issuePayload({ labels: [{ name: 'bug' }] }); - await expect( - ingestWebhook({ event: 'issues', body, signature: sign(body) }, options()), - ).resolves.toEqual({ status: 'ignored', reason: 'No actionable issue task in this event' }); + await expect(ingestWebhook(issuesDelivery(body), options())).resolves.toEqual({ + status: 'ignored', + reason: 'No actionable issue task in this event', + }); expect(tasks.size).toBe(0); }); @@ -323,10 +370,7 @@ describe('ingestWebhook issue tasks', () => { ); await bindCheckout(); const body = issuePayload(); - const outcome = await ingestWebhook( - { event: 'issues', body, signature: sign(body) }, - options(), - ); + const outcome = await ingestWebhook(issuesDelivery(body), options()); expect(outcome.status).toBe('accepted'); if (outcome.status !== 'accepted' || !('issue' in outcome)) return; expect(outcome.issue).toEqual({ owner: 'acme', repo: 'app', number: 12 }); @@ -351,10 +395,10 @@ describe('ingestWebhook issue tasks', () => { ); await bindCheckout(); const body = issuePayload(); - const outcome = await ingestWebhook( - { event: 'issues', body, signature: sign(body) }, - { ...options(), github: { token: 'ghs_token_value' } }, - ); + const outcome = await ingestWebhook(issuesDelivery(body), { + ...options(), + github: { token: 'ghs_token_value' }, + }); expect(outcome.status).toBe('accepted'); if (outcome.status !== 'accepted' || !('validationComment' in outcome)) return; expect(outcome.validationComment).toEqual({ @@ -371,10 +415,7 @@ describe('ingestWebhook issue tasks', () => { ); await bindCheckout('https://github.com/donor/library.git'); const body = issuePayload(); - const outcome = await ingestWebhook( - { event: 'issues', body, signature: sign(body) }, - options(), - ); + const outcome = await ingestWebhook(issuesDelivery(body), options()); expect(outcome).toEqual({ status: 'rejected', reason: expect.stringContaining('checkout tracks donor/library') as unknown, @@ -389,10 +430,7 @@ describe('ingestWebhook issue tasks', () => { 'utf8', ); const body = issuePayload(); - const outcome = await ingestWebhook( - { event: 'issues', body, signature: sign(body) }, - options(), - ); + const outcome = await ingestWebhook(issuesDelivery(body), options()); expect(outcome).toEqual({ status: 'rejected', reason: expect.stringContaining('trusted origin repository') as unknown, @@ -409,7 +447,7 @@ describe('ingestWebhook issue tasks', () => { await bindCheckout(); const body = issuePayload(); const shared = options(); - const request = { event: 'issues', body, signature: sign(body), delivery: 'delivery-guid-1' }; + const request = issuesDelivery(body, 'delivery-guid-1'); const first = await ingestWebhook(request, shared); const second = await ingestWebhook(request, shared); expect(first.status).toBe('accepted'); @@ -426,8 +464,8 @@ describe('ingestWebhook issue tasks', () => { await bindCheckout(); const body = issuePayload(); const shared = options(); - const first = await ingestWebhook({ event: 'issues', body, signature: sign(body) }, shared); - const second = await ingestWebhook({ event: 'issues', body, signature: sign(body) }, shared); + const first = await ingestWebhook(issuesDelivery(body), shared); + const second = await ingestWebhook(issuesDelivery(body), shared); expect(second).toBe(first); expect(tasks.size).toBe(1); }); @@ -441,7 +479,7 @@ describe('ingestWebhook issue tasks', () => { await bindCheckout(); const deliveryClaims = new PersistentDeliveryClaimStore(memoryStorage(), []); const body = issuePayload(); - const request = { event: 'issues', body, signature: sign(body), delivery: 'delivery-guid-2' }; + const request = issuesDelivery(body, 'delivery-guid-2'); const first = await ingestWebhook(request, { ...options(), deliveryClaims }); // A fresh in-process registry simulates a restarted or different server instance. const second = await ingestWebhook(request, { ...options(), deliveryClaims }); @@ -463,10 +501,10 @@ describe('ingestWebhook issue tasks', () => { release: async () => undefined, }; const body = issuePayload(); - const outcome = await ingestWebhook( - { event: 'issues', body, signature: sign(body), delivery: 'delivery-guid-3' }, - { ...options(), deliveryClaims }, - ); + const outcome = await ingestWebhook(issuesDelivery(body, 'delivery-guid-3'), { + ...options(), + deliveryClaims, + }); expect(outcome).toEqual({ status: 'ignored', reason: 'This delivery is already claimed by an in-flight run', @@ -802,6 +840,7 @@ function recordingFetch(): { describe('publishEvidence', () => { const target = { + provider: 'github' as const, owner: 'acme', repo: 'app', number: 7, @@ -836,4 +875,32 @@ describe('publishEvidence', () => { expect(bodies[0]).toMatchObject({ head_sha: target.headSha, status: 'completed' }); expect(bodies[0]?.conclusion).not.toBe('success'); }); + + it('surfaces an explicit degradation on a provider without a neutral state', async () => { + const result = await runTask({ repository: checkout, feedback: 'x', mode: 'observe' }); + const { fetch, bodies } = recordingFetch(); + const outcome = await publishEvidence({ ...target, provider: 'gitlab' as const }, result.id, { + token: 'glpat_token_value', + fetch, + }); + expect(outcome.published).toBe(true); + expect(outcome.reason).toContain('no neutral state'); + expect(bodies[0]?.state).toBe('success'); + }); + + it('resolves independent credentials for the two Bitbucket products', () => { + const originalCloud = process.env.BITBUCKET_CLOUD_TOKEN; + const originalDataCenter = process.env.BITBUCKET_DATA_CENTER_TOKEN; + try { + process.env.BITBUCKET_CLOUD_TOKEN = 'cloud-credential'; + process.env.BITBUCKET_DATA_CENTER_TOKEN = 'data-center-credential'; + expect(statusTokenFromEnvironment('bitbucket-cloud')).toBe('cloud-credential'); + expect(statusTokenFromEnvironment('bitbucket-data-center')).toBe('data-center-credential'); + } finally { + if (originalCloud === undefined) delete process.env.BITBUCKET_CLOUD_TOKEN; + else process.env.BITBUCKET_CLOUD_TOKEN = originalCloud; + if (originalDataCenter === undefined) delete process.env.BITBUCKET_DATA_CENTER_TOKEN; + else process.env.BITBUCKET_DATA_CENTER_TOKEN = originalDataCenter; + } + }); }); diff --git a/apps/server/src/router.ts b/apps/server/src/router.ts index dc62a63..1f1537c 100644 --- a/apps/server/src/router.ts +++ b/apps/server/src/router.ts @@ -2,21 +2,6 @@ import { createHash } from 'node:crypto'; import { AgentZero } from '@agent-zero/agent'; import { loadConfig, mayModifyRepository } from '@agent-zero/config'; -import { - GitHubChecks, - GitHubIssueComments, - GitHubPullRequests, - issueBranchName, - issueInputFromTask, - parseIssueTask, - parseReviewEvent, - prepareIssuePullRequest, - prepareIssueValidationComment, - reviewInputFromEvent, - verifyWebhook, - type BranchFile, - type IssueTask, -} from '@agent-zero/github'; import { modelFromEnvironment } from '@agent-zero/models'; import { createRunner, @@ -33,10 +18,27 @@ import { secretValuesFromEnvironment, taskId, type IssueRef, - type PullRequestRef, type ReviewInput, type TaskResult, } from '@agent-zero/shared'; +import { + createProvider, + GitHubIssueComments, + GitHubPullRequests, + issueBranchName, + issueInputFromTask, + parseIssueTask, + prepareIssuePullRequest, + prepareIssueValidationComment, + providerForDelivery, + readHeader, + reviewInputFromEvent, + type BranchFile, + type ChangeRequestRef, + type IssueTask, + type ProviderKind, + type WebhookHeaders, +} from '@agent-zero/source-control'; import { z } from 'zod'; import { @@ -251,20 +253,25 @@ export async function decideApproval( } export interface WebhookRequest { - event: string; + /** The raw request body, exactly as received; signatures verify these bytes. */ body: string; - signature: string | undefined; - /** GitHub's `X-GitHub-Delivery` identifier, used to recognize redeliveries of the same event. */ - delivery?: string; + headers: WebhookHeaders; } -export interface WebhookOptions { +/** One source-control provider a deployment accepts webhooks from, with its own secret. */ +export interface ProviderWebhookConfig { + kind: ProviderKind; secret: string; +} + +export interface WebhookOptions { + /** Providers this deployment listens to. One deployment may connect several. */ + providers: readonly ProviderWebhookConfig[]; checkoutPath: string; ignoreAuthors?: readonly string[]; store?: TaskStore; scheduler?: TaskScheduler; - /** Credentials for publishing an issue run's verified changes as a pull request. */ + /** Credentials for publishing a GitHub issue run's verified changes as a pull request. */ github?: { token: string | undefined; fetch?: typeof globalThis.fetch }; /** In-flight delivery-key claims for issue events; defaults to a process-wide registry. */ deliveries?: Map>; @@ -278,7 +285,12 @@ export interface WebhookOptions { export type WebhookOutcome = | { status: 'rejected'; reason: string } | { status: 'ignored'; reason: string } - | { status: 'accepted'; result: TaskResult; pullRequest: PullRequestRef } + | { + status: 'accepted'; + result: TaskResult; + provider: ProviderKind; + changeRequest: ChangeRequestRef; + } | { status: 'accepted'; result: TaskResult; @@ -291,13 +303,28 @@ export type WebhookOutcome = validationComment: { posted: boolean; reason: string | null }; }; +/** + * The GitHub issue-to-PR workflow currently only exists on the GitHub adapter: it depends on the + * Git data API for branch and pull-request creation and has no equivalent implemented for the + * other providers yet. It is dispatched here, ahead of the provider-neutral review-event path, + * because it is not part of the shared `SourceControlProvider` review-event contract. + */ export async function ingestWebhook( request: WebhookRequest, options: WebhookOptions, ): Promise { - if (!verifyWebhook(request.body, request.signature, options.secret)) + const kinds = options.providers.map((provider) => provider.kind); + const provider = providerForDelivery(request.headers, kinds); + if (!provider) + return { status: 'rejected', reason: 'No configured provider recognizes this delivery' }; + const secret = options.providers.find((entry) => entry.kind === provider.kind)?.secret ?? ''; + + if (!provider.verifyWebhook({ body: request.body, headers: request.headers }, secret)) return { status: 'rejected', reason: 'Invalid webhook signature' }; + const eventName = provider.eventName(request.headers); + if (eventName === undefined) return { status: 'ignored', reason: 'The delivery names no event' }; + let payload: unknown; try { payload = JSON.parse(request.body); @@ -305,10 +332,11 @@ export async function ingestWebhook( return { status: 'rejected', reason: 'Webhook body is not valid JSON' }; } - if (request.event === 'issues') return ingestIssueEvent(request, payload, options); + if (provider.kind === 'github' && eventName === 'issues') + return ingestIssueEvent(request, eventName, payload, options); - const event = parseReviewEvent( - request.event, + const event = provider.parseReviewEvent( + eventName, payload, options.ignoreAuthors ? { ignoreAuthors: options.ignoreAuthors } : {}, ); @@ -330,7 +358,12 @@ export async function ingestWebhook( reviewInputFromEvent(event, { checkoutPath: options.checkoutPath, mode }), runOptions, ); - return { status: 'accepted', result, pullRequest: event.pullRequest }; + return { + status: 'accepted', + result, + provider: provider.kind, + changeRequest: event.changeRequest, + }; } /** Process-wide issue delivery claims, bounded so redelivery tracking cannot grow without limit. */ @@ -338,7 +371,8 @@ const defaultDeliveries = new Map>(); const MAX_DELIVERY_CLAIMS = 10_000; /** - * Run one scoped issue task and, when the run earns it, publish the result as a pull request. + * Run one scoped GitHub issue task and, when the run earns it, publish the result as a pull + * request. * * The issue text is untrusted input: it becomes feedback for the runtime to validate, the run mode * comes only from repository policy, and policy must opt in (`issues.enabled` plus the required @@ -350,6 +384,7 @@ const MAX_DELIVERY_CLAIMS = 10_000; */ async function ingestIssueEvent( request: WebhookRequest, + eventName: string, payload: unknown, options: WebhookOptions, ): Promise { @@ -357,7 +392,7 @@ async function ingestIssueEvent( if (!config.issues.enabled) return { status: 'ignored', reason: 'Issue tasks are disabled by repository policy' }; - const task = parseIssueTask('issues', payload, { + const task = parseIssueTask(eventName, payload, { requireLabel: config.issues.requireLabel, ...(options.ignoreAuthors ? { ignoreAuthors: options.ignoreAuthors } : {}), }); @@ -370,7 +405,7 @@ async function ingestIssueEvent( // The claim is registered before the work starts, so a concurrent redelivery shares the // in-flight outcome rather than racing a second run past the check above. const deliveries = options.deliveries ?? defaultDeliveries; - const key = issueDeliveryKey(request); + const key = issueDeliveryKey(request, eventName); const claimed = deliveries.get(key); if (claimed) return claimed; @@ -530,10 +565,10 @@ export async function publishIssueValidation( * The idempotency key for one issue delivery. GitHub's delivery identifier is preferred; without * one, a redelivered event still carries a byte-identical body, so its digest recognizes it. */ -function issueDeliveryKey(request: WebhookRequest): string { - if (request.delivery !== undefined && request.delivery.length > 0) - return `delivery:${request.delivery}`; - return `payload:${createHash('sha256').update(`${request.event}\n${request.body}`).digest('hex')}`; +function issueDeliveryKey(request: WebhookRequest, eventName: string): string { + const delivery = readHeader(request.headers, 'x-github-delivery'); + if (delivery !== undefined && delivery.length > 0) return `delivery:${delivery}`; + return `payload:${createHash('sha256').update(`${eventName}\n${request.body}`).digest('hex')}`; } export interface OpenIssuePullRequestOptions { @@ -617,27 +652,48 @@ export async function openIssuePullRequest( export interface PublishOptions { token: string | undefined; + /** API base URL, required for self-hosted providers. */ + baseUrl?: string; fetch?: typeof globalThis.fetch; store?: TaskStore; } +const statusTokenVariables: Record = { + github: 'GITHUB_TOKEN', + gitlab: 'GITLAB_TOKEN', + 'bitbucket-cloud': 'BITBUCKET_CLOUD_TOKEN', + 'bitbucket-data-center': 'BITBUCKET_DATA_CENTER_TOKEN', + gitea: 'GITEA_TOKEN', +}; + +/** The status credential for one provider, from its fixed environment variable. */ +export function statusTokenFromEnvironment(kind: ProviderKind): string | undefined { + return process.env[statusTokenVariables[kind]]; +} + export function githubTokenFromEnvironment(): string | undefined { - return process.env.GITHUB_TOKEN; + return statusTokenFromEnvironment('github'); } export async function publishEvidence( - target: PullRequestRef, + target: ChangeRequestRef, taskIdentifier: string, options: PublishOptions, ): Promise<{ published: boolean; reason?: string }> { - if (!options.token) return { published: false, reason: 'GITHUB_TOKEN is not configured' }; + if (!options.token) + return { + published: false, + reason: `${statusTokenVariables[target.provider]} is not configured`, + }; const task = await (options.store ?? defaultStore).get(taskIdentifier); if (!task?.evidence) return { published: false, reason: `Unknown task: ${taskIdentifier}` }; - await new GitHubChecks({ + const publisher = createProvider(target.provider).statusPublisher({ token: options.token, + ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}), ...(options.fetch ? { fetch: options.fetch } : {}), - }).publish(target, task.evidence); - return { published: true }; + }); + const publication = await publisher.publish(target, task.evidence); + return { published: true, ...(publication.degraded ? { reason: publication.degraded } : {}) }; } /** @@ -665,8 +721,10 @@ function checkoutRepositoryMismatch( /** * Read each changed file's final content through the run's own boundary. * - * The bytes are captured raw and stored as base64: a string read would replace invalid UTF-8 - * sequences, so a verified binary change would be published with corrupted contents. + * The snapshot is captured the moment a run finishes, while the runner's lease is still held, so a + * later publication reads exactly what the run verified rather than whatever the checkout holds by + * the time someone gets around to publishing it — a window where the working tree could have been + * mutated and the change would be published with corrupted contents. */ async function snapshotChangedFiles( runner: Runner, diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json index 2e9d943..3d7a7a5 100644 --- a/apps/server/tsconfig.json +++ b/apps/server/tsconfig.json @@ -11,7 +11,7 @@ "references": [ { "path": "../../packages/agent" }, { "path": "../../packages/config" }, - { "path": "../../packages/github" }, + { "path": "../../packages/source-control" }, { "path": "../../packages/models" }, { "path": "../../packages/runner" }, { "path": "../../packages/shared" } diff --git a/docs/architecture.md b/docs/architecture.md index 3c708fa..2ce0b1f 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -3,9 +3,9 @@ Agent Zero is organized as a dependency-directed monorepo. The core decides what should happen; adapters decide how external systems communicate with it; the runner controls what is allowed to happen to a checkout. ```text -GitHub adapter ─┐ -CLI adapter ────┼──> agent runtime ──> runner boundary ──> isolated checkout -oRPC server ────┘ │ +Source-control adapters ─┐ +CLI adapter ─────────────┼──> agent runtime ──> runner boundary ──> isolated checkout +oRPC server ─────────────┘ │ ├──> model abstraction ──> provider └──> shared contracts @@ -15,7 +15,7 @@ Nuxt dashboard ───> operational interface ──> auth adapter ──> ses ## Dependency direction - `shared` contains stable data contracts and must not import feature packages. -- `config`, `models`, `github`, and `runner` implement focused capabilities around shared contracts. +- `config`, `models`, `source-control`, and `runner` implement focused capabilities around shared contracts. - `agent` composes policies and state transitions without knowing HTTP or terminal details. - `cli` is an entry-point adapter. It may depend on the runtime, but the runtime must not depend on it. - `apps/server` is an entry-point adapter and composition root. Like `cli`, it may depend on the runtime; the runtime must not depend on it. @@ -26,7 +26,7 @@ If a change creates a reverse dependency, move the shared contract inward instea ## Execution boundary -Only `packages/runner` may execute commands or mutate a target repository at runtime. The boundary is responsible for validating working directories, arguments, timeouts, output limits, and execution mode. A transport handler, GitHub adapter, model provider, or state transition must request runner work through typed contracts rather than invoking a shell directly. +Only `packages/runner` may execute commands or mutate a target repository at runtime. The boundary is responsible for validating working directories, arguments, timeouts, output limits, and execution mode. A transport handler, source-control adapter, model provider, or state transition must request runner work through typed contracts rather than invoking a shell directly. `observe` is the default mode. It can inspect and report but cannot write. Enabling `fix` requires both an explicit mode and repository policy permission. @@ -58,13 +58,13 @@ Transport concerns stop here: headers, status mapping, and request objects never ## Issue-to-PR workflow -A scoped GitHub issue can become a verified, review-ready pull request without ever widening the runtime's authority. The entry point is the same authenticated webhook path: an `issues` event is parsed in `packages/github` and produces a task only when repository policy has opted in (`issues.enabled`) and the issue carries the `issues.requireLabel` label, so arbitrary issue text can never start a run. The issue's title and body travel to the runtime as bounded, untrusted feedback with trigger `issue` — data to validate, never instructions — and the run mode comes only from repository policy, never from the wire. +A scoped GitHub issue can become a verified, review-ready pull request without ever widening the runtime's authority. The entry point is the same authenticated webhook path: an `issues` event is parsed in `packages/source-control` and produces a task only when repository policy has opted in (`issues.enabled`) and the issue carries the `issues.requireLabel` label, so arbitrary issue text can never start a run. The issue's title and body travel to the runtime as bounded, untrusted feedback with trigger `issue` — data to validate, never instructions — and the run mode comes only from repository policy, never from the wire. The run itself is the ordinary lifecycle. During planning the model records verifiable acceptance criteria for the issue alongside its plan; the runtime bounds them and carries them into the task result and evidence bundle. Writes still require an explicit write mode, `autofix.enabled`, confidence, an allowed change-risk class, and — by default, like proactive work — an isolated runner. High-impact changes stop at `needs-human` exactly as before. The validation verdict is reported back where the work was requested. Unless `issues.validationComment` is disabled, a finished run posts one comment on the issue, composed by `prepareIssueValidationComment` from the persisted evidence alone: **confirmed** when repository evidence supports the report, **not confirmed** with every rejection reason when it does not, **inconclusive** when a human should decide. A run that failed before reaching a verdict posts nothing rather than something misleading, and the `GitHubIssueComments` adapter can only add a comment — it has no path to label, edit, or close an issue. The comment claims a fix exists only when the run was actually verified. -Publication has a single home: `prepareIssuePullRequest` in `packages/github` decides whether a finished run has earned a pull request, and composes it when it has. It refuses any run that is not `completed`, not `accepted`, not verified by the repository's own checks, changed no files, or proposes a high-impact change, so a pull request can never claim success its evidence does not support — the body _is_ the rendered evidence, including the acceptance criteria. The composition root in `apps/server` then reads the verified file contents through a read-only runner and hands them to the `GitHubPullRequests` adapter, which publishes them as a commit on a fresh `issues.branchPrefix` branch through the Git data API and opens the pull request against the default branch. The branch name is assembled only from operator policy, the issue number, and the task identifier; an existing ref is never force-updated; and the default branch is never committed to. A failed publication never fails the run — the evidence is already persisted — it is reported as the reason no pull request exists. +Publication has a single home: `prepareIssuePullRequest` in `packages/source-control` decides whether a finished run has earned a pull request, and composes it when it has. It refuses any run that is not `completed`, not `accepted`, not verified by the repository's own checks, changed no files, or proposes a high-impact change, so a pull request can never claim success its evidence does not support — the body _is_ the rendered evidence, including the acceptance criteria. The composition root in `apps/server` then reads the verified file contents through a read-only runner and hands them to the `GitHubPullRequests` adapter, which publishes them as a commit on a fresh `issues.branchPrefix` branch through the Git data API and opens the pull request against the default branch. The branch name is assembled only from operator policy, the issue number, and the task identifier; an existing ref is never force-updated; and the default branch is never committed to. A failed publication never fails the run — the evidence is already persisted — it is reported as the reason no pull request exists. ## Authentication boundary @@ -106,7 +106,7 @@ Validation lives in `packages/agent/src/validation.ts` and is independent of any `TaskResult.verified` is derived in exactly one place, at the point a run produces its terminal result: it requires a completed state, an applied change, and every executed check passing. No branch can assert verification it did not earn, which is what makes "a failed verification is never presented as success" a property of the code rather than a convention. -`EvidenceBundle` and its Markdown renderer live in `packages/shared` because both the GitHub adapter and the CLI consume them, and because rendering is a pure function over contracts with no I/O. Terminal states map deterministically onto GitHub check conclusions in `packages/github`. +`EvidenceBundle` and its Markdown renderer live in `packages/shared` because both the source-control adapters and the CLI consume them, and because rendering is a pure function over contracts with no I/O. Terminal states map deterministically onto a provider-neutral run outcome in `packages/source-control`, which each provider adapter translates into its own status vocabulary — explicitly noting any conclusion the platform cannot express (see `docs/source-control-providers.md`). ## Adding a capability diff --git a/docs/source-control-providers.md b/docs/source-control-providers.md new file mode 100644 index 0000000..e801894 --- /dev/null +++ b/docs/source-control-providers.md @@ -0,0 +1,99 @@ +# Source-control providers + +Agent Zero integrates with source-control platforms through `packages/source-control`: a +provider-neutral boundary with one adapter per platform. The agent runtime consumes only shared +contracts (`ReviewInput`, `FeedbackItem`, `PullRequestRef`); provider payload shapes, URLs, IDs, +event names, and credentials never cross the boundary. One deployment may connect repositories +from several providers at once: inbound deliveries are routed to the adapter that recognizes +their headers, and each configured provider keeps its own webhook secret. + +The find → fix → verify workflow is identical on every provider. What differs is what each +platform can express, and the boundary makes those differences explicit instead of guessing. + +## Contracts + +- `SourceControlProvider` — one platform: webhook recognition, authentication, event + normalization, and status publishing. +- `ProviderCapabilities` — what the adapter can actually deliver. Flags describe the webhook and + API surface the adapter consumes, not the platform's brochure. +- `ChangeRequestRef` — a provider-neutral pull-/merge-request reference. `baseSha` is present + only when the provider's payload carries a diff base. +- `runOutcome` — the provider-neutral meaning of a finished run (`success`, `failure`, + `neutral`, `action-required`), derived from the evidence bundle in exactly one place. +- `StatusPublication` — what was actually reported, including a `degraded` note whenever an + outcome had no native equivalent on the platform. + +## Capability matrix + +| Capability | GitHub | GitLab | Bitbucket Cloud | Bitbucket Data Center | Gitea / Forgejo | +| ---------------------- | ----------- | ------------- | --------------- | --------------------- | --------------- | +| Webhook authentication | HMAC-SHA256 | shared token | HMAC-SHA256 | HMAC-SHA256 | HMAC-SHA256 | +| Status reporting | check runs | commit status | build status | build status | commit status | +| Neutral conclusion | native | degraded | degraded | degraded | degraded | +| Action-required | native | degraded | degraded | degraded | degraded | +| Review submissions | yes | notes only | comments only | comments only | yes | +| Formal change requests | yes | no text | no text | no text | yes | +| Inline comment anchors | yes | yes | yes | not delivered | not delivered | +| Bot author detection | yes | no | no | no | no | +| Diff base in payload | yes | no | yes | yes | yes | + +Notes on explicit degradation: + +- **Statuses.** Only GitHub can express `neutral` and `action_required`. Elsewhere a neutral + outcome (for example, incorrect feedback rejected with evidence) is reported as the platform's + success state, and action-required maps to the platform's blocking state (`failed` on GitLab + and Bitbucket, `warning` on Gitea). Every mapping is returned in `StatusPublication.degraded` + so callers can surface it; a failed verification is never presented as success anywhere. +- **Diff base.** GitLab merge-request webhooks carry no base commit. The adapter never invents + one: the run receives no pull-request range and falls back to runner-side diff discovery. +- **Formal change requests.** GitLab approvals/"request changes", Bitbucket's + `changes_request_created`, and Bitbucket Data Center's `needs_work` arrive without text, so + there is no claim to validate and the events are ignored. Reviewer text arrives as comments. +- **Bots.** Only GitHub payloads mark bot authors, so `allowBots: false` filters bots there and + is documented as unenforceable elsewhere. Self-replies are prevented on every provider through + `ignoreAuthors`. + +## Webhook routing + +Deliveries are identified by provider headers, not by URL: + +| Provider | Event header | Authentication header | +| --------------------- | ----------------------------------- | ------------------------------------------------------ | +| GitHub | `X-GitHub-Event` | `X-Hub-Signature-256` (`sha256=`) | +| GitLab | `X-Gitlab-Event` | `X-Gitlab-Token` (constant-time) | +| Bitbucket Cloud | `X-Event-Key` | `X-Hub-Signature` (`sha256=`) | +| Bitbucket Data Center | `X-Event-Key` | `X-Hub-Signature` (`sha256=`) | +| Gitea / Forgejo | `X-Gitea-Event` / `X-Forgejo-Event` | `X-Gitea-Signature` / `X-Forgejo-Signature` (bare hex) | + +Gitea and Forgejo also send GitHub compatibility headers; the registry consults their adapter +first and the GitHub adapter declines deliveries carrying a Gitea or Forgejo header. The two +Bitbucket products are distinguished by event-key shape (`pullrequest:*` versus `pr:*`). + +Regardless of provider, a webhook can never escalate a run: parsed events produce `observe`-mode +input unless the deployment's own policy chooses otherwise, and an unverifiable delivery is +rejected before its payload is parsed. + +## Status credentials + +Status publishing reads one fixed environment variable per provider; credentials are sent only +as an `Authorization` header and are redacted from any error raised. + +| Provider | Variable | Notes | +| --------------------- | ----------------------------- | ---------------------------------------- | +| GitHub | `GITHUB_TOKEN` | Checks API | +| GitLab | `GITLAB_TOKEN` | `baseUrl` for GitLab Self-Managed | +| Bitbucket Cloud | `BITBUCKET_CLOUD_TOKEN` | access token with repository write scope | +| Bitbucket Data Center | `BITBUCKET_DATA_CENTER_TOKEN` | `baseUrl` required | +| Gitea / Forgejo | `GITEA_TOKEN` | `baseUrl` required | + +The two Bitbucket products keep separate variables because a deployment may connect both with +distinct credentials; a shared variable would force one publication path to authenticate with +the other product's token. + +## Conformance + +Every adapter must pass the same conformance suite (`src/conformance.ts`), driven by authentic +signed fixtures per provider: recognition, constant-time authentication with forgery and +tampering rejection, proactive and feedback normalization, self-reply suppression, junk-payload +tolerance, observe-by-default input, credential-free status publishing, and explicit degradation +of unsupported conclusions. New provider adapters start by supplying fixtures to this suite. diff --git a/packages/github/src/checks.test.ts b/packages/github/src/checks.test.ts deleted file mode 100644 index a552a85..0000000 --- a/packages/github/src/checks.test.ts +++ /dev/null @@ -1,201 +0,0 @@ -import type { CheckResult, EvidenceBundle } from '@agent-zero/shared'; -import { describe, expect, it } from 'vitest'; - -import { checkConclusion, GitHubChecks } from './checks.js'; - -const target = { - owner: 'acme', - repo: 'app', - number: 7, - baseSha: 'b'.repeat(40), - headSha: 'a'.repeat(40), -}; -const REDACTED_MARKER = /\[redacted]/; -const LEAKED_TOKEN = /ghs_token_value/; - -const passing: CheckResult = { - command: 'pnpm run test', - exitCode: 0, - stdout: '', - stderr: '', - durationMs: 5, -}; -const failing: CheckResult = { ...passing, exitCode: 1, stderr: 'assertion failed' }; - -function bundle(overrides: Partial = {}): EvidenceBundle { - return { - taskId: 'az_test', - state: 'completed', - verdict: 'accepted', - verified: true, - mode: 'fix', - trigger: 'feedback', - source: 'github:acme/app#7', - issue: null, - runner: { kind: 'container', isolated: true, writable: true, network: 'none' }, - finding: null, - plan: [], - acceptanceCriteria: [], - changedFiles: ['src/user.ts'], - checks: [passing], - attempts: 1, - transitions: [], - summary: 'Fixed and verified', - ...overrides, - }; -} - -describe('checkConclusion', () => { - it('reports success only for a verified run', () => { - expect(checkConclusion(bundle())).toBe('success'); - }); - - it('never reports success when a check failed', () => { - expect(checkConclusion(bundle({ checks: [passing, failing], verified: true }))).toBe('failure'); - }); - - it('reports a crashed run as a failure', () => { - expect(checkConclusion(bundle({ state: 'failed', verified: false, checks: [] }))).toBe( - 'failure', - ); - }); - - it('asks for action when a run needs a human', () => { - expect(checkConclusion(bundle({ state: 'needs-human', verified: false, checks: [] }))).toBe( - 'action_required', - ); - }); - - it('reports rejected feedback as neutral rather than a pull-request failure', () => { - expect( - checkConclusion( - bundle({ verdict: 'rejected', verified: false, checks: [], changedFiles: [] }), - ), - ).toBe('neutral'); - }); - - it('reports an observe-only run as neutral', () => { - expect( - checkConclusion(bundle({ mode: 'observe', verified: false, checks: [], changedFiles: [] })), - ).toBe('neutral'); - }); -}); - -/** One recorded request, already decoded so tests never stringify an unknown body. */ -interface RecordedCall { - url: string; - headers: Headers; - body: Record; -} - -type FetchArguments = Parameters; - -const token = 'ghs_token_value_1234567890'; - -function requestUrl(url: FetchArguments[0]): string { - if (typeof url === 'string') return url; - return url instanceof URL ? url.href : url.url; -} - -function readBody(body: NonNullable['body']): Record { - if (typeof body !== 'string') return {}; - const parsed: unknown = JSON.parse(body); - return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) - ? { ...parsed } - : {}; -} - -function created(): Response { - return new Response(JSON.stringify({ id: 42 }), { - status: 201, - headers: { 'content-type': 'application/json' }, - }); -} - -function client(handler: () => Response = created): { - checks: GitHubChecks; - calls: RecordedCall[]; -} { - const calls: RecordedCall[] = []; - const checks = new GitHubChecks({ - token, - fetch: async (url, init) => { - calls.push({ - url: requestUrl(url), - headers: new Headers(init?.headers), - body: readBody(init?.body), - }); - return handler(); - }, - }); - return { checks, calls }; -} - -/** The check output object a completion request carries. */ -function readOutput(call: RecordedCall | undefined): { title: string; text: string } { - const output = call?.body.output; - if (typeof output !== 'object' || output === null) throw new Error('no check output recorded'); - const record: Record = { ...output }; - return { - title: typeof record.title === 'string' ? record.title : '', - text: typeof record.text === 'string' ? record.text : '', - }; -} - -describe('GitHubChecks', () => { - it('opens an in-progress run against the head commit', async () => { - const { checks, calls } = client(created); - await expect(checks.start(target)).resolves.toBe(42); - expect(calls[0]?.url).toBe('https://api.github.com/repos/acme/app/check-runs'); - expect(calls[0]?.body).toMatchObject({ - head_sha: target.headSha, - status: 'in_progress', - name: 'Agent Zero', - }); - }); - - it('sends the token only as an authorization header', async () => { - const { checks, calls } = client(created); - await checks.publish(target, bundle()); - expect(calls[0]?.headers.get('authorization')).toBe(`Bearer ${token}`); - expect(JSON.stringify(calls[0]?.body)).not.toContain('ghs_token_value'); - expect(calls[0]?.url).not.toContain('ghs_token_value'); - }); - - it('completes a run with the evidence report and a matching conclusion', async () => { - const { checks, calls } = client(created); - await checks.complete(target, 42, bundle({ verified: false, checks: [failing] })); - expect(calls[0]?.url).toBe('https://api.github.com/repos/acme/app/check-runs/42'); - expect(calls[0]?.body.conclusion).toBe('failure'); - const output = readOutput(calls[0]); - expect(output.title).toContain('Feedback accepted'); - expect(output.title).toContain('failed'); - expect(output.text).toContain('assertion failed'); - }); - - it('keeps the report inside the GitHub output limit', async () => { - const { checks, calls } = client(created); - await checks.publish( - target, - bundle({ verified: false, checks: [{ ...failing, stderr: 'x'.repeat(200_000) }] }), - ); - expect(readOutput(calls[0]).text.length).toBeLessThanOrEqual(60_000); - }); - - it('redacts the token from a failed request instead of leaking it', async () => { - const { checks } = client(() => new Response(`bad credentials for ${token}`, { status: 401 })); - await expect(checks.publish(target, bundle())).rejects.toThrow(REDACTED_MARKER); - await expect(checks.publish(target, bundle())).rejects.not.toThrow(LEAKED_TOKEN); - }); - - it('fails loudly when GitHub does not return a check run id', async () => { - const { checks } = client( - () => - new Response(JSON.stringify({ message: 'ok' }), { - status: 201, - headers: { 'content-type': 'application/json' }, - }), - ); - await expect(checks.start(target)).rejects.toThrow('did not return a check run id'); - }); -}); diff --git a/packages/github/src/checks.ts b/packages/github/src/checks.ts deleted file mode 100644 index abe23bc..0000000 --- a/packages/github/src/checks.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { - evidenceTitle, - redactSecrets, - renderEvidenceMarkdown, - secretValuesFromEnvironment, - type EvidenceBundle, - type PullRequestRef, -} from '@agent-zero/shared'; - -/** Conclusions a check run may report. */ -export type CheckConclusion = 'success' | 'failure' | 'neutral' | 'action_required'; - -/** GitHub caps check output fields; staying under the limit keeps a report from being rejected. */ -const MAX_OUTPUT = 60_000; -const MAX_TITLE = 255; -const MAX_SUMMARY = 4_000; - -export interface GitHubChecksOptions { - token: string; - baseUrl?: string; - /** Check run name, so several Agent Zero configurations can report side by side. */ - name?: string; - fetch?: typeof globalThis.fetch; -} - -/** - * Decide what a run means for a pull request. - * - * Verification is the only thing that produces `success`. A failing check, an unreached terminal - * state, or an unverified change can never be reported as a passing check, which is what keeps the - * GitHub status honest. Rejecting incorrect feedback is a legitimate, neutral outcome rather than a - * failure: nothing is wrong with the pull request. - */ -export function checkConclusion(bundle: EvidenceBundle): CheckConclusion { - if (bundle.state === 'failed') return 'failure'; - if (bundle.checks.some((check) => check.exitCode !== 0)) return 'failure'; - if (bundle.state === 'needs-human') return 'action_required'; - if (bundle.verified) return 'success'; - return 'neutral'; -} - -/** - * Publishes run evidence to the GitHub Checks API. - * - * The token is only ever sent as an Authorization header, and any error body is redacted before it - * is raised, so a failed publish cannot leak a credential into logs. - */ -export class GitHubChecks { - private readonly baseUrl: string; - private readonly name: string; - private readonly request: typeof globalThis.fetch; - - constructor(private readonly options: GitHubChecksOptions) { - this.baseUrl = options.baseUrl ?? 'https://api.github.com'; - this.name = options.name ?? 'Agent Zero'; - this.request = options.fetch ?? globalThis.fetch; - } - - /** Open an in-progress check run so a long verification is visible while it happens. */ - async start(target: PullRequestRef): Promise { - const body = await this.send('POST', `/repos/${target.owner}/${target.repo}/check-runs`, { - name: this.name, - head_sha: target.headSha, - status: 'in_progress', - }); - return readCheckRunId(body); - } - - /** Complete an existing check run with the run's evidence. */ - async complete( - target: PullRequestRef, - checkRunId: number, - bundle: EvidenceBundle, - ): Promise { - await this.send( - 'PATCH', - `/repos/${target.owner}/${target.repo}/check-runs/${String(checkRunId)}`, - this.completionPayload(bundle), - ); - } - - /** Create an already-completed check run, for a verification that finished quickly. */ - async publish(target: PullRequestRef, bundle: EvidenceBundle): Promise { - const body = await this.send('POST', `/repos/${target.owner}/${target.repo}/check-runs`, { - name: this.name, - head_sha: target.headSha, - ...this.completionPayload(bundle), - }); - return readCheckRunId(body); - } - - /** The request body for a finished check run, including the rendered evidence report. */ - completionPayload(bundle: EvidenceBundle): Record { - const secrets = secretValuesFromEnvironment(); - return { - status: 'completed', - conclusion: checkConclusion(bundle), - output: { - title: redactSecrets(evidenceTitle(bundle), secrets).slice(0, MAX_TITLE), - summary: redactSecrets(bundle.summary, secrets).slice(0, MAX_SUMMARY), - text: renderEvidenceMarkdown(bundle, { maxLength: MAX_OUTPUT, secrets }), - }, - }; - } - - private async send( - method: 'POST' | 'PATCH', - path: string, - body: Record, - ): Promise { - const response = await this.request(`${this.baseUrl}${path}`, { - method, - headers: { - accept: 'application/vnd.github+json', - authorization: `Bearer ${this.options.token}`, - 'content-type': 'application/json', - 'x-github-api-version': '2022-11-28', - }, - body: JSON.stringify(body), - }); - if (!response.ok) { - const detail = redactSecrets(await response.text(), [ - this.options.token, - ...secretValuesFromEnvironment(), - ]); - throw new Error( - `GitHub check run request failed (${String(response.status)}): ${detail.slice(0, 1_000)}`, - ); - } - return response.json(); - } -} - -function readCheckRunId(body: unknown): number { - if (typeof body === 'object' && body !== null && 'id' in body && typeof body.id === 'number') - return body.id; - throw new Error('GitHub did not return a check run id'); -} diff --git a/packages/github/src/events.ts b/packages/github/src/events.ts deleted file mode 100644 index 739988b..0000000 --- a/packages/github/src/events.ts +++ /dev/null @@ -1,228 +0,0 @@ -import { - isRepositoryRelativePath, - type FeedbackItem, - type PullRequestRef, - type ReviewInput, - type ReviewTrigger, - type RunMode, -} from '@agent-zero/shared'; - -/** Webhook event names this adapter understands. */ -export const supportedEvents = [ - 'pull_request', - 'pull_request_review', - 'pull_request_review_comment', -] as const; -export type SupportedEvent = (typeof supportedEvents)[number]; - -/** A review event normalized away from GitHub's payload shape. */ -export interface ReviewEvent { - trigger: ReviewTrigger; - pullRequest: PullRequestRef; - items: FeedbackItem[]; - /** True when at least one item came from a formal request for changes. */ - requestedChanges: boolean; -} - -export interface ParseOptions { - /** - * Logins whose feedback is ignored, normally including the account Agent Zero posts as. - * - * Without this a run reacts to its own comments and loops. - */ - ignoreAuthors?: readonly string[]; - /** Whether feedback from bot accounts is ingested. AI reviewers are a first-class source. */ - allowBots?: boolean; -} - -/** Untrusted comment bodies are bounded before they reach a prompt or an evidence report. */ -const MAX_BODY = 8_000; -const COMMIT_SHA = /^[0-9a-f]{7,64}$/i; - -/** - * Turn a GitHub webhook payload into a review event, or null when there is nothing to act on. - * - * The payload is untrusted, so every field is checked rather than asserted. Approvals and - * dismissals produce nothing: there is no claim to validate. - */ -export function parseReviewEvent( - event: string, - payload: unknown, - options: ParseOptions = {}, -): ReviewEvent | null { - if (!isRecord(payload)) return null; - const pullRequest = readPullRequest(payload); - if (!pullRequest) return null; - - if (event === 'pull_request') { - if (!isProactiveAction(payload.action)) return null; - return { trigger: 'proactive', pullRequest, items: [], requestedChanges: false }; - } - - const items = - event === 'pull_request_review_comment' - ? readReviewComment(payload, options) - : event === 'pull_request_review' - ? readReview(payload, options) - : null; - if (!items || items.length === 0) return null; - - return { - trigger: 'feedback', - pullRequest, - items, - requestedChanges: items.some((item) => item.requestedChanges), - }; -} - -/** - * Build runtime input for a review event. - * - * The mode is supplied by the caller and defaults to `observe`, so an inbound webhook can never - * escalate a run into writing to a repository on its own. - */ -export function reviewInputFromEvent( - event: ReviewEvent, - options: { checkoutPath: string; mode?: RunMode }, -): ReviewInput { - const { owner, repo, number } = event.pullRequest; - const files = [ - ...new Set( - event.items - .map((item) => item.path) - .filter((path): path is string => path !== undefined && isRepositoryRelativePath(path)), - ), - ]; - return { - repository: options.checkoutPath, - mode: options.mode ?? 'observe', - trigger: event.trigger, - source: `github:${owner}/${repo}#${String(number)}`, - pullRequest: event.pullRequest, - ...(event.trigger === 'feedback' - ? { feedback: renderFeedback(event.items), items: event.items } - : {}), - ...(files.length > 0 ? { files } : {}), - }; -} - -/** A single human-readable transcript of the review, used when no structured items are consumed. */ -export function renderFeedback(items: readonly FeedbackItem[]): string { - return items - .map((item) => { - const location = item.path - ? ` on ${item.path}${item.line === undefined ? '' : `:${String(item.line)}`}` - : ''; - const kind = item.requestedChanges ? `${item.kind} (changes requested)` : item.kind; - return `[${kind} by ${item.author}${location}]\n${item.body}`; - }) - .join('\n\n---\n\n'); -} - -function readReviewComment( - payload: Record, - options: ParseOptions, -): FeedbackItem[] | null { - if (payload.action !== 'created') return null; - if (!isRecord(payload.comment)) return null; - const comment = payload.comment; - const author = readAuthor(comment.user, options); - const body = readBody(comment.body); - if (author === null || body === null) return null; - const path = typeof comment.path === 'string' ? comment.path : undefined; - const line = readLine(comment.line ?? comment.original_line); - return [ - { - id: readId(comment.id, 'review-comment'), - kind: 'review-comment', - body, - author, - // A single inline comment is a remark; the review that carries it decides on changes. - requestedChanges: false, - ...(path === undefined ? {} : { path }), - ...(line === undefined ? {} : { line }), - }, - ]; -} - -function readReview( - payload: Record, - options: ParseOptions, -): FeedbackItem[] | null { - if (payload.action !== 'submitted') return null; - if (!isRecord(payload.review)) return null; - const review = payload.review; - const state = typeof review.state === 'string' ? review.state.toLowerCase() : ''; - // An approval or a dismissal carries no claim to validate. - if (state !== 'changes_requested' && state !== 'commented') return null; - const author = readAuthor(review.user, options); - const body = readBody(review.body); - if (author === null || body === null) return null; - return [ - { - id: readId(review.id, 'review'), - kind: 'review-body', - body, - author, - requestedChanges: state === 'changes_requested', - }, - ]; -} - -function readPullRequest(payload: Record): PullRequestRef | null { - const pullRequest = isRecord(payload.pull_request) ? payload.pull_request : undefined; - const repository = isRecord(payload.repository) ? payload.repository : undefined; - if (!pullRequest || !repository) return null; - - const number = typeof pullRequest.number === 'number' ? pullRequest.number : undefined; - const head = isRecord(pullRequest.head) ? pullRequest.head : undefined; - const base = isRecord(pullRequest.base) ? pullRequest.base : undefined; - const headSha = typeof head?.sha === 'string' ? head.sha : undefined; - const baseSha = typeof base?.sha === 'string' ? base.sha : undefined; - const repo = typeof repository.name === 'string' ? repository.name : undefined; - const ownerRecord = isRecord(repository.owner) ? repository.owner : undefined; - const owner = typeof ownerRecord?.login === 'string' ? ownerRecord.login : undefined; - - if (number === undefined || !baseSha || !headSha || !repo || !owner) return null; - if (!COMMIT_SHA.test(baseSha) || !COMMIT_SHA.test(headSha)) return null; - return { owner, repo, number, baseSha, headSha }; -} - -function isProactiveAction(action: unknown): boolean { - return ( - action === 'opened' || - action === 'reopened' || - action === 'synchronize' || - action === 'ready_for_review' - ); -} - -function readAuthor(user: unknown, options: ParseOptions): string | null { - if (!isRecord(user)) return null; - const login = typeof user.login === 'string' ? user.login : ''; - if (login.length === 0) return null; - const ignored = options.ignoreAuthors ?? []; - if (ignored.some((ignore) => ignore.toLowerCase() === login.toLowerCase())) return null; - if (options.allowBots === false && user.type === 'Bot') return null; - return login; -} - -function readBody(body: unknown): string | null { - if (typeof body !== 'string') return null; - const trimmed = body.trim(); - if (trimmed.length === 0) return null; - return trimmed.slice(0, MAX_BODY); -} - -function readLine(value: unknown): number | undefined { - return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined; -} - -function readId(value: unknown, prefix: string): string { - if (typeof value === 'number' || typeof value === 'string') return `${prefix}:${String(value)}`; - return prefix; -} - -function isRecord(value: unknown): value is Record { - return typeof value === 'object' && value !== null && !Array.isArray(value); -} diff --git a/packages/github/src/index.test.ts b/packages/github/src/index.test.ts deleted file mode 100644 index 343eae0..0000000 --- a/packages/github/src/index.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { createHmac } from 'node:crypto'; - -import { describe, expect, it } from 'vitest'; - -import { verifyWebhook } from './index.js'; - -describe('verifyWebhook', () => { - it('accepts the exact HMAC and rejects a forged one', () => { - const body = '{"ok":true}'; - const secret = 'secret'; - const signature = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`; - expect(verifyWebhook(body, signature, secret)).toBe(true); - expect(verifyWebhook(body, `${signature.slice(0, -1)}0`, secret)).toBe(false); - }); -}); diff --git a/packages/github/src/index.ts b/packages/github/src/index.ts deleted file mode 100644 index 1cd0647..0000000 --- a/packages/github/src/index.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { createHmac, timingSafeEqual } from 'node:crypto'; - -export { - checkConclusion, - GitHubChecks, - type CheckConclusion, - type GitHubChecksOptions, -} from './checks.js'; -export { - parseReviewEvent, - renderFeedback, - reviewInputFromEvent, - supportedEvents, - type ParseOptions, - type ReviewEvent, - type SupportedEvent, -} from './events.js'; -export { GitHubIssueComments, type GitHubIssueCommentsOptions } from './comments.js'; -export { - issueBranchName, - issueInputFromTask, - parseIssueTask, - prepareIssuePullRequest, - prepareIssueValidationComment, - supportedIssueEvents, - VALIDATION_COMMENT_MARKER, - type IssueTask, - type IssueValidationComment, - type ParseIssueOptions, - type PullRequestReadiness, -} from './issues.js'; -export { - assertSafeBranchName, - GitHubPullRequests, - isSafeBranchName, - type BranchFile, - type GitHubPullRequestsOptions, - type OpenPullRequestOptions, - type PublishBranchOptions, - type RepositoryTarget, -} from './pulls.js'; - -/** - * Verify a webhook signature in constant time. - * - * The comparison length is checked first because `timingSafeEqual` throws on a length mismatch, and - * a thrown error would be a slower path than a rejection. - */ -export function verifyWebhook( - body: string, - signature: string | undefined, - secret: string, -): boolean { - if (!signature?.startsWith('sha256=')) return false; - const expected = `sha256=${createHmac('sha256', secret).update(body).digest('hex')}`; - return ( - signature.length === expected.length && - timingSafeEqual(Buffer.from(signature), Buffer.from(expected)) - ); -} diff --git a/packages/github/package.json b/packages/source-control/package.json similarity index 95% rename from packages/github/package.json rename to packages/source-control/package.json index 40a67a7..4e6d5c4 100644 --- a/packages/github/package.json +++ b/packages/source-control/package.json @@ -1,5 +1,5 @@ { - "name": "@agent-zero/github", + "name": "@agent-zero/source-control", "version": "0.4.0", "type": "module", "main": "./dist/index.cjs", diff --git a/packages/source-control/src/conformance.test.ts b/packages/source-control/src/conformance.test.ts new file mode 100644 index 0000000..035afa6 --- /dev/null +++ b/packages/source-control/src/conformance.test.ts @@ -0,0 +1,220 @@ +import { createHmac } from 'node:crypto'; + +import { describe, expect, it } from 'vitest'; + +import { conformanceCases, type ProviderConformanceFixtures } from './conformance.js'; +import type { ProviderKind, WebhookDelivery } from './contracts.js'; +import { allProviders } from './registry.js'; + +const secret = 'webhook-secret'; +const token = 'zz-status-credential'; + +function hmac(body: string, prefix = ''): string { + return `${prefix}${createHmac('sha256', secret).update(body).digest('hex')}`; +} + +function githubDelivery(event: string, payload: unknown): WebhookDelivery { + const body = JSON.stringify(payload); + return { + body, + headers: { 'x-github-event': event, 'x-hub-signature-256': hmac(body, 'sha256=') }, + }; +} + +function gitlabDelivery(event: string, payload: unknown): WebhookDelivery { + return { + body: JSON.stringify(payload), + headers: { 'x-gitlab-event': event, 'x-gitlab-token': secret }, + }; +} + +function bitbucketDelivery(event: string, payload: unknown): WebhookDelivery { + const body = JSON.stringify(payload); + return { + body, + headers: { 'x-event-key': event, 'x-hub-signature': hmac(body, 'sha256=') }, + }; +} + +function giteaDelivery(event: string, payload: unknown): WebhookDelivery { + const body = JSON.stringify(payload); + return { + body, + headers: { + 'x-gitea-event': event, + 'x-gitea-signature': hmac(body), + // Gitea sends a GitHub compatibility header; routing must still pick the Gitea adapter. + 'x-github-event': event, + }, + }; +} + +const githubPullRequest = { + number: 7, + base: { sha: 'b'.repeat(40) }, + head: { sha: 'a'.repeat(40) }, +}; +const githubRepository = { name: 'app', owner: { login: 'acme' } }; + +const gitlabMergeRequest = { + iid: 7, + last_commit: { id: 'a'.repeat(40) }, +}; + +const cloudPullRequest = { + id: 7, + source: { commit: { hash: 'a'.repeat(12) } }, + destination: { commit: { hash: 'b'.repeat(12) } }, +}; + +const dataCenterPullRequest = { + id: 7, + fromRef: { latestCommit: 'a'.repeat(40) }, + toRef: { + latestCommit: 'b'.repeat(40), + repository: { slug: 'app', project: { key: 'ACME' } }, + }, +}; + +const fixtures: Record = { + github: { + secret, + proactive: githubDelivery('pull_request', { + action: 'opened', + repository: githubRepository, + pull_request: githubPullRequest, + }), + feedback: githubDelivery('pull_request_review', { + action: 'submitted', + repository: githubRepository, + pull_request: githubPullRequest, + review: { + id: 202, + body: 'Please guard the null return.', + state: 'changes_requested', + user: { login: 'alice', type: 'User' }, + }, + }), + feedbackAuthor: 'alice', + claimFree: githubDelivery('pull_request_review', { + action: 'submitted', + repository: githubRepository, + pull_request: githubPullRequest, + review: { id: 203, body: 'Nice.', state: 'approved', user: { login: 'alice' } }, + }), + statusOptions: (fetch) => ({ token, fetch }), + statusUrlPrefix: 'https://api.github.com/repos/acme/app/check-runs', + }, + gitlab: { + secret, + proactive: gitlabDelivery('Merge Request Hook', { + object_kind: 'merge_request', + project: { path_with_namespace: 'acme/app' }, + object_attributes: { ...gitlabMergeRequest, action: 'open' }, + }), + feedback: gitlabDelivery('Note Hook', { + object_kind: 'note', + user: { username: 'alice' }, + project: { path_with_namespace: 'acme/app' }, + object_attributes: { + id: 11, + note: 'This dereferences a null return.', + noteable_type: 'MergeRequest', + position: { new_path: 'src/user.ts', new_line: 12 }, + }, + merge_request: gitlabMergeRequest, + }), + feedbackAuthor: 'alice', + claimFree: gitlabDelivery('Merge Request Hook', { + object_kind: 'merge_request', + project: { path_with_namespace: 'acme/app' }, + object_attributes: { ...gitlabMergeRequest, action: 'approved' }, + }), + statusOptions: (fetch) => ({ token, fetch }), + statusUrlPrefix: 'https://gitlab.com/api/v4/projects/acme%2Fapp/statuses/', + }, + 'bitbucket-cloud': { + secret, + proactive: bitbucketDelivery('pullrequest:created', { + pullrequest: cloudPullRequest, + repository: { full_name: 'acme/app' }, + }), + feedback: bitbucketDelivery('pullrequest:comment_created', { + pullrequest: cloudPullRequest, + repository: { full_name: 'acme/app' }, + comment: { + id: 31, + content: { raw: 'This dereferences a null return.' }, + user: { nickname: 'alice', display_name: 'Alice' }, + inline: { path: 'src/user.ts', to: 12 }, + }, + }), + feedbackAuthor: 'alice', + claimFree: bitbucketDelivery('pullrequest:approved', { + pullrequest: cloudPullRequest, + repository: { full_name: 'acme/app' }, + approval: { user: { nickname: 'alice' } }, + }), + statusOptions: (fetch) => ({ token, fetch }), + statusUrlPrefix: 'https://api.bitbucket.org/2.0/repositories/acme/app/commit/', + }, + 'bitbucket-data-center': { + secret, + proactive: bitbucketDelivery('pr:opened', { pullRequest: dataCenterPullRequest }), + feedback: bitbucketDelivery('pr:comment:added', { + pullRequest: dataCenterPullRequest, + comment: { + id: 41, + text: 'This dereferences a null return.', + author: { name: 'alice', displayName: 'Alice' }, + }, + }), + feedbackAuthor: 'alice', + claimFree: bitbucketDelivery('pr:reviewer:approved', { + pullRequest: dataCenterPullRequest, + participant: { user: { name: 'alice' } }, + }), + statusOptions: (fetch) => ({ token, fetch, baseUrl: 'https://bitbucket.example.com' }), + statusUrlPrefix: + 'https://bitbucket.example.com/rest/api/latest/projects/ACME/repos/app/commits/', + }, + gitea: { + secret, + proactive: giteaDelivery('pull_request', { + action: 'opened', + repository: githubRepository, + pull_request: githubPullRequest, + }), + feedback: giteaDelivery('pull_request_review_rejected', { + action: 'reviewed', + repository: githubRepository, + pull_request: githubPullRequest, + sender: { login: 'alice' }, + review: { id: 51, type: 'pull_request_review_rejected', content: 'Guard the null return.' }, + }), + feedbackAuthor: 'alice', + claimFree: giteaDelivery('pull_request_review_approved', { + action: 'reviewed', + repository: githubRepository, + pull_request: githubPullRequest, + sender: { login: 'alice' }, + review: { id: 52, type: 'pull_request_review_approved', content: 'Nice.' }, + }), + statusOptions: (fetch) => ({ token, fetch, baseUrl: 'https://gitea.example.com' }), + statusUrlPrefix: 'https://gitea.example.com/api/v1/repos/acme/app/statuses/', + }, +}; + +for (const provider of allProviders()) { + describe(`${provider.kind} adapter conformance`, () => { + for (const conformanceCase of conformanceCases) { + // oxlint-disable-next-line vitest/valid-title -- case names come from the shared suite + it(conformanceCase.name, async () => { + // The cases assert by throwing, so the suite stays framework-free for other runners. + await expect( + conformanceCase.run(provider, fixtures[provider.kind]), + ).resolves.toBeUndefined(); + }); + } + }); +} diff --git a/packages/source-control/src/conformance.ts b/packages/source-control/src/conformance.ts new file mode 100644 index 0000000..c6a4c4f --- /dev/null +++ b/packages/source-control/src/conformance.ts @@ -0,0 +1,315 @@ +import type { EvidenceBundle } from '@agent-zero/shared'; + +import type { + SourceControlProvider, + StatusPublisherOptions, + WebhookDelivery, +} from './contracts.js'; +import { reviewInputFromEvent } from './input.js'; +import { COMMIT_SHA, MAX_BODY } from './untrusted.js'; + +/** + * The fixtures one adapter supplies to the conformance suite. + * + * Fixtures are authentic deliveries: headers and body exactly as the provider would send them, + * signed or tokened with `secret`. The suite derives every negative case (forged signature, + * tampered body, ignored author, junk payload) from these, so an adapter cannot pass by special- + * casing the happy path. + */ +export interface ProviderConformanceFixtures { + secret: string; + /** An authenticated delivery that must parse into a proactive review. */ + proactive: WebhookDelivery; + /** An authenticated delivery that must parse into reviewer feedback. */ + feedback: WebhookDelivery; + /** The author of `feedback`, for the self-ignore check. */ + feedbackAuthor: string; + /** A delivery carrying an approval or another claim-free event, which must parse to null. */ + claimFree?: WebhookDelivery; + /** Publisher options wired to an injected fetch, so no test touches the network. */ + statusOptions: (fetch: typeof globalThis.fetch) => StatusPublisherOptions; + /** Every status request must address this URL prefix. */ + statusUrlPrefix: string; +} + +export interface ConformanceCase { + name: string; + run: (provider: SourceControlProvider, fixtures: ProviderConformanceFixtures) => Promise; +} + +function ok(condition: boolean, message: string): asserts condition { + if (!condition) throw new Error(message); +} + +function parsed(provider: SourceControlProvider, delivery: WebhookDelivery) { + const event = provider.eventName(delivery.headers); + ok(event !== undefined, 'eventName must identify the fixture delivery'); + return provider.parseReviewEvent(event, JSON.parse(delivery.body)); +} + +function evidence(overrides: Partial): EvidenceBundle { + return { + taskId: 'az_conformance', + state: 'completed', + verdict: 'accepted', + verified: true, + mode: 'observe', + trigger: 'feedback', + source: null, + issue: null, + runner: { kind: 'local', isolated: false, writable: false, network: 'none' }, + finding: null, + plan: [], + acceptanceCriteria: [], + changedFiles: [], + checks: [{ command: 'test', exitCode: 0, stdout: '', stderr: '', durationMs: 1 }], + attempts: 1, + transitions: [], + summary: 'conformance summary', + ...overrides, + }; +} + +interface RecordedRequest { + url: string; + init: RequestInit | undefined; +} + +function requestUrl(url: Parameters[0]): string { + if (typeof url === 'string') return url; + return url instanceof URL ? url.href : url.url; +} + +function recordingFetch(): { fetch: typeof globalThis.fetch; requests: RecordedRequest[] } { + const requests: RecordedRequest[] = []; + const fetch: typeof globalThis.fetch = async (url, init) => { + requests.push({ url: requestUrl(url), init }); + return new Response(JSON.stringify({ id: 1 }), { + status: 201, + headers: { 'content-type': 'application/json' }, + }); + }; + return { fetch, requests }; +} + +/** + * The behavior every provider adapter must exhibit. + * + * The cases are framework-free and throw on violation, so any test runner (and any out-of-tree + * adapter) can execute them unchanged. + */ +export const conformanceCases: readonly ConformanceCase[] = [ + { + name: 'declares coherent capabilities', + run: async (provider) => { + const c = provider.capabilities; + ok( + c.changeRequestNoun === 'pull request' || c.changeRequestNoun === 'merge request', + 'changeRequestNoun must be a known noun', + ); + ok( + ['check-runs', 'commit-status', 'build-status'].includes(c.statusReporting), + 'statusReporting must be a known surface', + ); + }, + }, + { + name: 'recognizes its own deliveries and nothing anonymous', + run: async (provider, fixtures) => { + ok(provider.recognizes(fixtures.proactive.headers), 'must recognize its proactive fixture'); + ok(provider.recognizes(fixtures.feedback.headers), 'must recognize its feedback fixture'); + ok(!provider.recognizes({}), 'must not claim a delivery with no headers'); + }, + }, + { + name: 'authenticates deliveries and rejects forgeries', + run: async (provider, fixtures) => { + for (const delivery of [fixtures.proactive, fixtures.feedback]) { + ok(provider.verifyWebhook(delivery, fixtures.secret), 'must accept an authentic delivery'); + ok( + !provider.verifyWebhook(delivery, `${fixtures.secret}-wrong`), + 'must reject the wrong secret', + ); + ok(!provider.verifyWebhook(delivery, ''), 'must reject an empty secret'); + ok( + !provider.verifyWebhook({ body: delivery.body, headers: {} }, fixtures.secret), + 'must reject a delivery with no credentials', + ); + if (provider.capabilities.webhookAuthentication === 'hmac-sha256') { + ok( + !provider.verifyWebhook( + { body: `${delivery.body} `, headers: delivery.headers }, + fixtures.secret, + ), + 'must reject a tampered body', + ); + } + } + }, + }, + { + name: 'parses a proactive delivery into a diff-triggered review', + run: async (provider, fixtures) => { + const event = parsed(provider, fixtures.proactive); + ok(event !== null, 'proactive fixture must parse'); + ok(event.trigger === 'proactive', 'trigger must be proactive'); + ok(event.items.length === 0, 'a proactive review must not invent feedback'); + ok(!event.requestedChanges, 'a proactive review carries no change request'); + assertRef(provider, event.changeRequest); + }, + }, + { + name: 'parses a feedback delivery into bounded, attributed items', + run: async (provider, fixtures) => { + const event = parsed(provider, fixtures.feedback); + ok(event !== null, 'feedback fixture must parse'); + ok(event.trigger === 'feedback', 'trigger must be feedback'); + ok(event.items.length > 0, 'feedback must carry at least one item'); + for (const item of event.items) { + ok(item.id.length > 0, 'items must be identifiable'); + ok(item.body.length > 0 && item.body.length <= MAX_BODY, 'bodies must be bounded'); + ok(item.author === fixtures.feedbackAuthor, 'the author must be preserved'); + if (item.requestedChanges) + ok( + provider.capabilities.changeRequests, + 'requestedChanges requires the changeRequests capability', + ); + } + assertRef(provider, event.changeRequest); + }, + }, + { + name: 'ignores its own account so a run cannot answer itself', + run: async (provider, fixtures) => { + const event = provider.eventName(fixtures.feedback.headers); + ok(event !== undefined, 'eventName must identify the fixture delivery'); + const result = provider.parseReviewEvent(event, JSON.parse(fixtures.feedback.body), { + ignoreAuthors: [fixtures.feedbackAuthor.toUpperCase()], + }); + ok(result === null, 'an ignored author must produce nothing'); + }, + }, + { + name: 'produces nothing for a claim-free event', + run: async (provider, fixtures) => { + if (!fixtures.claimFree) return; + ok( + parsed(provider, fixtures.claimFree) === null, + 'an approval or claim-free event must parse to null', + ); + }, + }, + { + name: 'rejects junk payloads instead of throwing', + run: async (provider, fixtures) => { + const event = provider.eventName(fixtures.feedback.headers) ?? 'unknown'; + for (const junk of [null, undefined, 42, 'text', [], {}, { action: 'created' }]) { + ok(provider.parseReviewEvent(event, junk) === null, 'junk payloads must parse to null'); + } + ok( + provider.parseReviewEvent('no-such-event', JSON.parse(fixtures.feedback.body)) === null, + 'unknown events must parse to null', + ); + }, + }, + { + name: 'builds observe-mode runtime input by default', + run: async (provider, fixtures) => { + const event = parsed(provider, fixtures.feedback); + ok(event !== null, 'feedback fixture must parse'); + const input = reviewInputFromEvent(event, { checkoutPath: '/checkout' }); + ok(input.mode === 'observe', 'a webhook must never escalate its own mode'); + ok( + input.source?.startsWith(`${provider.kind}:`) === true, + 'the source label must be provider-qualified', + ); + if (provider.capabilities.diffBase) + ok(input.pullRequest !== undefined, 'diffBase providers must supply a full reference'); + else + ok( + input.pullRequest === undefined, + 'a partial diff reference must not masquerade as complete', + ); + }, + }, + { + name: 'publishes statuses without leaking the credential', + run: async (provider, fixtures) => { + const { fetch, requests } = recordingFetch(); + const options = fixtures.statusOptions(fetch); + const publisher = provider.statusPublisher(options); + const publication = await publisher.publish( + (parsed(provider, fixtures.proactive) ?? assertNever()).changeRequest, + evidence({}), + ); + ok(publication.outcome === 'success', 'a verified run must publish success'); + ok(publication.state.length > 0, 'the provider-native state must be reported'); + ok(publication.degraded === undefined, 'success must never be degraded'); + ok(requests.length > 0, 'a publication must send a request'); + for (const request of requests) { + ok( + request.url.startsWith(fixtures.statusUrlPrefix), + `unexpected status URL ${request.url}`, + ); + const headers = new Headers(request.init?.headers); + ok( + headers.get('authorization')?.includes(options.token) === true, + 'the token must travel as an Authorization header', + ); + const body = typeof request.init?.body === 'string' ? request.init.body : ''; + const visible = request.url + body; + ok(!visible.includes(options.token), 'the token must not appear outside the header'); + } + }, + }, + { + name: 'degrades unsupported status conclusions explicitly', + run: async (provider, fixtures) => { + const { fetch } = recordingFetch(); + const publisher = provider.statusPublisher(fixtures.statusOptions(fetch)); + const target = (parsed(provider, fixtures.proactive) ?? assertNever()).changeRequest; + + const neutral = await publisher.publish(target, evidence({ verified: false })); + ok(neutral.outcome === 'neutral', 'an unverified clean run is neutral'); + if (provider.capabilities.neutralStatus) + ok(neutral.degraded === undefined, 'native neutral must not be degraded'); + else ok(neutral.degraded !== undefined, 'a mapped neutral must say it was mapped'); + + const needsHuman = await publisher.publish( + target, + evidence({ state: 'needs-human', verified: false }), + ); + ok(needsHuman.outcome === 'action-required', 'needs-human is action-required'); + if (provider.capabilities.actionRequiredStatus) + ok(needsHuman.degraded === undefined, 'native action-required must not be degraded'); + else ok(needsHuman.degraded !== undefined, 'a mapped action-required must say it was mapped'); + + const failed = await publisher.publish( + target, + evidence({ + state: 'failed', + verified: false, + checks: [{ command: 'test', exitCode: 1, stdout: '', stderr: '', durationMs: 1 }], + }), + ); + ok(failed.outcome === 'failure', 'a failed run is a failure'); + ok(failed.degraded === undefined, 'every provider has a native failure state'); + }, + }, +]; + +function assertRef( + provider: SourceControlProvider, + ref: { owner: string; repo: string; number: number; headSha: string; baseSha?: string }, +): void { + ok(ref.owner.length > 0 && ref.repo.length > 0, 'the reference must name a repository'); + ok(Number.isInteger(ref.number) && ref.number > 0, 'the reference must carry a number'); + ok(COMMIT_SHA.test(ref.headSha), 'the head commit must look like a commit'); + if (provider.capabilities.diffBase) + ok(ref.baseSha !== undefined && COMMIT_SHA.test(ref.baseSha), 'diffBase promises a base'); + else ok(ref.baseSha === undefined, 'a provider without diffBase must not invent a base'); +} + +function assertNever(): never { + throw new Error('the proactive fixture must parse'); +} diff --git a/packages/source-control/src/contracts.ts b/packages/source-control/src/contracts.ts new file mode 100644 index 0000000..fe4f909 --- /dev/null +++ b/packages/source-control/src/contracts.ts @@ -0,0 +1,181 @@ +import type { + EvidenceBundle, + FeedbackItem, + PullRequestRef, + ReviewTrigger, +} from '@agent-zero/shared'; + +/** Source-control platforms Agent Zero can integrate with. */ +export const providerKinds = [ + 'github', + 'gitlab', + 'bitbucket-cloud', + 'bitbucket-data-center', + 'gitea', +] as const; +export type ProviderKind = (typeof providerKinds)[number]; + +export function isProviderKind(value: unknown): value is ProviderKind { + return typeof value === 'string' && (providerKinds as readonly string[]).includes(value); +} + +/** + * What one provider can actually deliver, so unsupported features degrade explicitly. + * + * Every flag is honest about the webhook and API surface the adapter consumes, not about the + * platform's full feature set: a platform capability the adapter cannot observe is reported as + * absent rather than assumed. + */ +export interface ProviderCapabilities { + /** How inbound webhook deliveries are authenticated. */ + webhookAuthentication: 'hmac-sha256' | 'shared-token'; + /** The API surface run evidence is reported through. */ + statusReporting: 'check-runs' | 'commit-status' | 'build-status'; + /** Whether the status vocabulary has a native neutral conclusion. */ + neutralStatus: boolean; + /** Whether the status vocabulary has a native action-required conclusion. */ + actionRequiredStatus: boolean; + /** Whether the provider delivers formal review submissions distinct from plain comments. */ + reviewSubmissions: boolean; + /** Whether ingested feedback can carry a formal request for changes. */ + changeRequests: boolean; + /** Whether inline comments arrive with file path and line information. */ + inlineComments: boolean; + /** Whether payloads mark bot authors, so `allowBots: false` can filter them. */ + botAuthorDetection: boolean; + /** Whether webhook payloads carry the base commit needed for a base-to-head diff. */ + diffBase: boolean; + /** The provider's own noun for a unit of proposed change, for user-facing text. */ + changeRequestNoun: 'pull request' | 'merge request'; +} + +/** Case-insensitive HTTP header map of an inbound webhook request. */ +export type WebhookHeaders = Readonly>; + +/** One inbound webhook request. The body is the raw bytes; signatures verify it verbatim. */ +export interface WebhookDelivery { + body: string; + headers: WebhookHeaders; +} + +/** + * Identifies the change request a run reports against, in provider-neutral terms. + * + * `owner` is the provider's namespace: a GitHub owner, a GitLab namespace path, a Bitbucket + * workspace or project key, or a Gitea owner. `baseSha` is absent when the provider's webhook + * payload does not carry a diff base; see `ProviderCapabilities.diffBase`. + */ +export interface ChangeRequestRef { + provider: ProviderKind; + owner: string; + repo: string; + number: number; + headSha: string; + baseSha?: string; +} + +/** The complete pull-request reference, for runs that can diff base to head. */ +export function toPullRequestRef(ref: ChangeRequestRef): PullRequestRef | undefined { + if (ref.baseSha === undefined) return undefined; + const { owner, repo, number, baseSha, headSha } = ref; + return { owner, repo, number, baseSha, headSha }; +} + +/** A review event normalized away from any provider's payload shape. */ +export interface ReviewEvent { + provider: ProviderKind; + trigger: ReviewTrigger; + changeRequest: ChangeRequestRef; + items: FeedbackItem[]; + /** True when at least one item came from a formal request for changes. */ + requestedChanges: boolean; +} + +export interface ParseOptions { + /** + * Logins whose feedback is ignored, normally including the account Agent Zero posts as. + * + * Without this a run reacts to its own comments and loops. + */ + ignoreAuthors?: readonly string[]; + /** + * Whether feedback from bot accounts is ingested. AI reviewers are a first-class source. + * Only enforceable on providers whose payloads mark bots; see `botAuthorDetection`. + */ + allowBots?: boolean; +} + +/** The provider-neutral meaning of a finished run for a change request. */ +export type RunOutcome = 'success' | 'failure' | 'neutral' | 'action-required'; + +/** + * Decide what a run means for a change request. + * + * Verification is the only thing that produces `success`. A failing check, an unreached terminal + * state, or an unverified change can never be reported as passing, which is what keeps the + * published status honest. Rejecting incorrect feedback is a legitimate, neutral outcome rather + * than a failure: nothing is wrong with the change request. + */ +export function runOutcome(bundle: EvidenceBundle): RunOutcome { + if (bundle.state === 'failed') return 'failure'; + if (bundle.checks.some((check) => check.exitCode !== 0)) return 'failure'; + if (bundle.state === 'needs-human') return 'action-required'; + if (bundle.verified) return 'success'; + return 'neutral'; +} + +/** What a status publisher actually reported, including any explicit degradation. */ +export interface StatusPublication { + outcome: RunOutcome; + /** The provider-native state that was reported. */ + state: string; + /** Present when the outcome had no native equivalent on this provider and was mapped. */ + degraded?: string; +} + +export interface StatusPublisherOptions { + /** Provider API credential. Sent only as an Authorization header, never logged. */ + token: string; + /** API base URL. Required for self-hosted providers; cloud providers have a default. */ + baseUrl?: string; + /** Status or check name, so several Agent Zero configurations can report side by side. */ + name?: string; + fetch?: typeof globalThis.fetch; +} + +/** Publishes run evidence as the provider's commit status or check equivalent. */ +export interface StatusPublisher { + publish(target: ChangeRequestRef, bundle: EvidenceBundle): Promise; +} + +/** + * One source-control platform behind the provider-neutral boundary. + * + * Adapters translate untrusted provider payloads into shared contracts and keep + * provider-specific URLs, IDs, credentials, and SDK shapes on their side of the boundary. + */ +export interface SourceControlProvider { + readonly kind: ProviderKind; + readonly capabilities: ProviderCapabilities; + /** True when the delivery's headers identify this provider. */ + recognizes(headers: WebhookHeaders): boolean; + /** The provider's event name for a delivery, read from its headers. */ + eventName(headers: WebhookHeaders): string | undefined; + /** Authenticate a delivery. The raw body is verified, never a re-serialization. */ + verifyWebhook(delivery: WebhookDelivery, secret: string): boolean; + /** Turn an untrusted payload into a review event, or null when there is nothing to act on. */ + parseReviewEvent(event: string, payload: unknown, options?: ParseOptions): ReviewEvent | null; + /** Build a status publisher, or throw `ProviderConfigurationError` when misconfigured. */ + statusPublisher(options: StatusPublisherOptions): StatusPublisher; +} + +/** A provider was asked for something its configuration cannot support. */ +export class ProviderConfigurationError extends Error { + constructor( + readonly provider: ProviderKind, + message: string, + ) { + super(`${provider}: ${message}`); + this.name = 'ProviderConfigurationError'; + } +} diff --git a/packages/source-control/src/index.ts b/packages/source-control/src/index.ts new file mode 100644 index 0000000..562e09e --- /dev/null +++ b/packages/source-control/src/index.ts @@ -0,0 +1,76 @@ +export { + isProviderKind, + ProviderConfigurationError, + providerKinds, + runOutcome, + toPullRequestRef, + type ChangeRequestRef, + type ParseOptions, + type ProviderCapabilities, + type ProviderKind, + type ReviewEvent, + type RunOutcome, + type SourceControlProvider, + type StatusPublication, + type StatusPublisher, + type StatusPublisherOptions, + type WebhookDelivery, + type WebhookHeaders, +} from './contracts.js'; +export { renderFeedback, reviewInputFromEvent, sourceLabel } from './input.js'; +export { allProviders, createProvider, providerForDelivery } from './registry.js'; +export { timingSafeStringEqual, verifyHmacSha256 } from './signatures.js'; +export { readHeader } from './untrusted.js'; +export { + bitbucketCloudProvider, + parseBitbucketCloudReviewEvent, +} from './providers/bitbucket-cloud.js'; +export { + bitbucketDataCenterProvider, + parseBitbucketDataCenterReviewEvent, +} from './providers/bitbucket-data-center.js'; +export { giteaProvider, parseGiteaReviewEvent } from './providers/gitea.js'; +export { + checkConclusion, + GitHubChecks, + githubProvider, + parseReviewEvent, + supportedEvents, + verifyWebhook, + type CheckConclusion, + type GitHubChecksOptions, + type SupportedEvent, +} from './providers/github.js'; +export { + GitHubIssueComments, + type GitHubIssueCommentsOptions, +} from './providers/github-comments.js'; +export { + issueBranchName, + issueInputFromTask, + parseIssueTask, + prepareIssuePullRequest, + prepareIssueValidationComment, + supportedIssueEvents, + VALIDATION_COMMENT_MARKER, + type IssueTask, + type IssueValidationComment, + type ParseIssueOptions, + type PullRequestReadiness, +} from './providers/github-issues.js'; +export { + assertSafeBranchName, + GitHubPullRequests, + isSafeBranchName, + type BranchFile, + type GitHubPullRequestsOptions, + type OpenPullRequestOptions, + type PublishBranchOptions, + type RepositoryTarget, +} from './providers/github-pulls.js'; +export { gitlabProvider, parseGitLabReviewEvent } from './providers/gitlab.js'; +export { + conformanceCases, + type ConformanceCase, + type ProviderConformanceFixtures, +} from './conformance.js'; diff --git a/packages/source-control/src/input.ts b/packages/source-control/src/input.ts new file mode 100644 index 0000000..e2e60b0 --- /dev/null +++ b/packages/source-control/src/input.ts @@ -0,0 +1,61 @@ +import { + isRepositoryRelativePath, + type FeedbackItem, + type ReviewInput, + type RunMode, +} from '@agent-zero/shared'; + +import { toPullRequestRef, type ChangeRequestRef, type ReviewEvent } from './contracts.js'; + +/** A stable, provider-qualified label for where a run came from. */ +export function sourceLabel(ref: ChangeRequestRef): string { + // GitLab writes merge requests as `!7`; everyone else numbers change requests with `#`. + const separator = ref.provider === 'gitlab' ? '!' : '#'; + return `${ref.provider}:${ref.owner}/${ref.repo}${separator}${String(ref.number)}`; +} + +/** + * Build runtime input for a review event. + * + * The mode is supplied by the caller and defaults to `observe`, so an inbound webhook can never + * escalate a run into writing to a repository on its own. The pull-request reference is attached + * only when the provider delivered a diff base; without one the run falls back to the runner's + * own diff discovery instead of trusting a partial range. + */ +export function reviewInputFromEvent( + event: ReviewEvent, + options: { checkoutPath: string; mode?: RunMode }, +): ReviewInput { + const pullRequest = toPullRequestRef(event.changeRequest); + const files = [ + ...new Set( + event.items + .map((item) => item.path) + .filter((path): path is string => path !== undefined && isRepositoryRelativePath(path)), + ), + ]; + return { + repository: options.checkoutPath, + mode: options.mode ?? 'observe', + trigger: event.trigger, + source: sourceLabel(event.changeRequest), + ...(pullRequest ? { pullRequest } : {}), + ...(event.trigger === 'feedback' + ? { feedback: renderFeedback(event.items), items: event.items } + : {}), + ...(files.length > 0 ? { files } : {}), + }; +} + +/** A single human-readable transcript of the review, used when no structured items are consumed. */ +export function renderFeedback(items: readonly FeedbackItem[]): string { + return items + .map((item) => { + const location = item.path + ? ` on ${item.path}${item.line === undefined ? '' : `:${String(item.line)}`}` + : ''; + const kind = item.requestedChanges ? `${item.kind} (changes requested)` : item.kind; + return `[${kind} by ${item.author}${location}]\n${item.body}`; + }) + .join('\n\n---\n\n'); +} diff --git a/packages/source-control/src/providers/bitbucket-cloud.ts b/packages/source-control/src/providers/bitbucket-cloud.ts new file mode 100644 index 0000000..7ca84cf --- /dev/null +++ b/packages/source-control/src/providers/bitbucket-cloud.ts @@ -0,0 +1,209 @@ +import { + evidenceTitle, + redactSecrets, + secretValuesFromEnvironment, + type EvidenceBundle, + type FeedbackItem, +} from '@agent-zero/shared'; + +import { + ProviderConfigurationError, + runOutcome, + type ChangeRequestRef, + type ParseOptions, + type ProviderCapabilities, + type ReviewEvent, + type RunOutcome, + type SourceControlProvider, + type StatusPublication, + type StatusPublisher, + type StatusPublisherOptions, + type WebhookDelivery, + type WebhookHeaders, +} from '../contracts.js'; +import { verifyHmacSha256 } from '../signatures.js'; +import { sendProviderRequest } from '../status.js'; +import { + acceptAuthor, + readBody, + readHeader, + readId, + readLine, + readPositiveInteger, + readRecord, + readSha, + readString, +} from '../untrusted.js'; + +const MAX_DESCRIPTION = 1_000; + +/** + * Bitbucket Cloud's `changes_request_created` event carries no text, so a formal request for + * changes has no claim this adapter can ingest; comments are the reviewable surface. Build + * statuses have neither a neutral nor an action-required state, so both degrade explicitly. + */ +const capabilities: ProviderCapabilities = { + webhookAuthentication: 'hmac-sha256', + statusReporting: 'build-status', + neutralStatus: false, + actionRequiredStatus: false, + reviewSubmissions: false, + changeRequests: false, + inlineComments: true, + botAuthorDetection: false, + diffBase: true, + changeRequestNoun: 'pull request', +}; + +/** Turn a Bitbucket Cloud webhook payload into a review event, or null when nothing is actionable. */ +export function parseBitbucketCloudReviewEvent( + event: string, + payload: unknown, + options: ParseOptions = {}, +): ReviewEvent | null { + const record = readRecord(payload); + if (!record) return null; + const changeRequest = readChangeRequest(record); + if (!changeRequest) return null; + + if (event === 'pullrequest:created' || event === 'pullrequest:updated') { + return { + provider: 'bitbucket-cloud', + trigger: 'proactive', + changeRequest, + items: [], + requestedChanges: false, + }; + } + + if (event !== 'pullrequest:comment_created') return null; + const comment = readRecord(record.comment); + if (!comment) return null; + const user = readRecord(comment.user); + const author = acceptAuthor( + readString(user?.nickname) ?? readString(user?.display_name), + false, + options, + ); + const body = readBody(readRecord(comment.content)?.raw); + if (author === null || body === null) return null; + + const inline = readRecord(comment.inline); + const path = readString(inline?.path); + const line = readLine(inline?.to); + const items: FeedbackItem[] = [ + { + id: readId(comment.id, 'comment'), + kind: 'review-comment', + body, + author, + // Bitbucket Cloud's formal "changes requested" signal is a separate, bodyless event. + requestedChanges: false, + ...(path === undefined ? {} : { path }), + ...(line === undefined ? {} : { line }), + }, + ]; + return { + provider: 'bitbucket-cloud', + trigger: 'feedback', + changeRequest, + items, + requestedChanges: false, + }; +} + +function readChangeRequest(payload: Record): ChangeRequestRef | null { + const pullRequest = readRecord(payload.pullrequest); + const repository = readRecord(payload.repository); + if (!pullRequest || !repository) return null; + + const fullName = readString(repository.full_name); + const number = readPositiveInteger(pullRequest.id); + const headSha = readSha(readRecord(readRecord(pullRequest.source)?.commit)?.hash); + // The destination head serves as the diff base; the runner diffs with merge-base semantics. + const baseSha = readSha(readRecord(readRecord(pullRequest.destination)?.commit)?.hash); + if (!fullName || number === undefined || !headSha || !baseSha) return null; + + const separator = fullName.indexOf('/'); + if (separator <= 0 || separator === fullName.length - 1) return null; + return { + provider: 'bitbucket-cloud', + owner: fullName.slice(0, separator), + repo: fullName.slice(separator + 1), + number, + headSha, + baseSha, + }; +} + +const stateByOutcome: Record = { + success: 'SUCCESSFUL', + failure: 'FAILED', + // Build statuses have no neutral or action-required state. A neutral outcome means nothing is + // wrong with the pull request, so it maps to SUCCESSFUL; a run that needs a human maps to + // FAILED so the pull request cannot quietly proceed. Both are reported as degraded. + neutral: 'SUCCESSFUL', + 'action-required': 'FAILED', +}; + +class BitbucketCloudStatusPublisher implements StatusPublisher { + constructor( + private readonly options: StatusPublisherOptions, + private readonly baseUrl: string, + ) {} + + async publish(target: ChangeRequestRef, bundle: EvidenceBundle): Promise { + const outcome = runOutcome(bundle); + const state = stateByOutcome[outcome]; + const name = this.options.name ?? 'Agent Zero'; + const secrets = secretValuesFromEnvironment(); + await sendProviderRequest({ + provider: 'bitbucket-cloud', + method: 'POST', + url: `${this.baseUrl}/2.0/repositories/${target.owner}/${target.repo}/commit/${target.headSha}/statuses/build`, + token: this.options.token, + tokenScheme: 'Bearer', + fetch: this.options.fetch, + body: { + key: name, + name, + state, + // Bitbucket Cloud requires a URL on build statuses; the pull request itself is the + // only address this run is guaranteed to have. + url: `https://bitbucket.org/${target.owner}/${target.repo}/pull-requests/${String(target.number)}`, + description: redactSecrets(evidenceTitle(bundle), secrets).slice(0, MAX_DESCRIPTION), + }, + }); + return { + outcome, + state, + ...(outcome === 'neutral' || outcome === 'action-required' + ? { degraded: `Bitbucket build statuses have no ${outcome} state; reported ${state}` } + : {}), + }; + } +} + +export const bitbucketCloudProvider: SourceControlProvider = { + kind: 'bitbucket-cloud', + capabilities, + recognizes(headers: WebhookHeaders): boolean { + return readHeader(headers, 'x-event-key')?.startsWith('pullrequest:') === true; + }, + eventName(headers: WebhookHeaders): string | undefined { + return readHeader(headers, 'x-event-key'); + }, + verifyWebhook(delivery: WebhookDelivery, secret: string): boolean { + const signature = readHeader(delivery.headers, 'x-hub-signature'); + return verifyHmacSha256(delivery.body, signature, secret, 'sha256='); + }, + parseReviewEvent: parseBitbucketCloudReviewEvent, + statusPublisher(options: StatusPublisherOptions): StatusPublisher { + if (options.token.length === 0) + throw new ProviderConfigurationError('bitbucket-cloud', 'a status token is required'); + return new BitbucketCloudStatusPublisher( + options, + options.baseUrl ?? 'https://api.bitbucket.org', + ); + }, +}; diff --git a/packages/source-control/src/providers/bitbucket-data-center.ts b/packages/source-control/src/providers/bitbucket-data-center.ts new file mode 100644 index 0000000..e3769de --- /dev/null +++ b/packages/source-control/src/providers/bitbucket-data-center.ts @@ -0,0 +1,208 @@ +import { + evidenceTitle, + redactSecrets, + secretValuesFromEnvironment, + type EvidenceBundle, + type FeedbackItem, +} from '@agent-zero/shared'; + +import { + ProviderConfigurationError, + runOutcome, + type ChangeRequestRef, + type ParseOptions, + type ProviderCapabilities, + type ReviewEvent, + type RunOutcome, + type SourceControlProvider, + type StatusPublication, + type StatusPublisher, + type StatusPublisherOptions, + type WebhookDelivery, + type WebhookHeaders, +} from '../contracts.js'; +import { verifyHmacSha256 } from '../signatures.js'; +import { sendProviderRequest } from '../status.js'; +import { + acceptAuthor, + readBody, + readHeader, + readId, + readLine, + readPositiveInteger, + readRecord, + readSha, + readString, +} from '../untrusted.js'; + +const MAX_DESCRIPTION = 1_000; +const TRAILING_SLASH = /\/$/u; + +/** + * Bitbucket Data Center's `pr:reviewer:needs_work` event carries no text, so a formal request + * for changes has no claim this adapter can ingest. Comment webhooks do not reliably deliver an + * inline anchor, so path and line are read when present but not promised. + */ +const capabilities: ProviderCapabilities = { + webhookAuthentication: 'hmac-sha256', + statusReporting: 'build-status', + neutralStatus: false, + actionRequiredStatus: false, + reviewSubmissions: false, + changeRequests: false, + inlineComments: false, + botAuthorDetection: false, + diffBase: true, + changeRequestNoun: 'pull request', +}; + +/** Turn a Bitbucket Data Center webhook payload into a review event, or null when nothing is actionable. */ +export function parseBitbucketDataCenterReviewEvent( + event: string, + payload: unknown, + options: ParseOptions = {}, +): ReviewEvent | null { + const record = readRecord(payload); + if (!record) return null; + const changeRequest = readChangeRequest(record); + if (!changeRequest) return null; + + // `pr:modified` fires for title and description edits, which introduce no reviewable diff. + if (event === 'pr:opened' || event === 'pr:from_ref_updated') { + return { + provider: 'bitbucket-data-center', + trigger: 'proactive', + changeRequest, + items: [], + requestedChanges: false, + }; + } + + if (event !== 'pr:comment:added') return null; + const comment = readRecord(record.comment); + if (!comment) return null; + const authorRecord = readRecord(comment.author); + const author = acceptAuthor( + readString(authorRecord?.name) ?? readString(authorRecord?.displayName), + false, + options, + ); + const body = readBody(comment.text); + if (author === null || body === null) return null; + + // The inline anchor is not part of the documented payload everywhere; read it when present. + const anchor = readRecord(comment.anchor) ?? readRecord(record.commentAnchor); + const path = readString(anchor?.path); + const line = readLine(anchor?.line); + const items: FeedbackItem[] = [ + { + id: readId(comment.id, 'comment'), + kind: 'review-comment', + body, + author, + // The formal "needs work" signal is a separate, bodyless event. + requestedChanges: false, + ...(path === undefined ? {} : { path }), + ...(line === undefined ? {} : { line }), + }, + ]; + return { + provider: 'bitbucket-data-center', + trigger: 'feedback', + changeRequest, + items, + requestedChanges: false, + }; +} + +function readChangeRequest(payload: Record): ChangeRequestRef | null { + const pullRequest = readRecord(payload.pullRequest); + if (!pullRequest) return null; + const fromRef = readRecord(pullRequest.fromRef); + const toRef = readRecord(pullRequest.toRef); + const repository = readRecord(toRef?.repository); + if (!fromRef || !toRef || !repository) return null; + + const number = readPositiveInteger(pullRequest.id); + const headSha = readSha(fromRef.latestCommit); + // The target head serves as the diff base; the runner diffs with merge-base semantics. + const baseSha = readSha(toRef.latestCommit); + const repo = readString(repository.slug); + const owner = readString(readRecord(repository.project)?.key); + if (number === undefined || !headSha || !baseSha || !repo || !owner) return null; + return { provider: 'bitbucket-data-center', owner, repo, number, headSha, baseSha }; +} + +const stateByOutcome: Record = { + success: 'SUCCESSFUL', + failure: 'FAILED', + // Build statuses have no neutral or action-required state; see the Cloud adapter for the + // rationale behind these mappings. Both are reported as degraded. + neutral: 'SUCCESSFUL', + 'action-required': 'FAILED', +}; + +class BitbucketDataCenterStatusPublisher implements StatusPublisher { + constructor( + private readonly options: StatusPublisherOptions, + private readonly baseUrl: string, + ) {} + + async publish(target: ChangeRequestRef, bundle: EvidenceBundle): Promise { + const outcome = runOutcome(bundle); + const state = stateByOutcome[outcome]; + const name = this.options.name ?? 'Agent Zero'; + const secrets = secretValuesFromEnvironment(); + await sendProviderRequest({ + provider: 'bitbucket-data-center', + method: 'POST', + url: `${this.baseUrl}/rest/api/latest/projects/${target.owner}/repos/${target.repo}/commits/${target.headSha}/builds`, + token: this.options.token, + tokenScheme: 'Bearer', + fetch: this.options.fetch, + body: { + key: name, + name, + state, + url: `${this.baseUrl}/projects/${target.owner}/repos/${target.repo}/pull-requests/${String(target.number)}`, + description: redactSecrets(evidenceTitle(bundle), secrets).slice(0, MAX_DESCRIPTION), + }, + }); + return { + outcome, + state, + ...(outcome === 'neutral' || outcome === 'action-required' + ? { degraded: `Bitbucket build statuses have no ${outcome} state; reported ${state}` } + : {}), + }; + } +} + +export const bitbucketDataCenterProvider: SourceControlProvider = { + kind: 'bitbucket-data-center', + capabilities, + recognizes(headers: WebhookHeaders): boolean { + return readHeader(headers, 'x-event-key')?.startsWith('pr:') === true; + }, + eventName(headers: WebhookHeaders): string | undefined { + return readHeader(headers, 'x-event-key'); + }, + verifyWebhook(delivery: WebhookDelivery, secret: string): boolean { + const signature = readHeader(delivery.headers, 'x-hub-signature'); + return verifyHmacSha256(delivery.body, signature, secret, 'sha256='); + }, + parseReviewEvent: parseBitbucketDataCenterReviewEvent, + statusPublisher(options: StatusPublisherOptions): StatusPublisher { + if (options.token.length === 0) + throw new ProviderConfigurationError('bitbucket-data-center', 'a status token is required'); + if (!options.baseUrl) + throw new ProviderConfigurationError( + 'bitbucket-data-center', + 'a baseUrl is required for a self-hosted instance', + ); + return new BitbucketDataCenterStatusPublisher( + options, + options.baseUrl.replace(TRAILING_SLASH, ''), + ); + }, +}; diff --git a/packages/source-control/src/providers/bitbucket.test.ts b/packages/source-control/src/providers/bitbucket.test.ts new file mode 100644 index 0000000..0989e29 --- /dev/null +++ b/packages/source-control/src/providers/bitbucket.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; + +import { parseBitbucketCloudReviewEvent } from './bitbucket-cloud.js'; +import { parseBitbucketDataCenterReviewEvent } from './bitbucket-data-center.js'; + +const cloudPayload = { + pullrequest: { + id: 7, + source: { commit: { hash: 'a'.repeat(12) } }, + destination: { commit: { hash: 'b'.repeat(12) } }, + }, + repository: { full_name: 'acme/app' }, +}; + +describe('parseBitbucketCloudReviewEvent', () => { + it('starts a proactive review for a created or updated pull request', () => { + for (const event of ['pullrequest:created', 'pullrequest:updated']) { + expect(parseBitbucketCloudReviewEvent(event, cloudPayload)).toMatchObject({ + provider: 'bitbucket-cloud', + trigger: 'proactive', + changeRequest: { + owner: 'acme', + repo: 'app', + number: 7, + headSha: 'a'.repeat(12), + baseSha: 'b'.repeat(12), + }, + }); + } + }); + + it('normalizes a comment with its inline anchor', () => { + const event = parseBitbucketCloudReviewEvent('pullrequest:comment_created', { + ...cloudPayload, + comment: { + id: 31, + content: { raw: 'This dereferences a null return.' }, + user: { nickname: 'alice', display_name: 'Alice' }, + inline: { path: 'src/user.ts', to: 12 }, + }, + }); + expect(event?.items[0]).toMatchObject({ + id: 'comment:31', + author: 'alice', + path: 'src/user.ts', + line: 12, + }); + }); + + it('falls back to the display name when no nickname exists', () => { + const event = parseBitbucketCloudReviewEvent('pullrequest:comment_created', { + ...cloudPayload, + comment: { id: 31, content: { raw: 'claim' }, user: { display_name: 'Alice' } }, + }); + expect(event?.items[0]?.author).toBe('Alice'); + }); + + it('produces nothing for the bodyless changes-requested event', () => { + expect( + parseBitbucketCloudReviewEvent('pullrequest:changes_request_created', cloudPayload), + ).toBeNull(); + }); +}); + +const dataCenterPayload = { + pullRequest: { + id: 7, + fromRef: { latestCommit: 'a'.repeat(40) }, + toRef: { + latestCommit: 'b'.repeat(40), + repository: { slug: 'app', project: { key: 'ACME' } }, + }, + }, +}; + +describe('parseBitbucketDataCenterReviewEvent', () => { + it('starts a proactive review when a pull request opens or gains commits', () => { + for (const event of ['pr:opened', 'pr:from_ref_updated']) { + expect(parseBitbucketDataCenterReviewEvent(event, dataCenterPayload)).toMatchObject({ + provider: 'bitbucket-data-center', + trigger: 'proactive', + changeRequest: { owner: 'ACME', repo: 'app', number: 7 }, + }); + } + }); + + it('ignores metadata edits that introduce no reviewable diff', () => { + expect(parseBitbucketDataCenterReviewEvent('pr:modified', dataCenterPayload)).toBeNull(); + }); + + it('normalizes a comment and survives a missing inline anchor', () => { + const event = parseBitbucketDataCenterReviewEvent('pr:comment:added', { + ...dataCenterPayload, + comment: { id: 41, text: 'This dereferences a null return.', author: { name: 'alice' } }, + }); + expect(event?.items[0]).toMatchObject({ id: 'comment:41', author: 'alice' }); + expect(event?.items[0]?.path).toBeUndefined(); + }); + + it('produces nothing for the bodyless needs-work event', () => { + expect( + parseBitbucketDataCenterReviewEvent('pr:reviewer:needs_work', dataCenterPayload), + ).toBeNull(); + }); +}); diff --git a/packages/source-control/src/providers/gitea.test.ts b/packages/source-control/src/providers/gitea.test.ts new file mode 100644 index 0000000..0bd55be --- /dev/null +++ b/packages/source-control/src/providers/gitea.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it } from 'vitest'; + +import { parseGiteaReviewEvent } from './gitea.js'; + +const repository = { name: 'app', owner: { login: 'acme' } }; +const pullRequest = { + number: 7, + base: { sha: 'b'.repeat(40) }, + head: { sha: 'a'.repeat(40) }, +}; + +function review(overrides: Record = {}): Record { + return { + action: 'reviewed', + repository, + pull_request: pullRequest, + sender: { login: 'alice' }, + review: { + id: 51, + type: 'pull_request_review_rejected', + content: 'Guard the null return.', + ...overrides, + }, + }; +} + +describe('parseGiteaReviewEvent', () => { + it('starts a proactive review for new and synchronized pull requests', () => { + for (const action of ['opened', 'reopened', 'synchronized', 'synchronize']) { + expect( + parseGiteaReviewEvent('pull_request', { action, repository, pull_request: pullRequest }), + ).toMatchObject({ + provider: 'gitea', + trigger: 'proactive', + changeRequest: { owner: 'acme', repo: 'app', baseSha: 'b'.repeat(40) }, + }); + } + }); + + it('marks a rejected review as a request for changes', () => { + const event = parseGiteaReviewEvent('pull_request_review_rejected', review()); + expect(event?.requestedChanges).toBe(true); + expect(event?.items[0]).toMatchObject({ kind: 'review-body', author: 'alice' }); + }); + + it('ingests a comment review without marking changes requested', () => { + const event = parseGiteaReviewEvent( + 'pull_request_review_comment', + review({ type: 'pull_request_review_comment' }), + ); + expect(event?.requestedChanges).toBe(false); + }); + + it('dispatches on review.type when the event name is the bare review event', () => { + const event = parseGiteaReviewEvent('pull_request_review', review()); + expect(event?.requestedChanges).toBe(true); + }); + + it('produces nothing for an approval', () => { + expect( + parseGiteaReviewEvent( + 'pull_request_review_approved', + review({ type: 'pull_request_review_approved', content: 'Nice.' }), + ), + ).toBeNull(); + }); + + it('produces nothing for a rejection with no body to act on', () => { + expect( + parseGiteaReviewEvent('pull_request_review_rejected', review({ content: ' ' })), + ).toBeNull(); + }); + + it('accepts a Forgejo-style owner username field', () => { + const event = parseGiteaReviewEvent('pull_request', { + action: 'opened', + repository: { name: 'app', owner: { username: 'acme' } }, + pull_request: pullRequest, + }); + expect(event?.changeRequest.owner).toBe('acme'); + }); +}); diff --git a/packages/source-control/src/providers/gitea.ts b/packages/source-control/src/providers/gitea.ts new file mode 100644 index 0000000..5d4e86c --- /dev/null +++ b/packages/source-control/src/providers/gitea.ts @@ -0,0 +1,223 @@ +import { + evidenceTitle, + redactSecrets, + secretValuesFromEnvironment, + type EvidenceBundle, + type FeedbackItem, +} from '@agent-zero/shared'; + +import { + ProviderConfigurationError, + runOutcome, + type ChangeRequestRef, + type ParseOptions, + type ProviderCapabilities, + type ReviewEvent, + type RunOutcome, + type SourceControlProvider, + type StatusPublication, + type StatusPublisher, + type StatusPublisherOptions, + type WebhookDelivery, + type WebhookHeaders, +} from '../contracts.js'; +import { verifyHmacSha256 } from '../signatures.js'; +import { sendProviderRequest } from '../status.js'; +import { + acceptAuthor, + readBody, + readHeader, + readId, + readPositiveInteger, + readRecord, + readSha, + readString, +} from '../untrusted.js'; + +const MAX_DESCRIPTION = 1_000; +const TRAILING_SLASH = /\/$/u; + +/** + * One adapter serves Gitea and Forgejo: Forgejo keeps Gitea's payload shapes and sends both its + * own and Gitea's compatibility headers. Review submissions arrive as whole events without + * per-comment file anchors, so inline positions are not promised. + */ +const capabilities: ProviderCapabilities = { + webhookAuthentication: 'hmac-sha256', + statusReporting: 'commit-status', + neutralStatus: false, + actionRequiredStatus: false, + reviewSubmissions: true, + changeRequests: true, + inlineComments: false, + botAuthorDetection: false, + diffBase: true, + changeRequestNoun: 'pull request', +}; + +const REVIEW_EVENT = /^pull_request_review/u; + +/** Turn a Gitea or Forgejo webhook payload into a review event, or null when nothing is actionable. */ +export function parseGiteaReviewEvent( + event: string, + payload: unknown, + options: ParseOptions = {}, +): ReviewEvent | null { + const record = readRecord(payload); + if (!record) return null; + const changeRequest = readChangeRequest(record); + if (!changeRequest) return null; + + if (event === 'pull_request') { + if (!isProactiveAction(record.action)) return null; + return { + provider: 'gitea', + trigger: 'proactive', + changeRequest, + items: [], + requestedChanges: false, + }; + } + + if (!REVIEW_EVENT.test(event)) return null; + const items = readReview(event, record, options); + if (!items || items.length === 0) return null; + return { + provider: 'gitea', + trigger: 'feedback', + changeRequest, + items, + requestedChanges: items.some((item) => item.requestedChanges), + }; +} + +function isProactiveAction(action: unknown): boolean { + return ( + action === 'opened' || + action === 'reopened' || + action === 'synchronize' || + action === 'synchronized' || + action === 'ready_for_review' + ); +} + +/** + * Gitea names review events `pull_request_review_approved`, `..._rejected`, and `..._comment`; + * some versions send a plain `pull_request_review` and disambiguate through `review.type`. Both + * spellings are accepted, and an approval produces nothing: there is no claim to validate. + */ +function readReview( + event: string, + payload: Record, + options: ParseOptions, +): FeedbackItem[] | null { + const review = readRecord(payload.review); + if (!review) return null; + const marker = `${event} ${readString(review.type) ?? ''}`; + if (marker.includes('approved')) return null; + const requestedChanges = marker.includes('rejected'); + if (!requestedChanges && !marker.includes('comment')) return null; + + const author = acceptAuthor(readString(readRecord(payload.sender)?.login), false, options); + const body = readBody(review.content); + if (author === null || body === null) return null; + return [ + { + id: readId(review.id, 'review'), + kind: 'review-body', + body, + author, + requestedChanges, + }, + ]; +} + +function readChangeRequest(payload: Record): ChangeRequestRef | null { + const pullRequest = readRecord(payload.pull_request); + const repository = readRecord(payload.repository); + if (!pullRequest || !repository) return null; + + const number = readPositiveInteger(pullRequest.number); + const headSha = readSha(readRecord(pullRequest.head)?.sha); + const baseSha = readSha(readRecord(pullRequest.base)?.sha); + const repo = readString(repository.name); + const ownerRecord = readRecord(repository.owner); + const owner = readString(ownerRecord?.login) ?? readString(ownerRecord?.username); + if (number === undefined || !headSha || !baseSha || !repo || !owner) return null; + return { provider: 'gitea', owner, repo, number, headSha, baseSha }; +} + +const stateByOutcome: Record = { + success: 'success', + failure: 'failure', + // Gitea statuses have no neutral state; nothing is wrong on a neutral outcome, so it maps to + // success. `warning` is the closest visible signal for a run that needs a human. Both are + // reported as degraded. + neutral: 'success', + 'action-required': 'warning', +}; + +class GiteaStatusPublisher implements StatusPublisher { + constructor( + private readonly options: StatusPublisherOptions, + private readonly baseUrl: string, + ) {} + + async publish(target: ChangeRequestRef, bundle: EvidenceBundle): Promise { + const outcome = runOutcome(bundle); + const state = stateByOutcome[outcome]; + const secrets = secretValuesFromEnvironment(); + await sendProviderRequest({ + provider: 'gitea', + method: 'POST', + url: `${this.baseUrl}/api/v1/repos/${target.owner}/${target.repo}/statuses/${target.headSha}`, + token: this.options.token, + tokenScheme: 'token', + fetch: this.options.fetch, + body: { + state, + context: this.options.name ?? 'Agent Zero', + description: redactSecrets(evidenceTitle(bundle), secrets).slice(0, MAX_DESCRIPTION), + }, + }); + return { + outcome, + state, + ...(outcome === 'neutral' || outcome === 'action-required' + ? { degraded: `Gitea commit statuses have no ${outcome} state; reported ${state}` } + : {}), + }; + } +} + +export const giteaProvider: SourceControlProvider = { + kind: 'gitea', + capabilities, + recognizes(headers: WebhookHeaders): boolean { + return ( + readHeader(headers, 'x-gitea-event') !== undefined || + readHeader(headers, 'x-forgejo-event') !== undefined + ); + }, + eventName(headers: WebhookHeaders): string | undefined { + return readHeader(headers, 'x-gitea-event') ?? readHeader(headers, 'x-forgejo-event'); + }, + verifyWebhook(delivery: WebhookDelivery, secret: string): boolean { + const signature = + readHeader(delivery.headers, 'x-gitea-signature') ?? + readHeader(delivery.headers, 'x-forgejo-signature'); + // Gitea and Forgejo sign with bare-hex HMAC-SHA256, without GitHub's `sha256=` prefix. + return verifyHmacSha256(delivery.body, signature, secret); + }, + parseReviewEvent: parseGiteaReviewEvent, + statusPublisher(options: StatusPublisherOptions): StatusPublisher { + if (options.token.length === 0) + throw new ProviderConfigurationError('gitea', 'a status token is required'); + if (!options.baseUrl) + throw new ProviderConfigurationError( + 'gitea', + 'a baseUrl is required for a self-hosted instance', + ); + return new GiteaStatusPublisher(options, options.baseUrl.replace(TRAILING_SLASH, '')); + }, +}; diff --git a/packages/github/src/comments.test.ts b/packages/source-control/src/providers/github-comments.test.ts similarity index 97% rename from packages/github/src/comments.test.ts rename to packages/source-control/src/providers/github-comments.test.ts index b0fc161..0dee944 100644 --- a/packages/github/src/comments.test.ts +++ b/packages/source-control/src/providers/github-comments.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { GitHubIssueComments } from './comments.js'; +import { GitHubIssueComments } from './github-comments.js'; interface RecordedRequest { method: string; diff --git a/packages/github/src/comments.ts b/packages/source-control/src/providers/github-comments.ts similarity index 100% rename from packages/github/src/comments.ts rename to packages/source-control/src/providers/github-comments.ts diff --git a/packages/github/src/issues.test.ts b/packages/source-control/src/providers/github-issues.test.ts similarity index 99% rename from packages/github/src/issues.test.ts rename to packages/source-control/src/providers/github-issues.test.ts index fd5aaf2..38253d3 100644 --- a/packages/github/src/issues.test.ts +++ b/packages/source-control/src/providers/github-issues.test.ts @@ -8,7 +8,7 @@ import { prepareIssuePullRequest, prepareIssueValidationComment, VALIDATION_COMMENT_MARKER, -} from './issues.js'; +} from './github-issues.js'; function payload(overrides: Record = {}): Record { return { diff --git a/packages/github/src/issues.ts b/packages/source-control/src/providers/github-issues.ts similarity index 99% rename from packages/github/src/issues.ts rename to packages/source-control/src/providers/github-issues.ts index fc98f42..6601ddf 100644 --- a/packages/github/src/issues.ts +++ b/packages/source-control/src/providers/github-issues.ts @@ -9,7 +9,7 @@ import { type RunMode, } from '@agent-zero/shared'; -import { assertSafeBranchName } from './pulls.js'; +import { assertSafeBranchName } from './github-pulls.js'; /** Webhook event name the issue-to-PR workflow understands. */ export const supportedIssueEvents = ['issues'] as const; diff --git a/packages/github/src/pulls.test.ts b/packages/source-control/src/providers/github-pulls.test.ts similarity index 99% rename from packages/github/src/pulls.test.ts rename to packages/source-control/src/providers/github-pulls.test.ts index 03868bc..43d1a29 100644 --- a/packages/github/src/pulls.test.ts +++ b/packages/source-control/src/providers/github-pulls.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import { GitHubPullRequests, isSafeBranchName } from './pulls.js'; +import { GitHubPullRequests, isSafeBranchName } from './github-pulls.js'; interface RecordedRequest { method: string; diff --git a/packages/github/src/pulls.ts b/packages/source-control/src/providers/github-pulls.ts similarity index 100% rename from packages/github/src/pulls.ts rename to packages/source-control/src/providers/github-pulls.ts diff --git a/packages/github/src/events.test.ts b/packages/source-control/src/providers/github.test.ts similarity index 53% rename from packages/github/src/events.test.ts rename to packages/source-control/src/providers/github.test.ts index ebc9baf..55d57a7 100644 --- a/packages/github/src/events.test.ts +++ b/packages/source-control/src/providers/github.test.ts @@ -1,6 +1,8 @@ +import type { CheckResult, EvidenceBundle } from '@agent-zero/shared'; import { describe, expect, it } from 'vitest'; -import { parseReviewEvent, reviewInputFromEvent } from './events.js'; +import { reviewInputFromEvent } from '../input.js'; +import { checkConclusion, GitHubChecks, parseReviewEvent } from './github.js'; const repository = { name: 'app', owner: { login: 'acme' } }; const pullRequest = { @@ -44,8 +46,10 @@ describe('parseReviewEvent for inline comments', () => { it('normalizes a created review comment', () => { const event = parseReviewEvent('pull_request_review_comment', reviewComment()); expect(event).toEqual({ + provider: 'github', trigger: 'feedback', - pullRequest: { + changeRequest: { + provider: 'github', owner: 'acme', repo: 'app', number: 7, @@ -102,7 +106,7 @@ describe('parseReviewEvent for proactive pull-request changes', () => { pull_request: pullRequest, }); expect(event).toMatchObject({ trigger: 'proactive', items: [], requestedChanges: false }); - expect(event?.pullRequest).toMatchObject({ + expect(event?.changeRequest).toMatchObject({ baseSha: 'b'.repeat(40), headSha: 'a'.repeat(40), }); @@ -230,3 +234,201 @@ describe('reviewInputFromEvent', () => { expect(reviewInputFromEvent(event!, { checkoutPath: '/checkout' }).files).toBeUndefined(); }); }); + +const target = { + provider: 'github' as const, + owner: 'acme', + repo: 'app', + number: 7, + baseSha: 'b'.repeat(40), + headSha: 'a'.repeat(40), +}; +const REDACTED_MARKER = /\[redacted]/; +const LEAKED_TOKEN = /ghs_token_value/; + +const passing: CheckResult = { + command: 'pnpm run test', + exitCode: 0, + stdout: '', + stderr: '', + durationMs: 5, +}; +const failing: CheckResult = { ...passing, exitCode: 1, stderr: 'assertion failed' }; + +function bundle(overrides: Partial = {}): EvidenceBundle { + return { + taskId: 'az_test', + state: 'completed', + verdict: 'accepted', + verified: true, + mode: 'fix', + trigger: 'feedback', + source: 'github:acme/app#7', + issue: null, + runner: { kind: 'container', isolated: true, writable: true, network: 'none' }, + finding: null, + plan: [], + acceptanceCriteria: [], + changedFiles: ['src/user.ts'], + checks: [passing], + attempts: 1, + transitions: [], + summary: 'Fixed and verified', + ...overrides, + }; +} + +describe('checkConclusion', () => { + it('reports success only for a verified run', () => { + expect(checkConclusion(bundle())).toBe('success'); + }); + + it('never reports success when a check failed', () => { + expect(checkConclusion(bundle({ checks: [passing, failing], verified: true }))).toBe('failure'); + }); + + it('reports a crashed run as a failure', () => { + expect(checkConclusion(bundle({ state: 'failed', verified: false, checks: [] }))).toBe( + 'failure', + ); + }); + + it('asks for action when a run needs a human', () => { + expect(checkConclusion(bundle({ state: 'needs-human', verified: false, checks: [] }))).toBe( + 'action_required', + ); + }); + + it('reports rejected feedback as neutral rather than a pull-request failure', () => { + expect( + checkConclusion( + bundle({ verdict: 'rejected', verified: false, checks: [], changedFiles: [] }), + ), + ).toBe('neutral'); + }); + + it('reports an observe-only run as neutral', () => { + expect( + checkConclusion(bundle({ mode: 'observe', verified: false, checks: [], changedFiles: [] })), + ).toBe('neutral'); + }); +}); + +/** One recorded request, already decoded so tests never stringify an unknown body. */ +interface RecordedCall { + url: string; + headers: Headers; + body: Record; +} + +type FetchArguments = Parameters; + +const token = 'ghs_token_value_1234567890'; + +function requestUrl(url: FetchArguments[0]): string { + if (typeof url === 'string') return url; + return url instanceof URL ? url.href : url.url; +} + +function readBody(body: NonNullable['body']): Record { + if (typeof body !== 'string') return {}; + const parsed: unknown = JSON.parse(body); + return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed) + ? { ...parsed } + : {}; +} + +function created(): Response { + return new Response(JSON.stringify({ id: 42 }), { + status: 201, + headers: { 'content-type': 'application/json' }, + }); +} + +function client(handler: () => Response = created): { + checks: GitHubChecks; + calls: RecordedCall[]; +} { + const calls: RecordedCall[] = []; + const checks = new GitHubChecks({ + token, + fetch: async (url, init) => { + calls.push({ + url: requestUrl(url), + headers: new Headers(init?.headers), + body: readBody(init?.body), + }); + return handler(); + }, + }); + return { checks, calls }; +} + +/** The check output object a completion request carries. */ +function readOutput(call: RecordedCall | undefined): { title: string; text: string } { + const output = call?.body.output; + if (typeof output !== 'object' || output === null) throw new Error('no check output recorded'); + const record: Record = { ...output }; + return { + title: typeof record.title === 'string' ? record.title : '', + text: typeof record.text === 'string' ? record.text : '', + }; +} + +describe('GitHubChecks', () => { + it('opens an in-progress run against the head commit', async () => { + const { checks, calls } = client(created); + await expect(checks.start(target)).resolves.toBe(42); + expect(calls[0]?.url).toBe('https://api.github.com/repos/acme/app/check-runs'); + expect(calls[0]?.body).toMatchObject({ + head_sha: target.headSha, + status: 'in_progress', + name: 'Agent Zero', + }); + }); + + it('sends the token only as an authorization header', async () => { + const { checks, calls } = client(created); + await checks.publish(target, bundle()); + expect(calls[0]?.headers.get('authorization')).toBe(`Bearer ${token}`); + expect(JSON.stringify(calls[0]?.body)).not.toContain('ghs_token_value'); + expect(calls[0]?.url).not.toContain('ghs_token_value'); + }); + + it('completes a run with the evidence report and a matching conclusion', async () => { + const { checks, calls } = client(created); + await checks.complete(target, 42, bundle({ verified: false, checks: [failing] })); + expect(calls[0]?.url).toBe('https://api.github.com/repos/acme/app/check-runs/42'); + expect(calls[0]?.body.conclusion).toBe('failure'); + const output = readOutput(calls[0]); + expect(output.title).toContain('Feedback accepted'); + expect(output.title).toContain('failed'); + expect(output.text).toContain('assertion failed'); + }); + + it('keeps the report inside the GitHub output limit', async () => { + const { checks, calls } = client(created); + await checks.publish( + target, + bundle({ verified: false, checks: [{ ...failing, stderr: 'x'.repeat(200_000) }] }), + ); + expect(readOutput(calls[0]).text.length).toBeLessThanOrEqual(60_000); + }); + + it('redacts the token from a failed request instead of leaking it', async () => { + const { checks } = client(() => new Response(`bad credentials for ${token}`, { status: 401 })); + await expect(checks.publish(target, bundle())).rejects.toThrow(REDACTED_MARKER); + await expect(checks.publish(target, bundle())).rejects.not.toThrow(LEAKED_TOKEN); + }); + + it('fails loudly when GitHub does not return a check run id', async () => { + const { checks } = client( + () => + new Response(JSON.stringify({ message: 'ok' }), { + status: 201, + headers: { 'content-type': 'application/json' }, + }), + ); + await expect(checks.start(target)).rejects.toThrow('did not return a check run id'); + }); +}); diff --git a/packages/source-control/src/providers/github.ts b/packages/source-control/src/providers/github.ts new file mode 100644 index 0000000..23c14d6 --- /dev/null +++ b/packages/source-control/src/providers/github.ts @@ -0,0 +1,356 @@ +import { + evidenceTitle, + redactSecrets, + renderEvidenceMarkdown, + secretValuesFromEnvironment, + type EvidenceBundle, + type FeedbackItem, +} from '@agent-zero/shared'; + +import { + ProviderConfigurationError, + runOutcome, + type ChangeRequestRef, + type ParseOptions, + type ProviderCapabilities, + type ReviewEvent, + type SourceControlProvider, + type StatusPublication, + type StatusPublisherOptions, + type WebhookDelivery, + type WebhookHeaders, +} from '../contracts.js'; +import { verifyHmacSha256 } from '../signatures.js'; +import { + acceptAuthor, + isRecord, + readBody, + readHeader, + readId, + readLine, + readPositiveInteger, + readRecord, + readSha, + readString, +} from '../untrusted.js'; + +/** Webhook event names this adapter understands. */ +export const supportedEvents = [ + 'pull_request', + 'pull_request_review', + 'pull_request_review_comment', +] as const; +export type SupportedEvent = (typeof supportedEvents)[number]; + +/** Conclusions a GitHub check run may report. */ +export type CheckConclusion = 'success' | 'failure' | 'neutral' | 'action_required'; + +/** GitHub caps check output fields; staying under the limit keeps a report from being rejected. */ +const MAX_OUTPUT = 60_000; +const MAX_TITLE = 255; +const MAX_SUMMARY = 4_000; + +const capabilities: ProviderCapabilities = { + webhookAuthentication: 'hmac-sha256', + statusReporting: 'check-runs', + neutralStatus: true, + actionRequiredStatus: true, + reviewSubmissions: true, + changeRequests: true, + inlineComments: true, + botAuthorDetection: true, + diffBase: true, + changeRequestNoun: 'pull request', +}; + +/** Terminal run outcomes map one-to-one onto GitHub check conclusions. */ +export function checkConclusion(bundle: EvidenceBundle): CheckConclusion { + const outcome = runOutcome(bundle); + return outcome === 'action-required' ? 'action_required' : outcome; +} + +export interface GitHubChecksOptions { + token: string; + baseUrl?: string; + /** Check run name, so several Agent Zero configurations can report side by side. */ + name?: string; + fetch?: typeof globalThis.fetch; +} + +/** + * Publishes run evidence to the GitHub Checks API. + * + * The token is only ever sent as an Authorization header, and any error body is redacted before + * it is raised, so a failed publish cannot leak a credential into logs. + */ +export class GitHubChecks { + private readonly baseUrl: string; + private readonly name: string; + private readonly request: typeof globalThis.fetch; + + constructor(private readonly options: GitHubChecksOptions) { + this.baseUrl = options.baseUrl ?? 'https://api.github.com'; + this.name = options.name ?? 'Agent Zero'; + this.request = options.fetch ?? globalThis.fetch; + } + + /** Open an in-progress check run so a long verification is visible while it happens. */ + async start(target: Pick): Promise { + const body = await this.send('POST', `/repos/${target.owner}/${target.repo}/check-runs`, { + name: this.name, + head_sha: target.headSha, + status: 'in_progress', + }); + return readCheckRunId(body); + } + + /** Complete an existing check run with the run's evidence. */ + async complete( + target: Pick, + checkRunId: number, + bundle: EvidenceBundle, + ): Promise { + await this.send( + 'PATCH', + `/repos/${target.owner}/${target.repo}/check-runs/${String(checkRunId)}`, + this.completionPayload(bundle), + ); + } + + /** Create an already-completed check run, for a verification that finished quickly. */ + async publish( + target: Pick, + bundle: EvidenceBundle, + ): Promise { + const body = await this.send('POST', `/repos/${target.owner}/${target.repo}/check-runs`, { + name: this.name, + head_sha: target.headSha, + ...this.completionPayload(bundle), + }); + return readCheckRunId(body); + } + + /** The request body for a finished check run, including the rendered evidence report. */ + completionPayload(bundle: EvidenceBundle): Record { + const secrets = secretValuesFromEnvironment(); + return { + status: 'completed', + conclusion: checkConclusion(bundle), + output: { + title: redactSecrets(evidenceTitle(bundle), secrets).slice(0, MAX_TITLE), + summary: redactSecrets(bundle.summary, secrets).slice(0, MAX_SUMMARY), + text: renderEvidenceMarkdown(bundle, { maxLength: MAX_OUTPUT, secrets }), + }, + }; + } + + private async send( + method: 'POST' | 'PATCH', + path: string, + body: Record, + ): Promise { + const response = await this.request(`${this.baseUrl}${path}`, { + method, + headers: { + accept: 'application/vnd.github+json', + authorization: `Bearer ${this.options.token}`, + 'content-type': 'application/json', + 'x-github-api-version': '2022-11-28', + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + const detail = redactSecrets(await response.text(), [ + this.options.token, + ...secretValuesFromEnvironment(), + ]); + throw new Error( + `GitHub check run request failed (${String(response.status)}): ${detail.slice(0, 1_000)}`, + ); + } + return response.json(); + } +} + +function readCheckRunId(body: unknown): number { + if (typeof body === 'object' && body !== null && 'id' in body && typeof body.id === 'number') + return body.id; + throw new Error('GitHub did not return a check run id'); +} + +/** + * Turn a GitHub webhook payload into a review event, or null when there is nothing to act on. + * + * The payload is untrusted, so every field is checked rather than asserted. Approvals and + * dismissals produce nothing: there is no claim to validate. + */ +export function parseReviewEvent( + event: string, + payload: unknown, + options: ParseOptions = {}, +): ReviewEvent | null { + if (!isRecord(payload)) return null; + const changeRequest = readChangeRequest(payload); + if (!changeRequest) return null; + + if (event === 'pull_request') { + if (!isProactiveAction(payload.action)) return null; + return { + provider: 'github', + trigger: 'proactive', + changeRequest, + items: [], + requestedChanges: false, + }; + } + + const items = + event === 'pull_request_review_comment' + ? readReviewComment(payload, options) + : event === 'pull_request_review' + ? readReview(payload, options) + : null; + if (!items || items.length === 0) return null; + + return { + provider: 'github', + trigger: 'feedback', + changeRequest, + items, + requestedChanges: items.some((item) => item.requestedChanges), + }; +} + +function readReviewComment( + payload: Record, + options: ParseOptions, +): FeedbackItem[] | null { + if (payload.action !== 'created') return null; + const comment = readRecord(payload.comment); + if (!comment) return null; + const author = readGitHubAuthor(comment.user, options); + const body = readBody(comment.body); + if (author === null || body === null) return null; + const path = readString(comment.path); + const line = readLine(comment.line ?? comment.original_line); + return [ + { + id: readId(comment.id, 'review-comment'), + kind: 'review-comment', + body, + author, + // A single inline comment is a remark; the review that carries it decides on changes. + requestedChanges: false, + ...(path === undefined ? {} : { path }), + ...(line === undefined ? {} : { line }), + }, + ]; +} + +function readReview( + payload: Record, + options: ParseOptions, +): FeedbackItem[] | null { + if (payload.action !== 'submitted') return null; + const review = readRecord(payload.review); + if (!review) return null; + const state = typeof review.state === 'string' ? review.state.toLowerCase() : ''; + // An approval or a dismissal carries no claim to validate. + if (state !== 'changes_requested' && state !== 'commented') return null; + const author = readGitHubAuthor(review.user, options); + const body = readBody(review.body); + if (author === null || body === null) return null; + return [ + { + id: readId(review.id, 'review'), + kind: 'review-body', + body, + author, + requestedChanges: state === 'changes_requested', + }, + ]; +} + +function readChangeRequest(payload: Record): ChangeRequestRef | null { + const pullRequest = readRecord(payload.pull_request); + const repository = readRecord(payload.repository); + if (!pullRequest || !repository) return null; + + const number = readPositiveInteger(pullRequest.number); + const headSha = readSha(readRecord(pullRequest.head)?.sha); + const baseSha = readSha(readRecord(pullRequest.base)?.sha); + const repo = readString(repository.name); + const owner = readString(readRecord(repository.owner)?.login); + + if (number === undefined || !baseSha || !headSha || !repo || !owner) return null; + return { provider: 'github', owner, repo, number, baseSha, headSha }; +} + +function isProactiveAction(action: unknown): boolean { + return ( + action === 'opened' || + action === 'reopened' || + action === 'synchronize' || + action === 'ready_for_review' + ); +} + +function readGitHubAuthor(user: unknown, options: ParseOptions): string | null { + const record = readRecord(user); + if (!record) return null; + return acceptAuthor(readString(record.login), record.type === 'Bot', options); +} + +class GitHubStatusPublisher { + constructor(private readonly checks: GitHubChecks) {} + + async publish(target: ChangeRequestRef, bundle: EvidenceBundle): Promise { + await this.checks.publish(target, bundle); + return { outcome: runOutcome(bundle), state: checkConclusion(bundle) }; + } +} + +export const githubProvider: SourceControlProvider = { + kind: 'github', + capabilities, + recognizes(headers: WebhookHeaders): boolean { + // Gitea and Forgejo send an X-GitHub-Event compatibility header; their own header wins. + if (readHeader(headers, 'x-gitea-event') || readHeader(headers, 'x-forgejo-event')) + return false; + return readHeader(headers, 'x-github-event') !== undefined; + }, + eventName(headers: WebhookHeaders): string | undefined { + return readHeader(headers, 'x-github-event'); + }, + verifyWebhook(delivery: WebhookDelivery, secret: string): boolean { + const signature = readHeader(delivery.headers, 'x-hub-signature-256'); + return verifyHmacSha256(delivery.body, signature, secret, 'sha256='); + }, + parseReviewEvent, + statusPublisher(options: StatusPublisherOptions): GitHubStatusPublisher { + if (options.token.length === 0) + throw new ProviderConfigurationError('github', 'a status token is required'); + return new GitHubStatusPublisher( + new GitHubChecks({ + token: options.token, + ...(options.baseUrl ? { baseUrl: options.baseUrl } : {}), + ...(options.name ? { name: options.name } : {}), + ...(options.fetch ? { fetch: options.fetch } : {}), + }), + ); + }, +}; + +/** + * Verify a GitHub webhook signature in constant time. + * + * Kept as a named export for callers that verify before routing; equivalent to + * `githubProvider.verifyWebhook` on a delivery whose header is already extracted. + */ +export function verifyWebhook( + body: string, + signature: string | undefined, + secret: string, +): boolean { + return verifyHmacSha256(body, signature, secret, 'sha256='); +} diff --git a/packages/source-control/src/providers/gitlab.test.ts b/packages/source-control/src/providers/gitlab.test.ts new file mode 100644 index 0000000..4365ecd --- /dev/null +++ b/packages/source-control/src/providers/gitlab.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; + +import { reviewInputFromEvent } from '../input.js'; +import { parseGitLabReviewEvent } from './gitlab.js'; + +const mergeRequest = { iid: 7, last_commit: { id: 'a'.repeat(40) } }; +const project = { path_with_namespace: 'acme/platform/app' }; + +function mergeRequestHook(overrides: Record = {}): Record { + return { + object_kind: 'merge_request', + project, + object_attributes: { ...mergeRequest, action: 'open', ...overrides }, + }; +} + +function noteHook(overrides: Record = {}): Record { + return { + object_kind: 'note', + user: { username: 'alice' }, + project, + object_attributes: { + id: 11, + note: 'This dereferences a null return.', + noteable_type: 'MergeRequest', + position: { new_path: 'src/user.ts', new_line: 12 }, + ...overrides, + }, + merge_request: mergeRequest, + }; +} + +describe('parseGitLabReviewEvent for merge requests', () => { + it('starts a proactive review when a merge request opens', () => { + const event = parseGitLabReviewEvent('Merge Request Hook', mergeRequestHook()); + expect(event).toMatchObject({ + provider: 'gitlab', + trigger: 'proactive', + items: [], + changeRequest: { + owner: 'acme/platform', + repo: 'app', + number: 7, + headSha: 'a'.repeat(40), + }, + }); + // GitLab webhooks never deliver a diff base; the reference must not invent one. + expect(event?.changeRequest.baseSha).toBeUndefined(); + }); + + it('treats an update as proactive only when commits changed', () => { + expect( + parseGitLabReviewEvent('Merge Request Hook', mergeRequestHook({ action: 'update' })), + ).toBeNull(); + expect( + parseGitLabReviewEvent( + 'Merge Request Hook', + mergeRequestHook({ action: 'update', oldrev: 'c'.repeat(40) }), + ), + ).not.toBeNull(); + }); + + it('produces nothing for approvals, merges, and closes', () => { + for (const action of ['approved', 'unapproved', 'merge', 'close']) + expect(parseGitLabReviewEvent('Merge Request Hook', mergeRequestHook({ action }))).toBeNull(); + }); + + it('accepts the object_kind spelling of the event name', () => { + expect(parseGitLabReviewEvent('merge_request', mergeRequestHook())).not.toBeNull(); + }); + + it('ignores unknown event names and contradictory payload kinds', () => { + expect(parseGitLabReviewEvent('Webhook', mergeRequestHook())).toBeNull(); + expect( + parseGitLabReviewEvent('Note Hook', { ...noteHook(), object_kind: 'merge_request' }), + ).toBeNull(); + }); + + it('requires a well-formed namespace path', () => { + for (const path of ['app', '/app', 'acme/']) { + expect( + parseGitLabReviewEvent('Merge Request Hook', { + ...mergeRequestHook(), + project: { path_with_namespace: path }, + }), + ).toBeNull(); + } + }); +}); + +describe('parseGitLabReviewEvent for notes', () => { + it('normalizes a merge-request note with its inline position', () => { + const event = parseGitLabReviewEvent('Note Hook', noteHook()); + expect(event?.items[0]).toMatchObject({ + id: 'note:11', + kind: 'review-comment', + author: 'alice', + path: 'src/user.ts', + line: 12, + requestedChanges: false, + }); + }); + + it('ignores notes on anything but a merge request', () => { + expect(parseGitLabReviewEvent('Note Hook', noteHook({ noteable_type: 'Commit' }))).toBeNull(); + }); + + it('ignores its own account so a run cannot answer itself', () => { + expect( + parseGitLabReviewEvent('Note Hook', noteHook(), { ignoreAuthors: ['ALICE'] }), + ).toBeNull(); + }); + + it('bounds an oversized note body', () => { + const event = parseGitLabReviewEvent('Note Hook', noteHook({ note: 'x'.repeat(20_000) })); + expect(event?.items[0]?.body.length).toBe(8_000); + }); +}); + +describe('reviewInputFromEvent for GitLab', () => { + it('labels the source with merge-request notation and omits the partial diff reference', () => { + const event = parseGitLabReviewEvent('Note Hook', noteHook()); + const input = reviewInputFromEvent(event!, { checkoutPath: '/checkout' }); + expect(input.source).toBe('gitlab:acme/platform/app!7'); + expect(input.pullRequest).toBeUndefined(); + expect(input.files).toEqual(['src/user.ts']); + }); +}); diff --git a/packages/source-control/src/providers/gitlab.ts b/packages/source-control/src/providers/gitlab.ts new file mode 100644 index 0000000..a5ae036 --- /dev/null +++ b/packages/source-control/src/providers/gitlab.ts @@ -0,0 +1,245 @@ +import { + evidenceTitle, + redactSecrets, + secretValuesFromEnvironment, + type EvidenceBundle, + type FeedbackItem, +} from '@agent-zero/shared'; + +import { + ProviderConfigurationError, + runOutcome, + type ChangeRequestRef, + type ParseOptions, + type ProviderCapabilities, + type ReviewEvent, + type RunOutcome, + type SourceControlProvider, + type StatusPublication, + type StatusPublisher, + type StatusPublisherOptions, + type WebhookDelivery, + type WebhookHeaders, +} from '../contracts.js'; +import { timingSafeStringEqual } from '../signatures.js'; +import { sendProviderRequest } from '../status.js'; +import { + acceptAuthor, + readBody, + readHeader, + readId, + readLine, + readPositiveInteger, + readRecord, + readSha, + readString, +} from '../untrusted.js'; + +const MAX_DESCRIPTION = 1_000; + +/** + * GitLab merge-request webhooks carry the head commit but no diff base, and its commit statuses + * have neither a neutral nor an action-required state, so both degrade explicitly. Approvals and + * "request changes" arrive as actions without a reviewable claim, so `changeRequests` is honest + * about what this adapter can ingest, not about the platform. + */ +const capabilities: ProviderCapabilities = { + webhookAuthentication: 'shared-token', + statusReporting: 'commit-status', + neutralStatus: false, + actionRequiredStatus: false, + reviewSubmissions: false, + changeRequests: false, + inlineComments: true, + botAuthorDetection: false, + diffBase: false, + changeRequestNoun: 'merge request', +}; + +/** + * Turn a GitLab webhook payload into a review event, or null when there is nothing to act on. + * + * The event name is matched against both the `X-Gitlab-Event` header form (`Merge Request Hook`) + * and the payload's `object_kind` spelling, so a caller may pass either. An unknown event name is + * ignored outright, and a payload whose `object_kind` contradicts the event name is rejected + * rather than trusted. + */ +export function parseGitLabReviewEvent( + event: string, + payload: unknown, + options: ParseOptions = {}, +): ReviewEvent | null { + const record = readRecord(payload); + if (!record) return null; + const kind = normalizeEventName(event); + if (kind === undefined) return null; + const objectKind = readString(record.object_kind); + if (objectKind !== undefined && objectKind !== kind) return null; + + if (kind === 'merge_request') return readMergeRequestEvent(record); + return readNoteEvent(record, options); +} + +function normalizeEventName(event: string): string | undefined { + const lowered = event.toLowerCase(); + if (lowered === 'merge request hook' || lowered === 'merge_request') return 'merge_request'; + if (lowered === 'note hook' || lowered === 'note') return 'note'; + return undefined; +} + +function readMergeRequestEvent(payload: Record): ReviewEvent | null { + const attributes = readRecord(payload.object_attributes); + if (!attributes) return null; + const action = readString(attributes.action); + // `update` fires for title and label edits too; `oldrev` is only present when commits changed. + const proactive = + action === 'open' || + action === 'reopen' || + (action === 'update' && readString(attributes.oldrev) !== undefined); + if (!proactive) return null; + + const changeRequest = readChangeRequest(payload, attributes); + if (!changeRequest) return null; + return { + provider: 'gitlab', + trigger: 'proactive', + changeRequest, + items: [], + requestedChanges: false, + }; +} + +function readNoteEvent( + payload: Record, + options: ParseOptions, +): ReviewEvent | null { + const attributes = readRecord(payload.object_attributes); + const mergeRequest = readRecord(payload.merge_request); + if (!attributes || !mergeRequest) return null; + if (readString(attributes.noteable_type) !== 'MergeRequest') return null; + + const author = acceptAuthor(readString(readRecord(payload.user)?.username), false, options); + const body = readBody(attributes.note); + if (author === null || body === null) return null; + + const changeRequest = readChangeRequest(payload, mergeRequest); + if (!changeRequest) return null; + + const position = readRecord(attributes.position); + const path = readString(position?.new_path); + const line = readLine(position?.new_line); + const items: FeedbackItem[] = [ + { + id: readId(attributes.id, 'note'), + kind: 'review-comment', + body, + author, + // GitLab notes are remarks; formal change requests never reach this adapter as text. + requestedChanges: false, + ...(path === undefined ? {} : { path }), + ...(line === undefined ? {} : { line }), + }, + ]; + return { + provider: 'gitlab', + trigger: 'feedback', + changeRequest, + items, + requestedChanges: false, + }; +} + +/** + * Build the change-request reference from a merge-request record. + * + * GitLab does not deliver a base commit in webhook payloads, so the reference carries only the + * head; runs fall back to runner-side diff discovery (see `capabilities.diffBase`). + */ +function readChangeRequest( + payload: Record, + mergeRequest: Record, +): ChangeRequestRef | null { + const project = readRecord(payload.project); + const pathWithNamespace = readString(project?.path_with_namespace); + const number = readPositiveInteger(mergeRequest.iid); + const headSha = readSha(readRecord(mergeRequest.last_commit)?.id); + if (!pathWithNamespace || number === undefined || !headSha) return null; + + const separator = pathWithNamespace.lastIndexOf('/'); + if (separator <= 0 || separator === pathWithNamespace.length - 1) return null; + return { + provider: 'gitlab', + owner: pathWithNamespace.slice(0, separator), + repo: pathWithNamespace.slice(separator + 1), + number, + headSha, + }; +} + +const stateByOutcome: Record = { + success: 'success', + failure: 'failed', + // GitLab commit statuses have no neutral or action-required state. Nothing is wrong with the + // merge request on a neutral outcome, so it maps to success; a run that needs a human maps to + // failed so the merge request cannot quietly proceed. Both are reported as degraded. + neutral: 'success', + 'action-required': 'failed', +}; + +class GitLabStatusPublisher implements StatusPublisher { + constructor( + private readonly options: StatusPublisherOptions, + private readonly baseUrl: string, + ) {} + + async publish(target: ChangeRequestRef, bundle: EvidenceBundle): Promise { + const outcome = runOutcome(bundle); + const state = stateByOutcome[outcome]; + const project = encodeURIComponent(`${target.owner}/${target.repo}`); + const secrets = secretValuesFromEnvironment(); + await sendProviderRequest({ + provider: 'gitlab', + method: 'POST', + url: `${this.baseUrl}/api/v4/projects/${project}/statuses/${target.headSha}`, + token: this.options.token, + tokenScheme: 'Bearer', + fetch: this.options.fetch, + body: { + state, + context: this.options.name ?? 'Agent Zero', + description: redactSecrets(evidenceTitle(bundle), secrets).slice(0, MAX_DESCRIPTION), + }, + }); + return { + outcome, + state, + ...(outcome === 'neutral' || outcome === 'action-required' + ? { degraded: `GitLab commit statuses have no ${outcome} state; reported ${state}` } + : {}), + }; + } +} + +export const gitlabProvider: SourceControlProvider = { + kind: 'gitlab', + capabilities, + recognizes(headers: WebhookHeaders): boolean { + return readHeader(headers, 'x-gitlab-event') !== undefined; + }, + eventName(headers: WebhookHeaders): string | undefined { + return readHeader(headers, 'x-gitlab-event'); + }, + verifyWebhook(delivery: WebhookDelivery, secret: string): boolean { + // GitLab authenticates with a shared token rather than a body signature. + return ( + secret.length > 0 && + timingSafeStringEqual(readHeader(delivery.headers, 'x-gitlab-token'), secret) + ); + }, + parseReviewEvent: parseGitLabReviewEvent, + statusPublisher(options: StatusPublisherOptions): StatusPublisher { + if (options.token.length === 0) + throw new ProviderConfigurationError('gitlab', 'a status token is required'); + return new GitLabStatusPublisher(options, options.baseUrl ?? 'https://gitlab.com'); + }, +}; diff --git a/packages/source-control/src/registry.test.ts b/packages/source-control/src/registry.test.ts new file mode 100644 index 0000000..379d36e --- /dev/null +++ b/packages/source-control/src/registry.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; + +import { createProvider, providerForDelivery } from './registry.js'; +import { verifyHmacSha256 } from './signatures.js'; + +describe('providerForDelivery', () => { + it('routes each provider by its own headers', () => { + expect(providerForDelivery({ 'x-github-event': 'pull_request' })?.kind).toBe('github'); + expect(providerForDelivery({ 'x-gitlab-event': 'Note Hook' })?.kind).toBe('gitlab'); + expect(providerForDelivery({ 'x-event-key': 'pullrequest:created' })?.kind).toBe( + 'bitbucket-cloud', + ); + expect(providerForDelivery({ 'x-event-key': 'pr:opened' })?.kind).toBe('bitbucket-data-center'); + expect(providerForDelivery({ 'x-gitea-event': 'pull_request' })?.kind).toBe('gitea'); + }); + + it('routes Gitea and Forgejo ahead of their GitHub compatibility header', () => { + expect( + providerForDelivery({ 'x-github-event': 'pull_request', 'x-gitea-event': 'pull_request' }) + ?.kind, + ).toBe('gitea'); + expect( + providerForDelivery({ 'x-github-event': 'pull_request', 'x-forgejo-event': 'pull_request' }) + ?.kind, + ).toBe('gitea'); + }); + + it('reads headers case-insensitively, as proxies rewrite casing', () => { + expect(providerForDelivery({ 'X-GitHub-Event': 'pull_request' })?.kind).toBe('github'); + }); + + it('only routes to providers the deployment configured', () => { + const headers = { 'x-github-event': 'pull_request' }; + expect(providerForDelivery(headers, ['gitlab'])).toBeUndefined(); + expect(providerForDelivery(headers, ['gitlab', 'github'])?.kind).toBe('github'); + }); + + it('returns nothing for an anonymous delivery', () => { + expect(providerForDelivery({})).toBeUndefined(); + }); +}); + +describe('createProvider', () => { + it('returns a stateless adapter per kind', () => { + expect(createProvider('github').kind).toBe('github'); + expect(createProvider('gitea')).toBe(createProvider('gitea')); + }); +}); + +describe('verifyHmacSha256', () => { + it('rejects an empty secret so a missing configuration cannot verify anything', () => { + expect(verifyHmacSha256('body', 'signature', '')).toBe(false); + }); +}); diff --git a/packages/source-control/src/registry.ts b/packages/source-control/src/registry.ts new file mode 100644 index 0000000..358a722 --- /dev/null +++ b/packages/source-control/src/registry.ts @@ -0,0 +1,51 @@ +import type { ProviderKind, SourceControlProvider, WebhookHeaders } from './contracts.js'; +import { bitbucketCloudProvider } from './providers/bitbucket-cloud.js'; +import { bitbucketDataCenterProvider } from './providers/bitbucket-data-center.js'; +import { giteaProvider } from './providers/gitea.js'; +import { githubProvider } from './providers/github.js'; +import { gitlabProvider } from './providers/gitlab.js'; + +/** + * Every provider adapter, in recognition order. + * + * Gitea and Forgejo send GitHub compatibility headers, so their adapter must be consulted before + * GitHub's; the GitHub adapter also declines deliveries that carry a Gitea or Forgejo header. + */ +const providers: readonly SourceControlProvider[] = [ + giteaProvider, + gitlabProvider, + bitbucketCloudProvider, + bitbucketDataCenterProvider, + githubProvider, +]; + +const byKind = new Map( + providers.map((provider) => [provider.kind, provider]), +); + +/** The adapter for one provider. Adapters are stateless; credentials live in publisher options. */ +export function createProvider(kind: ProviderKind): SourceControlProvider { + const provider = byKind.get(kind); + if (!provider) throw new Error(`Unknown source-control provider: ${kind}`); + return provider; +} + +export function allProviders(): readonly SourceControlProvider[] { + return providers; +} + +/** + * Route an inbound delivery to the adapter that recognizes its headers. + * + * `kinds` restricts routing to the providers a deployment actually configured, so an + * unconfigured provider's deliveries are rejected instead of half-processed. + */ +export function providerForDelivery( + headers: WebhookHeaders, + kinds?: readonly ProviderKind[], +): SourceControlProvider | undefined { + return providers.find( + (provider) => + (kinds === undefined || kinds.includes(provider.kind)) && provider.recognizes(headers), + ); +} diff --git a/packages/source-control/src/signatures.ts b/packages/source-control/src/signatures.ts new file mode 100644 index 0000000..fa8f29b --- /dev/null +++ b/packages/source-control/src/signatures.ts @@ -0,0 +1,32 @@ +import { createHmac, timingSafeEqual } from 'node:crypto'; + +/** + * Compare two strings in constant time. + * + * The comparison length is checked first because `timingSafeEqual` throws on a length mismatch, + * and a thrown error would be a slower path than a rejection. + */ +export function timingSafeStringEqual(actual: string | undefined, expected: string): boolean { + if (actual === undefined || expected.length === 0) return false; + return ( + actual.length === expected.length && timingSafeEqual(Buffer.from(actual), Buffer.from(expected)) + ); +} + +/** + * Verify an HMAC-SHA256 hex signature over the raw request body in constant time. + * + * `prefix` covers the `sha256=` convention GitHub and Bitbucket use; Gitea and Forgejo send the + * bare hex digest. + */ +export function verifyHmacSha256( + body: string, + signature: string | undefined, + secret: string, + prefix = '', +): boolean { + if (signature === undefined || secret.length === 0) return false; + if (prefix.length > 0 && !signature.startsWith(prefix)) return false; + const expected = `${prefix}${createHmac('sha256', secret).update(body).digest('hex')}`; + return timingSafeStringEqual(signature, expected); +} diff --git a/packages/source-control/src/status.ts b/packages/source-control/src/status.ts new file mode 100644 index 0000000..f9f5826 --- /dev/null +++ b/packages/source-control/src/status.ts @@ -0,0 +1,46 @@ +import { redactSecrets, secretValuesFromEnvironment } from '@agent-zero/shared'; + +import type { ProviderKind } from './contracts.js'; + +export interface ProviderRequestOptions { + provider: ProviderKind; + method: 'POST' | 'PATCH'; + url: string; + token: string; + /** The Authorization scheme the provider expects. */ + tokenScheme: 'Bearer' | 'token'; + headers?: Record; + body: Record; + fetch?: typeof globalThis.fetch | undefined; +} + +/** + * Send one JSON request to a provider API. + * + * The token is only ever sent as an Authorization header, and any error body is redacted before + * it is raised, so a failed publish cannot leak a credential into logs. + */ +export async function sendProviderRequest(options: ProviderRequestOptions): Promise { + const request = options.fetch ?? globalThis.fetch; + const response = await request(options.url, { + method: options.method, + headers: { + accept: 'application/json', + authorization: `${options.tokenScheme} ${options.token}`, + 'content-type': 'application/json', + ...options.headers, + }, + body: JSON.stringify(options.body), + }); + if (!response.ok) { + const detail = redactSecrets(await response.text(), [ + options.token, + ...secretValuesFromEnvironment(), + ]); + throw new Error( + `${options.provider} status request failed (${String(response.status)}): ${detail.slice(0, 1_000)}`, + ); + } + // Some providers answer 204 or a non-JSON body; the callers only need success. + return response.json().catch(() => undefined); +} diff --git a/packages/source-control/src/untrusted.ts b/packages/source-control/src/untrusted.ts new file mode 100644 index 0000000..c71d7fe --- /dev/null +++ b/packages/source-control/src/untrusted.ts @@ -0,0 +1,74 @@ +import type { ParseOptions } from './contracts.js'; + +/** Untrusted comment bodies are bounded before they reach a prompt or an evidence report. */ +export const MAX_BODY = 8_000; +export const COMMIT_SHA = /^[0-9a-f]{7,64}$/i; + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +export function readRecord(value: unknown): Record | undefined { + return isRecord(value) ? value : undefined; +} + +export function readString(value: unknown): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined; +} + +/** A positive integer, as required for change-request numbers and line numbers. */ +export function readPositiveInteger(value: unknown): number | undefined { + return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : undefined; +} + +export function readSha(value: unknown): string | undefined { + return typeof value === 'string' && COMMIT_SHA.test(value) ? value : undefined; +} + +export function readBody(body: unknown): string | null { + if (typeof body !== 'string') return null; + const trimmed = body.trim(); + if (trimmed.length === 0) return null; + return trimmed.slice(0, MAX_BODY); +} + +export function readLine(value: unknown): number | undefined { + return readPositiveInteger(value); +} + +export function readId(value: unknown, prefix: string): string { + if (typeof value === 'number' || typeof value === 'string') return `${prefix}:${String(value)}`; + return prefix; +} + +/** + * Accept or reject a feedback author. + * + * `isBot` is only meaningful on providers whose payloads mark bot accounts; adapters on other + * providers pass `false`, so `allowBots: false` cannot filter what the payload does not reveal. + */ +export function acceptAuthor( + login: string | undefined, + isBot: boolean, + options: ParseOptions, +): string | null { + if (!login) return null; + const ignored = options.ignoreAuthors ?? []; + if (ignored.some((ignore) => ignore.toLowerCase() === login.toLowerCase())) return null; + if (options.allowBots === false && isBot) return null; + return login; +} + +/** Case-insensitive header lookup; webhook hosts disagree on header-name casing. */ +export function readHeader( + headers: Readonly>, + name: string, +): string | undefined { + const direct = headers[name]; + if (direct !== undefined) return direct; + const lower = name.toLowerCase(); + for (const [key, value] of Object.entries(headers)) { + if (key.toLowerCase() === lower) return value; + } + return undefined; +} diff --git a/packages/github/tsconfig.json b/packages/source-control/tsconfig.json similarity index 100% rename from packages/github/tsconfig.json rename to packages/source-control/tsconfig.json diff --git a/packages/github/tsdown.config.ts b/packages/source-control/tsdown.config.ts similarity index 100% rename from packages/github/tsdown.config.ts rename to packages/source-control/tsdown.config.ts diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c28bbe9..d9e7658 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -284,9 +284,6 @@ importers: '@agent-zero/config': specifier: workspace:* version: 0.3.0 - '@agent-zero/github': - specifier: workspace:* - version: 0.3.0 '@agent-zero/models': specifier: workspace:* version: 0.3.0 @@ -296,6 +293,9 @@ importers: '@agent-zero/shared': specifier: workspace:* version: 0.3.0 + '@agent-zero/source-control': + specifier: workspace:* + version: 0.3.0 '@orpc/server': specifier: 2.0.0-beta.26 version: 2.0.0-beta.26(crossws@0.4.10) @@ -611,7 +611,7 @@ importers: specifier: ^3.2.4 version: 3.2.7(@types/debug@4.1.13)(@types/node@24.13.3)(happy-dom@20.11.2) - packages/github: + packages/source-control: dependencies: '@agent-zero/shared': specifier: workspace:* diff --git a/tsconfig.json b/tsconfig.json index 6b8b85c..3124f5e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -5,7 +5,7 @@ { "path": "packages/config" }, { "path": "packages/models" }, { "path": "packages/runner" }, - { "path": "packages/github" }, + { "path": "packages/source-control" }, { "path": "packages/agent" }, { "path": "packages/cli" }, { "path": "packages/auth" },