argus-reviewer-e2e 0.4.1 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -5
- package/action/action.yml +3 -3
- package/action/runtime.mjs +2 -2
- package/dist/cli.d.ts +18 -0
- package/dist/cli.js +259 -237
- package/dist/config.d.ts +60 -2
- package/dist/config.js +85 -2
- package/dist/detect.js +2 -2
- package/dist/engine/loop.d.ts +8 -1
- package/dist/onboarding/pr-content.d.ts +12 -0
- package/dist/onboarding/pr-content.js +56 -0
- package/dist/onboarding/pr.d.ts +23 -0
- package/dist/onboarding/pr.js +196 -0
- package/dist/onboarding/scaffold.d.ts +37 -0
- package/dist/onboarding/scaffold.js +174 -0
- package/dist/pipeline/budget.d.ts +13 -0
- package/dist/pipeline/budget.js +34 -0
- package/dist/review/chunks.d.ts +21 -0
- package/dist/review/chunks.js +96 -0
- package/dist/vision/openrouter.d.ts +37 -1
- package/dist/vision/openrouter.js +111 -5
- package/package.json +2 -1
package/dist/config.d.ts
CHANGED
|
@@ -114,11 +114,17 @@ export interface Config {
|
|
|
114
114
|
*/
|
|
115
115
|
decisionModel: string | undefined;
|
|
116
116
|
/**
|
|
117
|
-
* Hard budget for the `argus-reviewer code-review` lane.
|
|
118
|
-
*
|
|
117
|
+
* Hard budget for the `argus-reviewer code-review` lane. Unset follows
|
|
118
|
+
* `budgetUsd` (default $1/run); `0` runs the review lane uncapped. After
|
|
119
|
+
* resolveConfig `undefined` means explicitly unlimited.
|
|
119
120
|
*/
|
|
120
121
|
codeReviewBudgetUsd: number | undefined;
|
|
121
122
|
provider: ProviderRules;
|
|
123
|
+
/**
|
|
124
|
+
* Per-run spend cap in USD. Default `DEFAULT_BUDGET_USD` ($1). `0` is the
|
|
125
|
+
* explicit unlimited switch (every command logs a warning). After
|
|
126
|
+
* resolveConfig `undefined` means explicitly unlimited — never "unset".
|
|
127
|
+
*/
|
|
122
128
|
budgetUsd: number | undefined;
|
|
123
129
|
target: Target | undefined;
|
|
124
130
|
cacheDir: string | undefined;
|
|
@@ -271,8 +277,39 @@ export interface Config {
|
|
|
271
277
|
* paths); `[]` excludes nothing.
|
|
272
278
|
*/
|
|
273
279
|
exclude: string[];
|
|
280
|
+
/**
|
|
281
|
+
* `realtime` (default) calls the chat API per chunk. `batch` submits all
|
|
282
|
+
* chunks through OpenRouter's async Batch API (cheaper, slower: minutes),
|
|
283
|
+
* falling back to realtime on failure or timeout.
|
|
284
|
+
*/
|
|
285
|
+
mode: 'realtime' | 'batch';
|
|
286
|
+
/**
|
|
287
|
+
* Poll deadline for a batch, ms. Must sit inside the CI job timeout
|
|
288
|
+
* (the shipped workflow's is 15 minutes) with room left for a realtime
|
|
289
|
+
* fallback. Default 480000.
|
|
290
|
+
*/
|
|
291
|
+
batchTimeoutMs: number;
|
|
292
|
+
/**
|
|
293
|
+
* Model for `mode: 'batch'`, a `:batch` slug (the base slug is what is
|
|
294
|
+
* sent). Separate from `code_model` because not every realtime model has
|
|
295
|
+
* a batch endpoint. Unset: `<review model>:batch` when that slug is known
|
|
296
|
+
* to exist, else DEFAULT_BATCH_MODEL.
|
|
297
|
+
*/
|
|
298
|
+
batchModel: string | undefined;
|
|
299
|
+
/**
|
|
300
|
+
* Per-request timeout for realtime review calls, ms (1..900000,
|
|
301
|
+
* default 120000). Reasoning models need more than the default.
|
|
302
|
+
*/
|
|
303
|
+
requestTimeoutMs: number;
|
|
274
304
|
};
|
|
275
305
|
}
|
|
306
|
+
export declare const DEFAULT_REQUEST_TIMEOUT_MS = 120000;
|
|
307
|
+
export declare const MAX_REQUEST_TIMEOUT_MS = 900000;
|
|
308
|
+
export declare const DEFAULT_BATCH_MODEL = "deepseek/deepseek-v4.1-flash:batch";
|
|
309
|
+
/** Batch slug for a review: explicit `batchModel`, else `<model>:batch` if known to exist, else the default. */
|
|
310
|
+
export declare function resolveBatchModel(reviewModel: string, batchModel: string | undefined): string;
|
|
311
|
+
/** Validates a per-request timeout; returns an error message or undefined when valid. */
|
|
312
|
+
export declare function checkRequestTimeoutMs(v: unknown): string | undefined;
|
|
276
313
|
export type ConfigInput = Partial<Omit<Config, 'provider' | 'sandbox' | 'review' | 'explore' | 'app'>> & {
|
|
277
314
|
provider?: Partial<ProviderRules>;
|
|
278
315
|
sandbox?: Partial<Sandbox>;
|
|
@@ -281,6 +318,27 @@ export type ConfigInput = Partial<Omit<Config, 'provider' | 'sandbox' | 'review'
|
|
|
281
318
|
app?: Partial<AppLane>;
|
|
282
319
|
};
|
|
283
320
|
export declare const DEFAULT_RECORD_STEP_CAP = 40;
|
|
321
|
+
/** Built-in per-run spend cap (USD) when nothing else is configured. */
|
|
322
|
+
export declare const DEFAULT_BUDGET_USD = 1;
|
|
323
|
+
/** Logged by every paid command when the cap was explicitly disabled. */
|
|
324
|
+
export declare const UNCAPPED_WARNING = "warning: spend cap disabled (budgetUsd/ARGUS_BUDGET_USD = 0): this run is UNCAPPED; model spend is unbounded";
|
|
325
|
+
export type BudgetSetting = {
|
|
326
|
+
kind: 'unset';
|
|
327
|
+
} | {
|
|
328
|
+
kind: 'invalid';
|
|
329
|
+
} | {
|
|
330
|
+
kind: 'unlimited';
|
|
331
|
+
} | {
|
|
332
|
+
kind: 'cap';
|
|
333
|
+
usd: number;
|
|
334
|
+
};
|
|
335
|
+
/** Parse ARGUS_BUDGET_USD / the `budget-usd` action input: `0` = unlimited. */
|
|
336
|
+
export declare function parseBudgetSetting(raw: string | undefined): BudgetSetting;
|
|
337
|
+
/**
|
|
338
|
+
* Apply an env/action budget setting. Returns the new cap (`undefined` =
|
|
339
|
+
* unlimited) or `'keep'` when the setting is unset/invalid.
|
|
340
|
+
*/
|
|
341
|
+
export declare function applyBudgetSetting(s: BudgetSetting): number | undefined | 'keep';
|
|
284
342
|
export declare const DEFAULT_EXPLORE: Explore;
|
|
285
343
|
export declare const DEFAULT_APP: AppLane;
|
|
286
344
|
export declare const DEFAULT_SANDBOX: Sandbox;
|
package/dist/config.js
CHANGED
|
@@ -2,7 +2,58 @@ import { pathToFileURL } from 'node:url';
|
|
|
2
2
|
import { DEFAULT_REVIEW_EXCLUDE } from './review/scope.js';
|
|
3
3
|
import { isReviewProfile } from './review/packs.js';
|
|
4
4
|
import { JEV_DEFAULT_MODEL } from './vision/decisions.js';
|
|
5
|
+
export const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
|
|
6
|
+
export const MAX_REQUEST_TIMEOUT_MS = 900_000;
|
|
7
|
+
export const DEFAULT_BATCH_MODEL = 'deepseek/deepseek-v4.1-flash:batch';
|
|
8
|
+
/**
|
|
9
|
+
* Base slugs measured to have an OpenRouter `:batch` endpoint (reviewer
|
|
10
|
+
* bake-off, 2026-10-03). The realtime default deepseek-v4-flash has none.
|
|
11
|
+
*/
|
|
12
|
+
const KNOWN_BATCH_BASES = new Set([
|
|
13
|
+
'google/gemini-2.5-flash-lite',
|
|
14
|
+
'deepseek/deepseek-v4.1-flash',
|
|
15
|
+
'z-ai/glm-5.3',
|
|
16
|
+
'z-ai/glm-5.3-flash',
|
|
17
|
+
'openai/gpt-oss-120b',
|
|
18
|
+
]);
|
|
19
|
+
/** Batch slug for a review: explicit `batchModel`, else `<model>:batch` if known to exist, else the default. */
|
|
20
|
+
export function resolveBatchModel(reviewModel, batchModel) {
|
|
21
|
+
if (batchModel !== undefined && batchModel !== '')
|
|
22
|
+
return batchModel;
|
|
23
|
+
const base = reviewModel.replace(/:batch$/, '');
|
|
24
|
+
return KNOWN_BATCH_BASES.has(base) ? `${base}:batch` : DEFAULT_BATCH_MODEL;
|
|
25
|
+
}
|
|
26
|
+
/** Validates a per-request timeout; returns an error message or undefined when valid. */
|
|
27
|
+
export function checkRequestTimeoutMs(v) {
|
|
28
|
+
return typeof v === 'number' && Number.isInteger(v) && v >= 1 && v <= MAX_REQUEST_TIMEOUT_MS
|
|
29
|
+
? undefined
|
|
30
|
+
: `requestTimeoutMs must be an integer from 1 to ${MAX_REQUEST_TIMEOUT_MS} (ms), got ${String(v)}`;
|
|
31
|
+
}
|
|
5
32
|
export const DEFAULT_RECORD_STEP_CAP = 40;
|
|
33
|
+
/** Built-in per-run spend cap (USD) when nothing else is configured. */
|
|
34
|
+
export const DEFAULT_BUDGET_USD = 1;
|
|
35
|
+
/** Logged by every paid command when the cap was explicitly disabled. */
|
|
36
|
+
export const UNCAPPED_WARNING = 'warning: spend cap disabled (budgetUsd/ARGUS_BUDGET_USD = 0): this run is UNCAPPED; model spend is unbounded';
|
|
37
|
+
/** Parse ARGUS_BUDGET_USD / the `budget-usd` action input: `0` = unlimited. */
|
|
38
|
+
export function parseBudgetSetting(raw) {
|
|
39
|
+
if (raw === undefined || raw.trim() === '')
|
|
40
|
+
return { kind: 'unset' };
|
|
41
|
+
const n = Number(raw);
|
|
42
|
+
if (!Number.isFinite(n) || n < 0)
|
|
43
|
+
return { kind: 'invalid' };
|
|
44
|
+
return n === 0 ? { kind: 'unlimited' } : { kind: 'cap', usd: n };
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Apply an env/action budget setting. Returns the new cap (`undefined` =
|
|
48
|
+
* unlimited) or `'keep'` when the setting is unset/invalid.
|
|
49
|
+
*/
|
|
50
|
+
export function applyBudgetSetting(s) {
|
|
51
|
+
if (s.kind === 'cap')
|
|
52
|
+
return s.usd;
|
|
53
|
+
if (s.kind === 'unlimited')
|
|
54
|
+
return undefined;
|
|
55
|
+
return 'keep';
|
|
56
|
+
}
|
|
6
57
|
export const DEFAULT_EXPLORE = {
|
|
7
58
|
enabled: false,
|
|
8
59
|
maxSteps: 20,
|
|
@@ -29,13 +80,13 @@ const defaults = {
|
|
|
29
80
|
model: 'google/gemini-2.5-flash-lite',
|
|
30
81
|
escalation_model: 'moonshotai/kimi-k2.5',
|
|
31
82
|
grounding_model: undefined,
|
|
32
|
-
code_model: 'deepseek/deepseek-v4
|
|
83
|
+
code_model: 'deepseek/deepseek-v4-flash',
|
|
33
84
|
decisionModel: JEV_DEFAULT_MODEL,
|
|
34
85
|
codeReviewBudgetUsd: undefined,
|
|
35
86
|
provider: {
|
|
36
87
|
ignore: ['siliconflow', 'novitaai', 'atlascloud', 'streamlake', 'chutes'],
|
|
37
88
|
},
|
|
38
|
-
budgetUsd:
|
|
89
|
+
budgetUsd: DEFAULT_BUDGET_USD,
|
|
39
90
|
target: undefined,
|
|
40
91
|
cacheDir: undefined,
|
|
41
92
|
testsDir: undefined,
|
|
@@ -67,6 +118,10 @@ const defaults = {
|
|
|
67
118
|
requestChanges: true,
|
|
68
119
|
profiles: [],
|
|
69
120
|
exclude: [...DEFAULT_REVIEW_EXCLUDE],
|
|
121
|
+
mode: 'realtime',
|
|
122
|
+
batchTimeoutMs: 480_000,
|
|
123
|
+
batchModel: undefined,
|
|
124
|
+
requestTimeoutMs: DEFAULT_REQUEST_TIMEOUT_MS,
|
|
70
125
|
},
|
|
71
126
|
};
|
|
72
127
|
export function defineConfig(input) {
|
|
@@ -207,7 +262,35 @@ export function resolveConfig(input = {}) {
|
|
|
207
262
|
rawReview.exclude.every((g) => typeof g === 'string' && g !== '')
|
|
208
263
|
? [...rawReview.exclude]
|
|
209
264
|
: [...DEFAULT_REVIEW_EXCLUDE];
|
|
265
|
+
review.mode = review.mode === 'batch' ? 'batch' : 'realtime';
|
|
266
|
+
review.batchTimeoutMs = posInt(review.batchTimeoutMs, defaults.review.batchTimeoutMs);
|
|
267
|
+
if (typeof review.batchModel !== 'string' || review.batchModel.trim() === '') {
|
|
268
|
+
review.batchModel = undefined;
|
|
269
|
+
}
|
|
270
|
+
if (rawReview.requestTimeoutMs !== undefined) {
|
|
271
|
+
const bad = checkRequestTimeoutMs(rawReview.requestTimeoutMs);
|
|
272
|
+
if (bad !== undefined)
|
|
273
|
+
throw new Error(`review.${bad}`);
|
|
274
|
+
}
|
|
210
275
|
const resolved = { ...defaults, ...input, provider, sandbox, explore, app, review };
|
|
276
|
+
// 0 = explicit unlimited; anything not a finite non-negative number
|
|
277
|
+
// (mis-typed, negative, null) degrades to the default cap, never to unlimited.
|
|
278
|
+
const rawBudget = input.budgetUsd;
|
|
279
|
+
resolved.budgetUsd =
|
|
280
|
+
rawBudget === undefined
|
|
281
|
+
? DEFAULT_BUDGET_USD
|
|
282
|
+
: typeof rawBudget === 'number' && Number.isFinite(rawBudget) && rawBudget >= 0
|
|
283
|
+
? rawBudget === 0
|
|
284
|
+
? undefined
|
|
285
|
+
: rawBudget
|
|
286
|
+
: DEFAULT_BUDGET_USD;
|
|
287
|
+
const rawReviewBudget = input.codeReviewBudgetUsd;
|
|
288
|
+
resolved.codeReviewBudgetUsd =
|
|
289
|
+
typeof rawReviewBudget === 'number' && Number.isFinite(rawReviewBudget) && rawReviewBudget > 0
|
|
290
|
+
? rawReviewBudget
|
|
291
|
+
: rawReviewBudget === 0
|
|
292
|
+
? undefined
|
|
293
|
+
: resolved.budgetUsd;
|
|
211
294
|
resolved.recordStepCap = posInt(resolved.recordStepCap, DEFAULT_RECORD_STEP_CAP);
|
|
212
295
|
// Retention is a non-negative integer (0 = keep none) — a mis-typed or
|
|
213
296
|
// negative bound degrades to unset, never to "keep everything".
|
package/dist/detect.js
CHANGED
|
@@ -160,7 +160,7 @@ async function a0EnvFileHost(home) {
|
|
|
160
160
|
* the default local port (http://localhost:5080).
|
|
161
161
|
*/
|
|
162
162
|
export async function resolveA0Host(env, opts = {}) {
|
|
163
|
-
const home = opts.home ?? homedir();
|
|
163
|
+
const home = opts.home ?? (env.HOME || homedir());
|
|
164
164
|
const probe = opts.probe ?? defaultProbe;
|
|
165
165
|
const fromEnv = env.AGENT_ZERO_HOST;
|
|
166
166
|
if (fromEnv !== undefined && fromEnv !== '')
|
|
@@ -192,7 +192,7 @@ async function playwrightBrowsers(home) {
|
|
|
192
192
|
}
|
|
193
193
|
export async function detectEnvironment(env, opts = {}) {
|
|
194
194
|
const exec = opts.exec ?? defaultExec;
|
|
195
|
-
const home = opts.home ?? homedir();
|
|
195
|
+
const home = opts.home ?? (env.HOME || homedir());
|
|
196
196
|
const [a0Version, gh, browsers, a0Host] = await Promise.all([
|
|
197
197
|
// Even the presence probe gets the allowlisted env — an `a0` binary is
|
|
198
198
|
// third-party code and never sees provider/git secrets.
|
package/dist/engine/loop.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { Actions } from './actions.js';
|
|
|
3
3
|
import { Config, ProviderRules } from '../config.js';
|
|
4
4
|
import { CallCost, CallKind } from '../vision/cost.js';
|
|
5
5
|
import { Ledger } from '../vision/ledger.js';
|
|
6
|
-
import { JsonSchema, Message } from '../vision/openrouter.js';
|
|
6
|
+
import { BatchItemResult, BatchRequest, JsonSchema, Message } from '../vision/openrouter.js';
|
|
7
7
|
import { FingerprintRecord, Point } from '../cache/fingerprint.js';
|
|
8
8
|
import { CachedAssert, FlowCache } from '../cache/store.js';
|
|
9
9
|
import { ErrorRecord } from '../journal/schema.js';
|
|
@@ -23,6 +23,13 @@ export interface VisionClient {
|
|
|
23
23
|
cost: CallCost;
|
|
24
24
|
model: string;
|
|
25
25
|
}>;
|
|
26
|
+
/** Optional async Batch API path (code review `review.mode: batch`). */
|
|
27
|
+
completeBatch?(opts: {
|
|
28
|
+
model: string;
|
|
29
|
+
requests: BatchRequest[];
|
|
30
|
+
kind?: CallKind;
|
|
31
|
+
deadlineMs: number;
|
|
32
|
+
}): Promise<BatchItemResult[]>;
|
|
26
33
|
}
|
|
27
34
|
export interface UiDriverApi {
|
|
28
35
|
click(x: number, y: number): Promise<Observation>;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare const DEFAULT_BRANCH = "argus/onboarding";
|
|
2
|
+
/** A validation message, or undefined when the repo slug is safe to use in argv and URLs. */
|
|
3
|
+
export declare function validateRepo(repo: string): string | undefined;
|
|
4
|
+
export declare function validateBranch(branch: string): string | undefined;
|
|
5
|
+
export interface PrBodyInput {
|
|
6
|
+
repo: string;
|
|
7
|
+
/** Default per-run budget in USD, from the resolved config defaults. */
|
|
8
|
+
budgetUsd: number;
|
|
9
|
+
/** Files this PR adds. */
|
|
10
|
+
paths: string[];
|
|
11
|
+
}
|
|
12
|
+
export declare function renderPrBody(input: PrBodyInput): string;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure, runtime-neutral pieces of the onboarding PR: name validation and the
|
|
3
|
+
* PR body. No Node imports, so the CLI (`init --pr`) and the App Worker
|
|
4
|
+
* (bundled for Cloudflare) render the same PR and cannot drift.
|
|
5
|
+
*/
|
|
6
|
+
import { scaffoldChecklist } from './scaffold.js';
|
|
7
|
+
export const DEFAULT_BRANCH = 'argus/onboarding';
|
|
8
|
+
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
9
|
+
const BRANCH_RE = /^[A-Za-z0-9][A-Za-z0-9._/-]*$/;
|
|
10
|
+
/** A validation message, or undefined when the repo slug is safe to use in argv and URLs. */
|
|
11
|
+
export function validateRepo(repo) {
|
|
12
|
+
if (!REPO_RE.test(repo) || repo.split('/').some((p) => p === '.' || p === '..')) {
|
|
13
|
+
return `--repo must look like owner/name (letters, digits, . _ -), got ${JSON.stringify(repo)}`;
|
|
14
|
+
}
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
export function validateBranch(branch) {
|
|
18
|
+
if (!BRANCH_RE.test(branch) ||
|
|
19
|
+
branch.includes('..') ||
|
|
20
|
+
branch.endsWith('/') ||
|
|
21
|
+
branch.endsWith('.lock')) {
|
|
22
|
+
return `--branch is not a safe branch name: ${JSON.stringify(branch)}`;
|
|
23
|
+
}
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
export function renderPrBody(input) {
|
|
27
|
+
const { repo, budgetUsd, paths } = input;
|
|
28
|
+
return `## Add Argus reviewer
|
|
29
|
+
|
|
30
|
+
This PR adds a review-only [Argus](https://github.com/duketopceo/Argus) setup. Nothing runs until it is merged.
|
|
31
|
+
|
|
32
|
+
### Files added
|
|
33
|
+
|
|
34
|
+
${paths.map((p) => `- \`${p}\``).join('\n')}
|
|
35
|
+
|
|
36
|
+
### Before merging: add the secret
|
|
37
|
+
|
|
38
|
+
- [ ] Add \`OPENROUTER_API_KEY\` as a repository secret: https://github.com/${repo}/settings/secrets/actions/new?name=OPENROUTER_API_KEY
|
|
39
|
+
- Or from your own terminal: \`gh secret set OPENROUTER_API_KEY --repo ${repo}\`
|
|
40
|
+
- [ ] Review the workflow files above. They use \`pull_request\` (never \`pull_request_target\`), \`persist-credentials: false\`, a pinned action SHA and least-privilege \`permissions\`.
|
|
41
|
+
|
|
42
|
+
Argus never sees your key. It is your OpenRouter key (BYOK) and it stays in your repository secrets. The command that opened this PR did not read it.
|
|
43
|
+
|
|
44
|
+
### What runs and what it costs
|
|
45
|
+
|
|
46
|
+
\`\`\`
|
|
47
|
+
${scaffoldChecklist(budgetUsd).join('\n')}
|
|
48
|
+
\`\`\`
|
|
49
|
+
|
|
50
|
+
### How to stop or uninstall
|
|
51
|
+
|
|
52
|
+
- Remove \`.github/workflows/argus-reviewer.yml\` and \`.github/workflows/argus-mention.yml\`, or
|
|
53
|
+
- delete the \`OPENROUTER_API_KEY\` repository secret (reviews stop, nothing else changes).
|
|
54
|
+
- Optionally delete \`argus-reviewer.config.ts\` and \`tests/argus/\`.
|
|
55
|
+
`;
|
|
56
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { ExecFn } from '../detect.js';
|
|
2
|
+
export { DEFAULT_BRANCH, renderPrBody, validateBranch, validateRepo, type PrBodyInput } from './pr-content.js';
|
|
3
|
+
/** owner/name from a GitHub remote URL (ssh, https, with or without .git). */
|
|
4
|
+
export declare function parseGithubRemote(url: string): string | undefined;
|
|
5
|
+
export interface InitPrOptions {
|
|
6
|
+
cwd: string;
|
|
7
|
+
exec: ExecFn;
|
|
8
|
+
repo: string | undefined;
|
|
9
|
+
branch: string;
|
|
10
|
+
budgetUsd: number;
|
|
11
|
+
}
|
|
12
|
+
export type InitPrResult = {
|
|
13
|
+
kind: 'created';
|
|
14
|
+
url: string;
|
|
15
|
+
repo: string;
|
|
16
|
+
branch: string;
|
|
17
|
+
} | {
|
|
18
|
+
kind: 'existing';
|
|
19
|
+
url: string;
|
|
20
|
+
repo: string;
|
|
21
|
+
branch: string;
|
|
22
|
+
};
|
|
23
|
+
export declare function initPr(opts: InitPrOptions): Promise<InitPrResult>;
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `argus-reviewer init --pr`: open an onboarding pull request instead of
|
|
3
|
+
* writing into the working tree.
|
|
4
|
+
*
|
|
5
|
+
* Shells out to the local `git` and `gh` through the injected ExecFn, so the
|
|
6
|
+
* user's own credentials do the pushing and nothing about the repo reaches
|
|
7
|
+
* Argus. The scaffold is committed in a throwaway git worktree, never in the
|
|
8
|
+
* user's checkout. This module never reads, prints or transmits
|
|
9
|
+
* OPENROUTER_API_KEY: the PR only names the secret and links to the page
|
|
10
|
+
* where the user adds it themselves.
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
14
|
+
import { tmpdir } from 'node:os';
|
|
15
|
+
import { dirname, join } from 'node:path';
|
|
16
|
+
import { CliError } from '../ui/errors.js';
|
|
17
|
+
import { renderPrBody, validateBranch } from './pr-content.js';
|
|
18
|
+
import { renderScaffold } from './scaffold.js';
|
|
19
|
+
export { DEFAULT_BRANCH, renderPrBody, validateBranch, validateRepo } from './pr-content.js';
|
|
20
|
+
const CONFIG_NAMES = [
|
|
21
|
+
'argus-reviewer.config.ts',
|
|
22
|
+
'argus-reviewer.config.json',
|
|
23
|
+
'vision-e2e.config.ts',
|
|
24
|
+
'vision-e2e.config.json',
|
|
25
|
+
];
|
|
26
|
+
const SHORT_MS = 30_000;
|
|
27
|
+
const NET_MS = 120_000;
|
|
28
|
+
/** owner/name from a GitHub remote URL (ssh, https, with or without .git). */
|
|
29
|
+
export function parseGithubRemote(url) {
|
|
30
|
+
const m = url.trim().match(/github\.com[:/]([A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+?)(?:\.git)?\/?$/);
|
|
31
|
+
return m?.[1];
|
|
32
|
+
}
|
|
33
|
+
function fail(message, fix) {
|
|
34
|
+
return new CliError('COMMAND_FAILED', message, fix !== undefined ? { fix } : {});
|
|
35
|
+
}
|
|
36
|
+
export async function initPr(opts) {
|
|
37
|
+
const { cwd, exec, branch } = opts;
|
|
38
|
+
const git = (args, ms = SHORT_MS) => exec('git', ['-C', cwd, ...args], ms);
|
|
39
|
+
const gh = (args, ms = SHORT_MS) => exec('gh', args, ms);
|
|
40
|
+
// 1. Which repo. The checkout's origin is the push target, so --repo may
|
|
41
|
+
// only confirm it, never redirect it.
|
|
42
|
+
const originRes = await git(['remote', 'get-url', 'origin']);
|
|
43
|
+
const originRepo = originRes.code === 0 ? parseGithubRemote(originRes.stdout) : undefined;
|
|
44
|
+
if (originRepo === undefined) {
|
|
45
|
+
throw fail('this checkout has no GitHub remote named origin to push the onboarding branch to', 'git remote add origin https://github.com/<owner>/<name>.git');
|
|
46
|
+
}
|
|
47
|
+
if (opts.repo !== undefined && opts.repo.toLowerCase() !== originRepo.toLowerCase()) {
|
|
48
|
+
throw fail(`--repo ${opts.repo} does not match this checkout's origin (${originRepo})`, `run it from a checkout of ${opts.repo}, or drop --repo`);
|
|
49
|
+
}
|
|
50
|
+
const repo = originRepo;
|
|
51
|
+
// 2. An open onboarding PR already? Report it and stop.
|
|
52
|
+
const listed = await gh([
|
|
53
|
+
'pr',
|
|
54
|
+
'list',
|
|
55
|
+
'--repo',
|
|
56
|
+
repo,
|
|
57
|
+
'--head',
|
|
58
|
+
branch,
|
|
59
|
+
'--state',
|
|
60
|
+
'open',
|
|
61
|
+
'--json',
|
|
62
|
+
'url',
|
|
63
|
+
]);
|
|
64
|
+
if (listed.code !== 0) {
|
|
65
|
+
throw fail(`could not query pull requests with gh: ${firstLine(listed.stderr) || 'gh not available'}`, 'install the GitHub CLI and run: gh auth login');
|
|
66
|
+
}
|
|
67
|
+
const existingUrl = firstUrl(listed.stdout);
|
|
68
|
+
if (existingUrl !== undefined)
|
|
69
|
+
return { kind: 'existing', url: existingUrl, repo, branch };
|
|
70
|
+
// 3. Never overwrite. An existing config is not a conflict: it is kept
|
|
71
|
+
// and left out of the PR, as plain `init` does.
|
|
72
|
+
const hasConfig = CONFIG_NAMES.some((n) => existsSync(join(cwd, n)));
|
|
73
|
+
const files = renderScaffold({ a0Host: undefined, includeConfig: !hasConfig });
|
|
74
|
+
const conflicts = files.filter((f) => existsSync(join(cwd, f.path))).map((f) => f.path);
|
|
75
|
+
if (conflicts.length > 0) {
|
|
76
|
+
throw fail(`refusing to overwrite existing files:\n${conflicts.map((c) => ` ${c}`).join('\n')}`, 'move or delete them, or use `argus-reviewer init` to keep your versions');
|
|
77
|
+
}
|
|
78
|
+
// 4. Base branch.
|
|
79
|
+
const base = await defaultBranch(git, gh, repo);
|
|
80
|
+
// 5. Branch: reuse whatever already exists, otherwise create and push it.
|
|
81
|
+
const remoteHas = (await git(['ls-remote', '--exit-code', '--heads', 'origin', branch], NET_MS)).code === 0;
|
|
82
|
+
const localHas = (await git(['rev-parse', '--verify', '--quiet', `refs/heads/${branch}`])).code === 0;
|
|
83
|
+
if (!remoteHas && !localHas) {
|
|
84
|
+
await commitInWorktree({ ...opts, files, base, git });
|
|
85
|
+
}
|
|
86
|
+
else if (!remoteHas) {
|
|
87
|
+
const pushed = await git(['push', '-u', 'origin', branch], NET_MS);
|
|
88
|
+
if (pushed.code !== 0)
|
|
89
|
+
throw fail(`git push failed: ${firstLine(pushed.stderr)}`);
|
|
90
|
+
}
|
|
91
|
+
// 6. Open the PR.
|
|
92
|
+
const bodyDir = await mkdtemp(join(tmpdir(), 'argus-initpr-body-'));
|
|
93
|
+
try {
|
|
94
|
+
const bodyFile = join(bodyDir, 'body.md');
|
|
95
|
+
await writeFile(bodyFile, renderPrBody({ repo, budgetUsd: opts.budgetUsd, paths: files.map((f) => f.path) }), 'utf8');
|
|
96
|
+
const created = await gh([
|
|
97
|
+
'pr',
|
|
98
|
+
'create',
|
|
99
|
+
'--repo',
|
|
100
|
+
repo,
|
|
101
|
+
'--head',
|
|
102
|
+
branch,
|
|
103
|
+
'--base',
|
|
104
|
+
base,
|
|
105
|
+
'--title',
|
|
106
|
+
'Add Argus reviewer (review-only, bring your own key)',
|
|
107
|
+
'--body-file',
|
|
108
|
+
bodyFile,
|
|
109
|
+
], NET_MS);
|
|
110
|
+
const url = firstUrl(created.stdout, true);
|
|
111
|
+
if (created.code !== 0 || url === undefined) {
|
|
112
|
+
throw fail(`gh pr create failed: ${firstLine(created.stderr) || firstLine(created.stdout)}`);
|
|
113
|
+
}
|
|
114
|
+
return { kind: 'created', url, repo, branch };
|
|
115
|
+
}
|
|
116
|
+
finally {
|
|
117
|
+
await rm(bodyDir, { recursive: true, force: true });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
async function defaultBranch(git, gh, repo) {
|
|
121
|
+
const sym = await git(['symbolic-ref', '--short', 'refs/remotes/origin/HEAD']);
|
|
122
|
+
const fromRef = sym.code === 0 ? sym.stdout.trim().replace(/^origin\//, '') : '';
|
|
123
|
+
if (fromRef !== '' && !validateBranch(fromRef))
|
|
124
|
+
return fromRef;
|
|
125
|
+
const view = await gh([
|
|
126
|
+
'repo',
|
|
127
|
+
'view',
|
|
128
|
+
'--repo',
|
|
129
|
+
repo,
|
|
130
|
+
'--json',
|
|
131
|
+
'defaultBranchRef',
|
|
132
|
+
'--jq',
|
|
133
|
+
'.defaultBranchRef.name',
|
|
134
|
+
]);
|
|
135
|
+
const name = view.stdout.trim();
|
|
136
|
+
if (view.code === 0 && name !== '' && !validateBranch(name))
|
|
137
|
+
return name;
|
|
138
|
+
throw fail(`could not determine the default branch of ${repo}`, 'git remote set-head origin --auto');
|
|
139
|
+
}
|
|
140
|
+
async function commitInWorktree(o) {
|
|
141
|
+
const { git, files, base, branch } = o;
|
|
142
|
+
const dir = await mkdtemp(join(tmpdir(), 'argus-initpr-wt-'));
|
|
143
|
+
const wt = (args, ms = SHORT_MS) => o.exec('git', ['-C', dir, ...args], ms);
|
|
144
|
+
try {
|
|
145
|
+
const added = await git(['worktree', 'add', '--no-track', '-b', branch, dir, `origin/${base}`]);
|
|
146
|
+
if (added.code !== 0) {
|
|
147
|
+
throw fail(`could not create the onboarding branch from origin/${base}: ${firstLine(added.stderr)}`, 'git fetch origin');
|
|
148
|
+
}
|
|
149
|
+
for (const f of files) {
|
|
150
|
+
const abs = join(dir, f.path);
|
|
151
|
+
await mkdir(dirname(abs), { recursive: true });
|
|
152
|
+
await writeFile(abs, f.content, 'utf8');
|
|
153
|
+
}
|
|
154
|
+
const staged = await wt(['add', '--', ...files.map((f) => f.path)]);
|
|
155
|
+
if (staged.code !== 0)
|
|
156
|
+
throw fail(`git add failed: ${firstLine(staged.stderr)}`);
|
|
157
|
+
const committed = await wt([
|
|
158
|
+
'commit',
|
|
159
|
+
'-m',
|
|
160
|
+
'chore: add Argus reviewer (review-only, BYOK)',
|
|
161
|
+
'-m',
|
|
162
|
+
'Generated by `argus-reviewer init --pr`.',
|
|
163
|
+
]);
|
|
164
|
+
if (committed.code !== 0) {
|
|
165
|
+
throw fail(`git commit failed: ${firstLine(committed.stderr) || firstLine(committed.stdout)}`);
|
|
166
|
+
}
|
|
167
|
+
const pushed = await wt(['push', '-u', 'origin', branch], NET_MS);
|
|
168
|
+
if (pushed.code !== 0)
|
|
169
|
+
throw fail(`git push failed: ${firstLine(pushed.stderr)}`);
|
|
170
|
+
}
|
|
171
|
+
finally {
|
|
172
|
+
await git(['worktree', 'remove', '--force', dir]);
|
|
173
|
+
await rm(dir, { recursive: true, force: true });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
function firstLine(s) {
|
|
177
|
+
return s.trim().split('\n')[0] ?? '';
|
|
178
|
+
}
|
|
179
|
+
/** First https URL in gh output: a JSON `[{"url":...}]` list, or (last=true) a bare line. */
|
|
180
|
+
function firstUrl(stdout, last = false) {
|
|
181
|
+
if (last) {
|
|
182
|
+
const line = stdout.trim().split('\n').pop() ?? '';
|
|
183
|
+
return /^https:\/\/github\.com\/\S+$/.test(line) ? line : undefined;
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
const parsed = JSON.parse(stdout);
|
|
187
|
+
if (Array.isArray(parsed)) {
|
|
188
|
+
const u = parsed[0]?.url;
|
|
189
|
+
return typeof u === 'string' && u.startsWith('https://') ? u : undefined;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
// fall through
|
|
194
|
+
}
|
|
195
|
+
return undefined;
|
|
196
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure scaffold generator shared by `argus-reviewer init` and `init --pr`
|
|
3
|
+
* (and, later, the App's onboarding Worker, so the surfaces cannot drift).
|
|
4
|
+
* No I/O, no config execution, no environment reads. Workflow templates keep
|
|
5
|
+
* `persist-credentials: false`, a pinned action SHA, least-privilege
|
|
6
|
+
* `permissions`, and never `pull_request_target`.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* The one place the user-facing action pin lives. Bump it here (and the golden
|
|
10
|
+
* fixtures) on release; see RELEASING.md. v0.4.0 and v0.4.1 ship an action.yml
|
|
11
|
+
* GitHub cannot parse, so never pin to them.
|
|
12
|
+
*/
|
|
13
|
+
export declare const ACTION_PIN_SHA = "dbf4b7f669507402d527cb0e350285ca56129448";
|
|
14
|
+
export declare const ACTION_PIN_TAG = "v0.4.2";
|
|
15
|
+
export interface ScaffoldFile {
|
|
16
|
+
path: string;
|
|
17
|
+
content: string;
|
|
18
|
+
}
|
|
19
|
+
export interface ScaffoldOptions {
|
|
20
|
+
/** Detected Agent Zero host: earns a commented suggestion in the config, never an enabled lane. */
|
|
21
|
+
a0Host: string | undefined;
|
|
22
|
+
/** False when a config file already exists (init without --force). */
|
|
23
|
+
includeConfig: boolean;
|
|
24
|
+
}
|
|
25
|
+
export declare function initConfig(a0Host: string | undefined): string;
|
|
26
|
+
export declare const INIT_TEST = "test('home renders', async (td) => {\n const ok = await td.assert('the page rendered without obvious errors')\n if (!ok) throw new Error('home did not render')\n})\n";
|
|
27
|
+
export declare const INIT_WORKFLOW: string;
|
|
28
|
+
export declare const INIT_MENTION_WORKFLOW: string;
|
|
29
|
+
export declare const CONFIG_PATH = "argus-reviewer.config.ts";
|
|
30
|
+
/** The files `init` writes, in write order. */
|
|
31
|
+
export declare function renderScaffold(opts: ScaffoldOptions): ScaffoldFile[];
|
|
32
|
+
/**
|
|
33
|
+
* "What runs and what it costs": what is sent to the provider, the default
|
|
34
|
+
* budget, and the stop path. Kept verbatim (DESIGN 7.8). `budgetUsd` comes
|
|
35
|
+
* from the caller's resolved defaults so it cannot go stale here.
|
|
36
|
+
*/
|
|
37
|
+
export declare function scaffoldChecklist(budgetUsd: number): string[];
|