Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
11 changes: 10 additions & 1 deletion src/runners/source-adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,15 @@ export interface SourceAdapter {
linkPr(issueRef: string, task: Task, prUrl: string): Promise<void>;
}

/**
* White-label mode: a commit author is supplied, so the run must leave no Vanguard trace in the
* client repo (no branded comments, no secret-blocked label, plain PR body). One definition for
* runSourcedIssue and the watch primitives that must agree with it.
*/
export function isWhiteLabel(deps: Pick<RunIssueDeps, 'commitAuthor'>): boolean {
return deps.commitAuthor !== undefined;
}

/** Shared result shape for all source-backed issue runners. */
export interface RunIssueResult {
task: Task;
Expand Down Expand Up @@ -339,7 +348,7 @@ export async function runSourcedIssue(
// White-label mode (triggered by --commit-author): the PR is delivered as a plain, human-looking PR.
// Branch becomes `feat/<issue-number>-<hash>`, the "by Vanguard" attribution and the Vanguard review
// comment / issue link-back are suppressed. Off by default — everything stays branded as Vanguard.
const whiteLabel = deps.commitAuthor !== undefined;
const whiteLabel = isWhiteLabel(deps);

const agents = selectAgents(deps, process.env, { proxyMode: deps.llmProxy !== undefined });

Expand Down
Loading
Loading