Skip to content
Merged
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ export {
githubProjectWatchPrimitives,
type WatchPrimitives,
type WatchTick,
type WatchRunOutcome,
type WatchLinearOptions,
type WatchGithubOptions,
type WatchGithubProjectOptions,
Expand Down
60 changes: 60 additions & 0 deletions src/runners/github.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { describe, it, expect } from 'vitest';
import { githubAdapter } from './github.js';
import { GITHUB_SECRET_BLOCKED_LABEL } from '../github-labels.js';
import type { RunGithubIssueDeps } from './github.js';
import type { GhRunner } from '../tasks/github.js';
import type { Task } from '../tasks/fetcher.js';

function makeDeps(): RunGithubIssueDeps {
return { repoPath: '/repo', repoSlug: 'owner/repo' };
}

function makeGh(failOn?: (args: string[]) => boolean): { gh: GhRunner; calls: string[][] } {
const calls: string[][] = [];
const gh: GhRunner = async (args) => {
calls.push(args);
if (failOn?.(args) === true) throw new Error('gh: failed');
return '';
};
return { gh, calls };
}

const task: Task = { id: 'owner/repo#7', title: 't', description: '', labels: [], children: [], comments: [] };

describe('githubAdapter', () => {
it('signalSecretBlock creates the secret-blocked label before adding it, and posts the masked comment', async () => {
const { gh, calls } = makeGh();
const adapter = githubAdapter(makeDeps(), gh);

await adapter.signalSecretBlock('owner/repo#7', task, {
reason: 'findings',
findings: [{ file: '.env', patternName: 'generic-api-key', masked: 'KEY=ab****' }],
});

const create = calls.findIndex((c) => c[0] === 'label' && c[1] === 'create' && c[2] === GITHUB_SECRET_BLOCKED_LABEL && c.includes('--force'));
const add = calls.findIndex((c) => c[0] === 'issue' && c[1] === 'edit' && c[2] === '7' && c.includes('--add-label') && c.includes(GITHUB_SECRET_BLOCKED_LABEL));
expect(create).toBeGreaterThan(-1);
expect(add).toBeGreaterThan(create); // `gh issue edit --add-label` fails on a repo without the label
const comment = calls.find((c) => c[0] === 'issue' && c[1] === 'comment' && c[2] === '7');
expect(comment?.at(-1)).toContain('blocked publish');
expect(comment?.at(-1)).toContain('KEY=ab****');
expect(comment?.at(-1)).not.toContain('trigger label'); // the release step is per watch source, posted by the primitive
});

it('signalSecretBlock never throws: a failed label create still attempts the add and the comment', async () => {
const { gh, calls } = makeGh((args) => args[0] === 'label');
const adapter = githubAdapter(makeDeps(), gh);

await expect(adapter.signalSecretBlock('owner/repo#7', task, { reason: 'scan-error', message: 'gitleaks missing' })).resolves.toBeUndefined();

expect(calls.some((c) => c[0] === 'issue' && c[1] === 'edit' && c.includes(GITHUB_SECRET_BLOCKED_LABEL))).toBe(true);
expect(calls.some((c) => c[0] === 'issue' && c[1] === 'comment')).toBe(true);
});

it('signalSecretBlock never throws when every gh call fails', async () => {
const { gh } = makeGh(() => true);
const adapter = githubAdapter(makeDeps(), gh);

await expect(adapter.signalSecretBlock('owner/repo#7', task, { reason: 'scan-error', message: 'x' })).resolves.toBeUndefined();
});
});
32 changes: 20 additions & 12 deletions src/runners/github.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { execa } from 'execa';
import { GitHubTaskFetcher, linkPullRequest, addPrFailureLabel, editGithubLabels, commentGithubIssue } from '../tasks/github.js';
import { GitHubTaskFetcher, linkPullRequest, addPrFailureLabel, editGithubLabels, commentGithubIssue, defaultGhRunner } from '../tasks/github.js';
import { GitHubProjectFetcher } from '../tasks/github-project.js';
import { implementReviewSimplifyStages } from '../pipeline/pipeline.js';
import { publishReviewVerdict } from '../pipeline/review-publish.js';
Expand All @@ -9,23 +9,25 @@ import { runSourcedIssue } from './source-adapter.js';
import { renderSecretBlockComment } from '../core/secret-scan.js';
import { GITHUB_VERIFY_FAILED_LABEL, GITHUB_VISUAL_PROOF_FAILED_LABEL, GITHUB_SECRET_BLOCKED_LABEL } from '../github-labels.js';
import type { Task } from '../tasks/fetcher.js';
import type { GhRunner } from '../tasks/github.js';
import type { ProviderChoice } from '../agents/registry.js';
import type { FanOutOutcome } from '../pipeline/fan-out.js';
import type { SecretBlock } from '../core/secret-scan.js';
import type { RunIssueDeps, SourceAdapter, ProofFailureKind } from './source-adapter.js';
import type { RunIssueDeps, RunIssueResult, SourceAdapter, ProofFailureKind } from './source-adapter.js';

/** Everything needed to run a single GitHub issue end to end. */
export interface RunGithubIssueDeps extends RunIssueDeps {
repoSlug: string;
}

export interface RunGithubIssueResult {
task: Task;
/** Absent when the agent produced no changes (no PR opened). */
prUrl?: string;
}
/** The shared run result, unnarrowed: `secretBlocked` must reach the watch loop so a withheld PR is not read as "no changes". */
export type RunGithubIssueResult = RunIssueResult;

function githubAdapter(deps: RunGithubIssueDeps): SourceAdapter {
/**
* @internal Exported for unit tests; production callers use runGithubIssue. Only `signalSecretBlock`
* goes through the injected `gh`; `prepare`, `linkPr` and `addFailureLabel` still reach the real CLI.
*/
export function githubAdapter(deps: RunGithubIssueDeps, gh: GhRunner = defaultGhRunner): SourceAdapter {
return {
async prepare(issueRef: string) {
const task = await new GitHubTaskFetcher(deps.repoSlug).fetch(issueRef);
Expand All @@ -43,10 +45,16 @@ function githubAdapter(deps: RunGithubIssueDeps): SourceAdapter {
await linkPullRequest(deps.repoSlug, issueRef, prUrl);
},
async signalSecretBlock(issueRef: string, _task: Task, block: SecretBlock) {
await Promise.all([
editGithubLabels(deps.repoSlug, issueRef, { add: [GITHUB_SECRET_BLOCKED_LABEL] }).catch(() => undefined),
commentGithubIssue(deps.repoSlug, issueRef, renderSecretBlockComment(block)).catch(() => undefined),
]);
// `gh issue edit --add-label` fails on a repo that never created the label, which would leave
// the held issue with no Vanguard label at all. Ensure it first (the same pre-step as
// addPrFailureLabel and the revise path); every step stays best-effort.
const label = gh(['label', 'create', GITHUB_SECRET_BLOCKED_LABEL, '--repo', deps.repoSlug, '--force'])
.catch(() => undefined)
.then(() => editGithubLabels(deps.repoSlug, issueRef, { add: [GITHUB_SECRET_BLOCKED_LABEL] }, gh))
.catch(() => undefined);
// No recovery procedure here: this adapter also serves one-shot `vanguard run` and Projects,
// whose release steps differ. The watch primitive that holds the issue posts the resume line.
await Promise.all([label, commentGithubIssue(deps.repoSlug, issueRef, renderSecretBlockComment(block), gh).catch(() => undefined)]);
},
};
}
Expand Down
36 changes: 36 additions & 0 deletions src/runners/gitlab.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,42 @@ describe('gitlabAdapter', () => {
expect(adapter.closeIssueOnMerge).toBe(true);
});

it('signalSecretBlock creates the secret-blocked label before adding it, and posts the masked note', async () => {
const { calls, glab } = makeGlab();
const adapter = gitlabAdapter(makeDeps(), glab);
const task = { id: 'group/project#7', title: 't', description: '', labels: [], children: [], comments: [] };

await adapter.signalSecretBlock('group/project#7', task, {
reason: 'findings',
findings: [{ file: '.env', patternName: 'generic-api-key', masked: 'KEY=ab****' }],
});

const create = calls.findIndex((c) => c[0] === 'label' && c[1] === 'create' && c.includes('vanguard::secret-blocked'));
const add = calls.findIndex((c) => c[0] === 'issue' && c[1] === 'update' && c.includes('vanguard::secret-blocked'));
expect(create).toBeGreaterThan(-1);
expect(add).toBeGreaterThan(create); // the add would be a silent no-op without the label
const note = calls.find((c) => c[0] === 'issue' && c[1] === 'note');
expect(note?.at(-1)).toContain('blocked publish');
expect(note?.at(-1)).toContain('KEY=ab****');
expect(note?.at(-1)).not.toContain('trigger label'); // the release step is per watch source, posted by the primitive
});

it('signalSecretBlock still adds the label and notes when label creation fails', async () => {
const calls: string[][] = [];
const glab: GlabRunner = async (args) => {
calls.push(args);
if (args[0] === 'label') throw new Error('403');
return '{}';
};
const adapter = gitlabAdapter(makeDeps(), glab);
const task = { id: 'group/project#7', title: 't', description: '', labels: [], children: [], comments: [] };

await expect(adapter.signalSecretBlock('group/project#7', task, { reason: 'scan-error', message: 'gitleaks missing' })).resolves.toBeUndefined();

expect(calls.some((c) => c[0] === 'issue' && c[1] === 'update' && c.includes('vanguard::secret-blocked'))).toBe(true);
expect(calls.some((c) => c[0] === 'issue' && c[1] === 'note')).toBe(true);
});

it('publishVerdict throws when reviewerOutcome is missing', async () => {
const adapter = gitlabAdapter(makeDeps());
await expect(
Expand Down
23 changes: 13 additions & 10 deletions src/runners/gitlab.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { execa } from 'execa';
import { agentAuthFromEnv } from '../agents/auth.js';
import { GitLabTaskFetcher, linkMergeRequest, addMrFailureLabel, editGitlabLabels, commentGitlabIssue } from '../tasks/gitlab.js';
import { GitLabTaskFetcher, linkMergeRequest, addMrFailureLabel, editGitlabLabels, commentGitlabIssue, defaultGlabRunner } from '../tasks/gitlab.js';
import { implementReviewSimplifyStages } from '../pipeline/pipeline.js';
import { parseMergeRequestRef, postMergeRequestNote, mergeRequestReviewMarker } from './mr-review.js';
import { REVIEW_INCOMPLETE, stripReviewMarkers } from './review-prompt.js';
Expand All @@ -13,18 +13,16 @@ import type { Task } from '../tasks/fetcher.js';
import type { ProviderChoice } from '../agents/registry.js';
import type { GlabRunner } from '../tasks/gitlab.js';
import type { SecretBlock } from '../core/secret-scan.js';
import type { RunIssueDeps, SourceAdapter, PublishVerdictInput, ProofFailureKind } from './source-adapter.js';
import type { RunIssueDeps, RunIssueResult, SourceAdapter, PublishVerdictInput, ProofFailureKind } from './source-adapter.js';

/** Everything needed to run a single GitLab issue end to end. */
export interface RunGitlabIssueDeps extends RunIssueDeps {
/** GitLab project path, e.g. `group/project`. */
project: string;
}

export interface RunGitlabIssueResult {
task: Task;
prUrl?: string;
}
/** The shared run result, unnarrowed: `secretBlocked` must reach the watch loop so a withheld MR is not read as "no changes". */
export type RunGitlabIssueResult = RunIssueResult;

/** @internal Exported for unit tests; production callers use runGitlabIssue. */
export function gitlabAdapter(deps: RunGitlabIssueDeps, glab?: GlabRunner): SourceAdapter {
Expand All @@ -43,10 +41,15 @@ export function gitlabAdapter(deps: RunGitlabIssueDeps, glab?: GlabRunner): Sour
await linkMergeRequest(deps.project, issueRef, mrUrl, glab);
},
async signalSecretBlock(issueRef: string, _task: Task, block: SecretBlock) {
await Promise.all([
editGitlabLabels(deps.project, issueRef, { add: [GITLAB_SECRET_BLOCKED_LABEL] }, glab).catch(() => undefined),
commentGitlabIssue(deps.project, issueRef, renderSecretBlockComment(block), glab).catch(() => undefined),
]);
// `glab issue update --label` is a silent no-op on a project lacking the label (see
// addMrFailureLabel), which would leave the held issue with no Vanguard label at all. Ensure
// it first; every step stays best-effort.
const label = (glab ?? defaultGlabRunner)(['label', 'create', '--repo', deps.project, '--name', GITLAB_SECRET_BLOCKED_LABEL])
.catch(() => undefined)
.then(() => editGitlabLabels(deps.project, issueRef, { add: [GITLAB_SECRET_BLOCKED_LABEL] }, glab))
.catch(() => undefined);
// No recovery procedure here (the watch primitive that holds the issue posts the resume line).
await Promise.all([label, commentGitlabIssue(deps.project, issueRef, renderSecretBlockComment(block), glab).catch(() => undefined)]);
},
};
}
Expand Down
12 changes: 5 additions & 7 deletions src/runners/linear.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,16 @@ import type { CustomProviderEntry } from '../agents/registry.js';
import type { FanOutOutcome } from '../pipeline/fan-out.js';
import type { SecretBlock } from '../core/secret-scan.js';
import type { GlabRunner } from '../tasks/gitlab.js';
import type { RunIssueDeps, SourceAdapter, PublishVerdictInput, ProofFailureKind } from './source-adapter.js';
import type { RunIssueDeps, RunIssueResult, SourceAdapter, PublishVerdictInput, ProofFailureKind } from './source-adapter.js';

/** Everything needed to run a single Linear issue end to end. */
export interface RunLinearIssueDeps extends RunIssueDeps {
linearKey: string;
skillsDir: string;
}

export interface RunLinearIssueResult {
task: Task;
/** Absent when the agent produced no changes (no PR opened). */
prUrl?: string;
}
/** The shared run result, unnarrowed: `secretBlocked` must reach the watch loop so a withheld PR is not read as "no changes". */
export type RunLinearIssueResult = RunIssueResult;

/**
* The canonical implement/review/simplify pipeline with only the implementer's prompt swapped, so
Expand Down Expand Up @@ -85,7 +82,8 @@ export function linearAdapter(deps: RunLinearIssueDeps, gitlabProject?: string,
await linkLinearIssue(task.id, prUrl);
},
async signalSecretBlock(_issueRef: string, task: Task, block: SecretBlock) {
// Linear uses workflow states, not labels; the comment is the primary signal.
// Linear uses workflow states, not labels; the comment is the primary signal. The watch
// primitive that holds the issue posts how to release it (it knows the configured states).
try {
await commentLinearIssue(task.id, renderSecretBlockComment(block));
} catch { /* best-effort */ }
Expand Down
Loading
Loading