@jameslovespancakes/pi-plus 1.0.10 → 1.0.12
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 +44 -20
- package/config/skills/workflow-code-review-actions/SKILL.md +44 -0
- package/package.json +6 -3
- package/src/core/accounts/oauth-pool.ts +102 -0
- package/src/core/accounts/registry.ts +8 -25
- package/src/core/accounts/routing.ts +122 -0
- package/src/core/anthropic/client-identity.ts +9 -64
- package/src/core/anthropic/quota.ts +8 -53
- package/src/core/anthropic/routing.ts +40 -115
- package/src/core/anthropic/store.ts +7 -9
- package/src/core/catalog/quality.ts +33 -15
- package/src/core/codex/quota.ts +8 -2
- package/src/core/codex/store.ts +12 -9
- package/src/core/config.ts +4 -17
- package/src/core/store.ts +13 -12
- package/src/domains/models/provider-picker.ts +1 -1
- package/src/domains/setup/index.ts +23 -23
- package/src/domains/subscriptions/accounts-picker.ts +18 -37
- package/src/domains/subscriptions/accounts.ts +23 -29
- package/src/domains/subscriptions/footer.ts +48 -29
- package/src/domains/subscriptions/index.ts +11 -27
- package/src/domains/subscriptions/provider.ts +38 -103
- package/src/domains/subscriptions/providers/anthropic.ts +5 -17
- package/src/domains/subscriptions/providers/codex.ts +171 -4
- package/src/domains/subscriptions/providers/hosted.ts +18 -0
- package/src/domains/subscriptions/providers/oauth-pool.ts +273 -0
- package/src/domains/subscriptions/routing.ts +19 -11
- package/src/domains/workflows/LICENSE.md +21 -0
- package/src/domains/workflows/index.ts +836 -0
- package/src/domains/workflows/runtime/advisory-challenge.ts +75 -0
- package/src/domains/workflows/runtime/advisory-evidence.ts +90 -0
- package/src/domains/workflows/runtime/advisory-schema.ts +83 -0
- package/src/domains/workflows/runtime/agent-attempt.ts +147 -0
- package/src/domains/workflows/runtime/agent-limits.ts +43 -0
- package/src/domains/workflows/runtime/agent-replay.ts +399 -0
- package/src/domains/workflows/runtime/agent-retry.ts +116 -0
- package/src/domains/workflows/runtime/agent-runner-types.ts +96 -0
- package/src/domains/workflows/runtime/agent-runner.ts +203 -0
- package/src/domains/workflows/runtime/agent-session-identity.ts +256 -0
- package/src/domains/workflows/runtime/agent-session-providers.ts +50 -0
- package/src/domains/workflows/runtime/agent-session.ts +382 -0
- package/src/domains/workflows/runtime/agent-skills.ts +270 -0
- package/src/domains/workflows/runtime/agent-workspace.ts +103 -0
- package/src/domains/workflows/runtime/background-workflow-tool.ts +75 -0
- package/src/domains/workflows/runtime/background-workflows.ts +492 -0
- package/src/domains/workflows/runtime/budget.ts +53 -0
- package/src/domains/workflows/runtime/cancellation.ts +87 -0
- package/src/domains/workflows/runtime/command-completions.ts +36 -0
- package/src/domains/workflows/runtime/concurrency.ts +403 -0
- package/src/domains/workflows/runtime/debug.ts +3 -0
- package/src/domains/workflows/runtime/diff-capture.ts +81 -0
- package/src/domains/workflows/runtime/discovery.ts +137 -0
- package/src/domains/workflows/runtime/dynamax-shortcuts.ts +122 -0
- package/src/domains/workflows/runtime/dynamax.ts +330 -0
- package/src/domains/workflows/runtime/engine.ts +543 -0
- package/src/domains/workflows/runtime/filesystem-error.ts +4 -0
- package/src/domains/workflows/runtime/finalizers.ts +66 -0
- package/src/domains/workflows/runtime/identity-canonicalization.ts +138 -0
- package/src/domains/workflows/runtime/identity-fingerprint.ts +15 -0
- package/src/domains/workflows/runtime/inline-workflow.ts +403 -0
- package/src/domains/workflows/runtime/journal.ts +313 -0
- package/src/domains/workflows/runtime/model-profiles.ts +310 -0
- package/src/domains/workflows/runtime/options.ts +157 -0
- package/src/domains/workflows/runtime/perf.ts +146 -0
- package/src/domains/workflows/runtime/pi-compat.ts +32 -0
- package/src/domains/workflows/runtime/process-runner.ts +260 -0
- package/src/domains/workflows/runtime/progress-types.ts +59 -0
- package/src/domains/workflows/runtime/progress.ts +400 -0
- package/src/domains/workflows/runtime/provider-usage-limit.ts +191 -0
- package/src/domains/workflows/runtime/replay-path-identity.ts +48 -0
- package/src/domains/workflows/runtime/research-contract.ts +89 -0
- package/src/domains/workflows/runtime/research-evidence.ts +289 -0
- package/src/domains/workflows/runtime/resume-context.ts +751 -0
- package/src/domains/workflows/runtime/review/code-review-orchestration.ts +24 -0
- package/src/domains/workflows/runtime/review/github-pr-comments.ts +249 -0
- package/src/domains/workflows/runtime/review/patch-validation.ts +120 -0
- package/src/domains/workflows/runtime/review/review-actions.ts +180 -0
- package/src/domains/workflows/runtime/review/review-budget.ts +45 -0
- package/src/domains/workflows/runtime/review/review-fix-workflow.ts +153 -0
- package/src/domains/workflows/runtime/review/review-format.ts +133 -0
- package/src/domains/workflows/runtime/review/review-handoff.ts +38 -0
- package/src/domains/workflows/runtime/review/review-issues.ts +87 -0
- package/src/domains/workflows/runtime/review/review-report.ts +42 -0
- package/src/domains/workflows/runtime/review/review-results-flow.ts +52 -0
- package/src/domains/workflows/runtime/review/review-results-viewer.ts +312 -0
- package/src/domains/workflows/runtime/review/review-session-coordinator.ts +153 -0
- package/src/domains/workflows/runtime/review/review-snapshot.ts +294 -0
- package/src/domains/workflows/runtime/review-diff-target.ts +130 -0
- package/src/domains/workflows/runtime/session-identity.ts +6 -0
- package/src/domains/workflows/runtime/structured-output.ts +32 -0
- package/src/domains/workflows/runtime/tool-capabilities.ts +44 -0
- package/src/domains/workflows/runtime/tool-source-identity.ts +150 -0
- package/src/domains/workflows/runtime/tree-fingerprint.ts +404 -0
- package/src/domains/workflows/runtime/types.ts +255 -0
- package/src/domains/workflows/runtime/ui/display-text.ts +13 -0
- package/src/domains/workflows/runtime/ui/dynamax-editor-decoration.ts +220 -0
- package/src/domains/workflows/runtime/ui/workflow-format.ts +165 -0
- package/src/domains/workflows/runtime/ui/workflow-inspector.ts +398 -0
- package/src/domains/workflows/runtime/ui/workflow-result-renderer.ts +171 -0
- package/src/domains/workflows/runtime/ui/workflow-viewer-layout.ts +51 -0
- package/src/domains/workflows/runtime/ui/workflow-widget.ts +78 -0
- package/src/domains/workflows/runtime/unknown-error.ts +8 -0
- package/src/domains/workflows/runtime/usage.ts +341 -0
- package/src/domains/workflows/runtime/workflow-advisory-utils.ts +245 -0
- package/src/domains/workflows/runtime/workflow-execution.ts +121 -0
- package/src/domains/workflows/runtime/workflow-module.ts +75 -0
- package/src/domains/workflows/runtime/workflow-run-background.ts +72 -0
- package/src/domains/workflows/runtime/workflow-run-controller.ts +342 -0
- package/src/domains/workflows/runtime/workflow-run-history.ts +162 -0
- package/src/domains/workflows/runtime/workflow-run-record.ts +635 -0
- package/src/domains/workflows/runtime/workflow-run-store.ts +176 -0
- package/src/domains/workflows/runtime/workflow-usage-limit-scheduler.ts +80 -0
- package/src/domains/workflows/runtime/workflows.ts +40 -0
- package/src/domains/workflows/runtime/worktree.ts +581 -0
- package/src/domains/workflows/workflows/code-review.ts +232 -0
- package/src/domains/workflows/workflows/diagnose.ts +154 -0
- package/src/domains/workflows/workflows/perf-review.ts +149 -0
- package/src/domains/workflows/workflows/refactor-scout.ts +143 -0
- package/src/domains/workflows/workflows/research.ts +169 -0
- package/src/services/usage-service.ts +6 -30
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
const DIFF_EMBED_CAP = 60_000;
|
|
2
|
+
|
|
3
|
+
export function buildCodeReviewScopeBlock(input: {
|
|
4
|
+
readonly diffCommand: string;
|
|
5
|
+
readonly files: readonly string[];
|
|
6
|
+
readonly summary: string;
|
|
7
|
+
readonly conventions?: string;
|
|
8
|
+
readonly diffText: string;
|
|
9
|
+
readonly target: string;
|
|
10
|
+
}): string {
|
|
11
|
+
const diffBlock = input.diffText
|
|
12
|
+
? `\n## Diff (review is bounded to these changed lines)\n\`\`\`diff\n${
|
|
13
|
+
input.diffText.length > DIFF_EMBED_CAP
|
|
14
|
+
? `${input.diffText.slice(0, DIFF_EMBED_CAP)}\n... (truncated — run \`${input.diffCommand}\` for the full diff)`
|
|
15
|
+
: input.diffText
|
|
16
|
+
}\n\`\`\`\n`
|
|
17
|
+
: "";
|
|
18
|
+
return (
|
|
19
|
+
`## Diff command\n${input.diffCommand}\n\n## Changed files\n${input.files.map((file) => `- ${file}`).join("\n")}\n\n` +
|
|
20
|
+
`## Summary\n${input.summary}\n\n## Conventions\n${input.conventions ?? "(none noted)"}\n` +
|
|
21
|
+
diffBlock +
|
|
22
|
+
(input.target ? `\n## User instructions (verbatim)\n${input.target}\n` : "")
|
|
23
|
+
);
|
|
24
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
import { throwIfAborted } from "../cancellation.ts";
|
|
2
|
+
import { isCommentableIssue, type ReviewIssue } from "./review-issues.ts";
|
|
3
|
+
import type { ReviewContext } from "./review-report.ts";
|
|
4
|
+
|
|
5
|
+
export interface ExecResultLike {
|
|
6
|
+
readonly stdout: string;
|
|
7
|
+
readonly stderr?: string;
|
|
8
|
+
readonly code: number;
|
|
9
|
+
readonly killed?: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export type ExecLike = (
|
|
13
|
+
command: string,
|
|
14
|
+
args: string[],
|
|
15
|
+
options?: { readonly cwd?: string; readonly timeout?: number; readonly signal?: AbortSignal },
|
|
16
|
+
) => Promise<ExecResultLike>;
|
|
17
|
+
|
|
18
|
+
export interface GitHubPrContext {
|
|
19
|
+
readonly owner: string;
|
|
20
|
+
readonly repo: string;
|
|
21
|
+
readonly number: number;
|
|
22
|
+
readonly headSha: string;
|
|
23
|
+
readonly url?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type ResolveGitHubPrContextResult = { readonly ok: true; readonly context: GitHubPrContext } | { readonly ok: false; readonly reason: string };
|
|
27
|
+
|
|
28
|
+
export type InlineCommentStatus =
|
|
29
|
+
| { readonly issueId: string; readonly status: "posted"; readonly url?: string }
|
|
30
|
+
| { readonly issueId: string; readonly status: "skipped"; readonly reason: string }
|
|
31
|
+
| { readonly issueId: string; readonly status: "failed"; readonly reason: string };
|
|
32
|
+
|
|
33
|
+
const PR_VIEW_JSON_FIELDS = "number,headRefOid,url";
|
|
34
|
+
|
|
35
|
+
export async function resolveGitHubPrContext(
|
|
36
|
+
exec: ExecLike,
|
|
37
|
+
cwd: string,
|
|
38
|
+
reviewContext: ReviewContext | undefined,
|
|
39
|
+
signal?: AbortSignal,
|
|
40
|
+
): Promise<ResolveGitHubPrContextResult> {
|
|
41
|
+
const parsedNumber = reviewContext?.diffTarget.kind === "pull-request" ? reviewContext.diffTarget.number : undefined;
|
|
42
|
+
const prArgs = parsedNumber
|
|
43
|
+
? ["pr", "view", String(parsedNumber), "--json", PR_VIEW_JSON_FIELDS]
|
|
44
|
+
: ["pr", "view", "--json", PR_VIEW_JSON_FIELDS];
|
|
45
|
+
const prView = await runJson(exec, cwd, prArgs, signal);
|
|
46
|
+
if (!prView.ok) return { ok: false, reason: prView.reason };
|
|
47
|
+
|
|
48
|
+
const number = numberField(prView.value, "number") ?? parsedNumber;
|
|
49
|
+
const headSha = stringField(prView.value, "headRefOid");
|
|
50
|
+
if (number === undefined) return { ok: false, reason: "No GitHub PR number found." };
|
|
51
|
+
if (!headSha) return { ok: false, reason: "GitHub PR head SHA is missing." };
|
|
52
|
+
|
|
53
|
+
const url = stringField(prView.value, "url");
|
|
54
|
+
const baseFromUrl = parsePullRequestUrl(url);
|
|
55
|
+
let owner = baseFromUrl?.owner;
|
|
56
|
+
let repo = baseFromUrl?.repo;
|
|
57
|
+
if (!owner || !repo) {
|
|
58
|
+
const repoView = await runJson(exec, cwd, ["repo", "view", "--json", "nameWithOwner"], signal);
|
|
59
|
+
if (!repoView.ok) return { ok: false, reason: repoView.reason };
|
|
60
|
+
const nameWithOwner = stringField(repoView.value, "nameWithOwner");
|
|
61
|
+
const parsed = parseNameWithOwner(nameWithOwner);
|
|
62
|
+
owner = owner ?? parsed?.owner;
|
|
63
|
+
repo = repo ?? parsed?.repo;
|
|
64
|
+
}
|
|
65
|
+
if (!owner || !repo) return { ok: false, reason: "GitHub repository owner/name is missing." };
|
|
66
|
+
|
|
67
|
+
return { ok: true, context: { owner, repo, number, headSha, url } };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function buildInlineCommentBody(issue: ReviewIssue): string {
|
|
71
|
+
const finding = issue.finding;
|
|
72
|
+
const evidence = finding.evidence.length > 0 ? finding.evidence.map((entry) => `- ${entry}`).join("\n") : "- (none cited)";
|
|
73
|
+
return `**${issue.id}: ${finding.summary}**
|
|
74
|
+
|
|
75
|
+
Severity: ${finding.severity} · Confidence: ${finding.confidence} · Category: ${finding.category}
|
|
76
|
+
|
|
77
|
+
Impact: ${finding.impact}
|
|
78
|
+
|
|
79
|
+
Evidence:
|
|
80
|
+
${evidence}
|
|
81
|
+
|
|
82
|
+
Recommendation: ${finding.recommendation}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function postInlineComment(
|
|
86
|
+
exec: ExecLike,
|
|
87
|
+
cwd: string,
|
|
88
|
+
prContext: GitHubPrContext,
|
|
89
|
+
issue: ReviewIssue,
|
|
90
|
+
signal?: AbortSignal,
|
|
91
|
+
): Promise<InlineCommentStatus> {
|
|
92
|
+
if (!isCommentableIssue(issue)) return { issueId: issue.id, status: "skipped", reason: "Finding has no commentable file/line." };
|
|
93
|
+
|
|
94
|
+
const result = await runCommand(exec, cwd, [
|
|
95
|
+
"api",
|
|
96
|
+
`repos/${prContext.owner}/${prContext.repo}/pulls/${prContext.number}/comments`,
|
|
97
|
+
"-f",
|
|
98
|
+
`body=${buildInlineCommentBody(issue)}`,
|
|
99
|
+
"-f",
|
|
100
|
+
`commit_id=${prContext.headSha}`,
|
|
101
|
+
"-f",
|
|
102
|
+
`path=${issue.file}`,
|
|
103
|
+
"-F",
|
|
104
|
+
`line=${issue.line}`,
|
|
105
|
+
"-f",
|
|
106
|
+
"side=RIGHT",
|
|
107
|
+
], signal);
|
|
108
|
+
if (!result.ok) return { issueId: issue.id, status: "failed", reason: result.reason };
|
|
109
|
+
return { issueId: issue.id, status: "posted", url: parsePostedCommentUrl(result.stdout) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export async function postInlineComments(
|
|
113
|
+
exec: ExecLike,
|
|
114
|
+
cwd: string,
|
|
115
|
+
prContext: GitHubPrContext,
|
|
116
|
+
issues: readonly ReviewIssue[],
|
|
117
|
+
signal?: AbortSignal,
|
|
118
|
+
): Promise<InlineCommentStatus[]> {
|
|
119
|
+
throwIfAborted(signal);
|
|
120
|
+
const existing = await loadExistingInlineCommentKeys(exec, cwd, prContext, signal);
|
|
121
|
+
if (!existing.ok) {
|
|
122
|
+
return issues.map((issue) => ({ issueId: issue.id, status: "failed", reason: existing.reason }));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const statuses: InlineCommentStatus[] = [];
|
|
126
|
+
for (const issue of issues) {
|
|
127
|
+
throwIfAborted(signal);
|
|
128
|
+
const key = inlineCommentKey(issue, prContext.headSha);
|
|
129
|
+
if (key && existing.keys.has(key)) {
|
|
130
|
+
statuses.push({ issueId: issue.id, status: "skipped", reason: "An identical inline comment already exists on this PR head." });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const status = await postInlineComment(exec, cwd, prContext, issue, signal);
|
|
134
|
+
statuses.push(status);
|
|
135
|
+
if (key && status.status === "posted") existing.keys.add(key);
|
|
136
|
+
}
|
|
137
|
+
return statuses;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function loadExistingInlineCommentKeys(
|
|
141
|
+
exec: ExecLike,
|
|
142
|
+
cwd: string,
|
|
143
|
+
prContext: GitHubPrContext,
|
|
144
|
+
signal: AbortSignal | undefined,
|
|
145
|
+
): Promise<{ readonly ok: true; readonly keys: Set<string> } | { readonly ok: false; readonly reason: string }> {
|
|
146
|
+
const endpoint = `repos/${prContext.owner}/${prContext.repo}/pulls/${prContext.number}/comments`;
|
|
147
|
+
const result = await runJson(exec, cwd, ["api", endpoint, "--paginate", "--slurp"], signal);
|
|
148
|
+
if (!result.ok) return { ok: false, reason: `Could not inspect existing PR comments: ${result.reason}` };
|
|
149
|
+
const keys = parseExistingInlineCommentKeys(result.value);
|
|
150
|
+
return keys ? { ok: true, keys } : { ok: false, reason: "Could not inspect existing PR comments: unexpected GitHub response." };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function runJson(
|
|
154
|
+
exec: ExecLike,
|
|
155
|
+
cwd: string,
|
|
156
|
+
args: string[],
|
|
157
|
+
signal?: AbortSignal,
|
|
158
|
+
): Promise<{ readonly ok: true; readonly value: unknown } | { readonly ok: false; readonly reason: string }> {
|
|
159
|
+
const result = await runCommand(exec, cwd, args, signal);
|
|
160
|
+
if (!result.ok) return result;
|
|
161
|
+
try {
|
|
162
|
+
return { ok: true, value: JSON.parse(result.stdout) };
|
|
163
|
+
} catch (error) {
|
|
164
|
+
return { ok: false, reason: error instanceof Error ? `Invalid gh JSON: ${error.message}` : "Invalid gh JSON." };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
async function runCommand(
|
|
169
|
+
exec: ExecLike,
|
|
170
|
+
cwd: string,
|
|
171
|
+
args: string[],
|
|
172
|
+
signal?: AbortSignal,
|
|
173
|
+
): Promise<{ readonly ok: true; readonly stdout: string } | { readonly ok: false; readonly reason: string }> {
|
|
174
|
+
throwIfAborted(signal);
|
|
175
|
+
try {
|
|
176
|
+
const result = await exec("gh", args, { cwd, timeout: 30_000, signal });
|
|
177
|
+
throwIfAborted(signal);
|
|
178
|
+
if (result.code !== 0) return { ok: false, reason: result.stderr?.trim() || `gh exited with code ${result.code}` };
|
|
179
|
+
return { ok: true, stdout: result.stdout };
|
|
180
|
+
} catch (error) {
|
|
181
|
+
throwIfAborted(signal);
|
|
182
|
+
return { ok: false, reason: error instanceof Error ? error.message : "gh command failed." };
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseExistingInlineCommentKeys(value: unknown): Set<string> | undefined {
|
|
187
|
+
if (!Array.isArray(value)) return undefined;
|
|
188
|
+
const comments = value.every(Array.isArray) ? value.flat() : value;
|
|
189
|
+
const keys = new Set<string>();
|
|
190
|
+
for (const comment of comments) {
|
|
191
|
+
const body = stringField(comment, "body");
|
|
192
|
+
const path = stringField(comment, "path");
|
|
193
|
+
const line = numberField(comment, "line");
|
|
194
|
+
const headSha = stringField(comment, "commit_id");
|
|
195
|
+
if (body && path && line !== undefined && headSha) keys.add(commentKey(body, path, line, headSha));
|
|
196
|
+
}
|
|
197
|
+
return keys;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
function inlineCommentKey(issue: ReviewIssue, headSha: string): string | undefined {
|
|
201
|
+
return isCommentableIssue(issue) ? commentKey(buildInlineCommentBody(issue), issue.file, issue.line, headSha) : undefined;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function commentKey(body: string, path: string, line: number, headSha: string): string {
|
|
205
|
+
return JSON.stringify([body, path, line, headSha]);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function numberField(value: unknown, key: string): number | undefined {
|
|
209
|
+
if (!isRecord(value)) return undefined;
|
|
210
|
+
const field = value[key];
|
|
211
|
+
return typeof field === "number" && Number.isFinite(field) ? field : undefined;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function stringField(value: unknown, key: string): string | undefined {
|
|
215
|
+
if (!isRecord(value)) return undefined;
|
|
216
|
+
const field = value[key];
|
|
217
|
+
return typeof field === "string" && field.trim().length > 0 ? field : undefined;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function parseNameWithOwner(value: string | undefined): { readonly owner: string; readonly repo: string } | undefined {
|
|
221
|
+
if (!value) return undefined;
|
|
222
|
+
const [owner, repo, extra] = value.split("/");
|
|
223
|
+
if (!owner || !repo || extra !== undefined) return undefined;
|
|
224
|
+
return { owner, repo };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function parsePullRequestUrl(value: string | undefined): { readonly owner: string; readonly repo: string } | undefined {
|
|
228
|
+
if (!value) return undefined;
|
|
229
|
+
try {
|
|
230
|
+
const parsed = new URL(value);
|
|
231
|
+
const [owner, repo, type, number, extra] = parsed.pathname.split("/").filter(Boolean);
|
|
232
|
+
if (!owner || !repo || type !== "pull" || !number || extra !== undefined) return undefined;
|
|
233
|
+
return { owner, repo };
|
|
234
|
+
} catch {
|
|
235
|
+
return undefined;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function parsePostedCommentUrl(stdout: string): string | undefined {
|
|
240
|
+
try {
|
|
241
|
+
return stringField(JSON.parse(stdout), "html_url");
|
|
242
|
+
} catch {
|
|
243
|
+
return undefined;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
248
|
+
return typeof value === "object" && value !== null;
|
|
249
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { Type, type Static } from "typebox";
|
|
3
|
+
import { throwIfAborted } from "../cancellation.ts";
|
|
4
|
+
import { runBoundedProcess, type BoundedProcessResult } from "../process-runner.ts";
|
|
5
|
+
import { WorktreeRegistry, type WorktreeBaseline } from "../worktree.ts";
|
|
6
|
+
import { unknownErrorMessage } from "../unknown-error.ts";
|
|
7
|
+
import { fingerprintReviewWorktreeBaseline } from "./review-snapshot.ts";
|
|
8
|
+
|
|
9
|
+
export const PatchEvaluationSchema = Type.Object({
|
|
10
|
+
outcome: Type.Union([Type.Literal("accepted"), Type.Literal("rejected"), Type.Literal("blocked")]),
|
|
11
|
+
reason: Type.String(),
|
|
12
|
+
checks: Type.Array(Type.Object({
|
|
13
|
+
file: Type.String({ description: "Executable for a focused validation command, not a shell command string." }),
|
|
14
|
+
args: Type.Array(Type.String()),
|
|
15
|
+
required: Type.Boolean(),
|
|
16
|
+
regression: Type.Optional(Type.Object({
|
|
17
|
+
baselinePatch: Type.String({ description: "Test-only patch to apply to the original baseline to reproduce the bug; exclude the repair." }),
|
|
18
|
+
expectedFailure: Type.String({ description: "Specific failure text proving the intended defect, not a missing dependency or setup error." }),
|
|
19
|
+
})),
|
|
20
|
+
}), { maxItems: 6 }),
|
|
21
|
+
});
|
|
22
|
+
export type PatchEvaluation = Static<typeof PatchEvaluationSchema>;
|
|
23
|
+
export type PatchCandidateStatus = "verified" | "rejected" | "blocked" | "no-patch";
|
|
24
|
+
export interface PatchValidation {
|
|
25
|
+
status: PatchCandidateStatus;
|
|
26
|
+
baselineFingerprint: string;
|
|
27
|
+
baselineOid?: string;
|
|
28
|
+
patchHash: string;
|
|
29
|
+
checks: { file: string; args: string[]; required: boolean; stage: "candidate" | "baseline"; result: BoundedProcessResult }[];
|
|
30
|
+
evaluation?: PatchEvaluation;
|
|
31
|
+
reason: string;
|
|
32
|
+
cleanupError?: string;
|
|
33
|
+
}
|
|
34
|
+
export function initialPatchValidation(baseline: WorktreeBaseline, baselineOid: string | undefined, patch: string): PatchValidation {
|
|
35
|
+
return { status: patch.trim() ? "blocked" : "no-patch", baselineFingerprint: fingerprintReviewWorktreeBaseline(baseline), ...(baselineOid ? { baselineOid } : {}),
|
|
36
|
+
patchHash: createHash("sha256").update(patch).digest("hex"), checks: [], reason: patch.trim() ? "Independent evaluation has not completed." : "The implementer produced no patch; this does not establish that no change is needed." };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface PatchValidationOptions {
|
|
40
|
+
cwd: string; baseline: WorktreeBaseline; expectedFingerprint: string; baselineOid: string; patch: string;
|
|
41
|
+
evaluation: PatchEvaluation; signal?: AbortSignal;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Reconstruct the exact reviewed baseline and retain evidence even if cleanup fails. */
|
|
45
|
+
export async function validateCandidatePatch(options: PatchValidationOptions): Promise<PatchValidation> {
|
|
46
|
+
const validation = initialPatchValidation(options.baseline, options.baselineOid, options.patch);
|
|
47
|
+
validation.evaluation = options.evaluation;
|
|
48
|
+
if (validation.baselineFingerprint !== options.expectedFingerprint) return { ...validation, status: "rejected", reason: "Stale reviewed baseline identity." };
|
|
49
|
+
if (!options.patch.trim()) return validation;
|
|
50
|
+
const worktrees = new WorktreeRegistry(options.cwd);
|
|
51
|
+
let result = validation;
|
|
52
|
+
try {
|
|
53
|
+
result = await evaluateCandidateInWorktrees(worktrees, options, validation);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throwIfAborted(options.signal);
|
|
56
|
+
result = { ...validation, status: "blocked", reason: unknownErrorMessage(error) };
|
|
57
|
+
} finally {
|
|
58
|
+
try {
|
|
59
|
+
await worktrees.removeAll();
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const cleanupError = unknownErrorMessage(error);
|
|
62
|
+
result = {
|
|
63
|
+
...result,
|
|
64
|
+
status: result.status === "verified" ? "blocked" : result.status,
|
|
65
|
+
cleanupError,
|
|
66
|
+
reason: `${result.reason}\nCleanup failed: ${cleanupError}`,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
throwIfAborted(options.signal);
|
|
70
|
+
}
|
|
71
|
+
return result;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function evaluateCandidateInWorktrees(
|
|
75
|
+
worktrees: WorktreeRegistry, options: PatchValidationOptions, validation: PatchValidation,
|
|
76
|
+
): Promise<PatchValidation> {
|
|
77
|
+
const candidate = await worktrees.add(options.signal, options.baseline);
|
|
78
|
+
if ("error" in candidate) return { ...validation, reason: candidate.error };
|
|
79
|
+
if (candidate.baselineOid !== options.baselineOid) return { ...validation, status: "rejected", reason: "Candidate was produced from a different baseline." };
|
|
80
|
+
const applied = await worktrees.applyPatch(candidate.path, options.patch, options.signal);
|
|
81
|
+
if (!applied.ok) return { ...validation, status: "rejected", reason: applied.error ?? applied.stderr };
|
|
82
|
+
const rejected = options.evaluation.outcome === "rejected" ? [options.evaluation.reason] : [];
|
|
83
|
+
const blocked = options.evaluation.outcome === "blocked" ? [options.evaluation.reason] : [];
|
|
84
|
+
if (!options.evaluation.checks.some((check) => check.required)) blocked.push("No required behavior check was selected.");
|
|
85
|
+
for (const check of options.evaluation.checks) {
|
|
86
|
+
const result = await runCheck(check, candidate.path, options.signal);
|
|
87
|
+
validation.checks.push({ ...check, stage: "candidate", result });
|
|
88
|
+
throwIfAborted(options.signal);
|
|
89
|
+
if (!result.ok) {
|
|
90
|
+
if (result.failure.kind === "exit") rejected.push(`${check.file}: ${result.failure.message}`);
|
|
91
|
+
else if (check.required) blocked.push(`${check.file}: ${result.failure.message}`);
|
|
92
|
+
}
|
|
93
|
+
if (check.regression) {
|
|
94
|
+
const original = await worktrees.add(options.signal, options.baseline);
|
|
95
|
+
if ("error" in original) { blocked.push(`Baseline worktree setup failed: ${original.error}`); continue; }
|
|
96
|
+
const testApplied = await worktrees.applyPatch(original.path, check.regression.baselinePatch, options.signal);
|
|
97
|
+
if (!testApplied.ok) { blocked.push(`Baseline regression setup failed: ${testApplied.error ?? testApplied.stderr}`); continue; }
|
|
98
|
+
const baselineResult = await runCheck(check, original.path, options.signal);
|
|
99
|
+
validation.checks.push({ ...check, stage: "baseline", result: baselineResult });
|
|
100
|
+
throwIfAborted(options.signal);
|
|
101
|
+
if (baselineResult.ok || baselineResult.failure.kind !== "exit" || !check.regression.expectedFailure.trim() ||
|
|
102
|
+
!(baselineResult.stdout + baselineResult.stderr).includes(check.regression.expectedFailure)) {
|
|
103
|
+
blocked.push(`${check.file}: baseline did not reproduce the expected failure: ${check.regression.expectedFailure}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Tests must not silently replace the candidate under evaluation.
|
|
108
|
+
const after = await worktrees.capturePatch(candidate.path, candidate.baselineOid, options.signal);
|
|
109
|
+
if ("error" in after) blocked.push(`Candidate capture failed: ${after.error}`);
|
|
110
|
+
else if (after.patch !== options.patch) blocked.push("Validation checks changed the candidate patch.");
|
|
111
|
+
if (rejected.length > 0) return { ...validation, status: "rejected", reason: [...rejected, ...blocked].join("\n") };
|
|
112
|
+
if (blocked.length > 0) return { ...validation, status: "blocked", reason: blocked.join("\n") };
|
|
113
|
+
return { ...validation, status: "verified", reason: "Independent evaluation and all required checks passed." };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function runCheck(check: PatchEvaluation["checks"][number], cwd: string, signal?: AbortSignal): Promise<BoundedProcessResult> {
|
|
117
|
+
return runBoundedProcess({ file: check.file, args: check.args, cwd, signal, timeoutMs: 120_000, maxBufferBytes: 1 << 20,
|
|
118
|
+
abortError: "Validation aborted", timeoutError: "Validation timed out", maxBufferError: "Validation output exceeded its limit",
|
|
119
|
+
exitError: (stderr, code) => stderr.trim() || `Validation exited with ${code}` });
|
|
120
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { throwIfAborted } from "../cancellation.ts";
|
|
3
|
+
import type { LoadedWorkflow } from "../types.ts";
|
|
4
|
+
import { unknownErrorMessage } from "../unknown-error.ts";
|
|
5
|
+
import type { WorktreeBaseline } from "../worktree.ts";
|
|
6
|
+
import { postInlineComments, resolveGitHubPrContext, type ExecLike, type InlineCommentStatus } from "./github-pr-comments.ts";
|
|
7
|
+
import { createReviewFixWorkflow } from "./review-fix-workflow.ts";
|
|
8
|
+
import { buildCommentHandoffPrompt } from "./review-handoff.ts";
|
|
9
|
+
import { isCommentableIssue, type ReviewIssue, type ReviewIssueSelection } from "./review-issues.ts";
|
|
10
|
+
import type { ReviewContext } from "./review-report.ts";
|
|
11
|
+
import { resolveReviewWorktreeBaseline, ReviewSnapshotUnavailableError } from "./review-snapshot.ts";
|
|
12
|
+
|
|
13
|
+
export type ReviewActionPi = Pick<ExtensionAPI, "sendUserMessage" | "exec">;
|
|
14
|
+
export interface ReviewActionContext {
|
|
15
|
+
readonly cwd: string;
|
|
16
|
+
readonly signal?: AbortSignal;
|
|
17
|
+
readonly ui: {
|
|
18
|
+
confirm(title: string, message: string): Promise<boolean>;
|
|
19
|
+
notify(message: string, type?: "info" | "warning" | "error"): void;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export type ReviewBaselineResolver = (
|
|
24
|
+
context: ReviewContext | undefined,
|
|
25
|
+
cwd: string,
|
|
26
|
+
signal?: AbortSignal,
|
|
27
|
+
) => Promise<WorktreeBaseline>;
|
|
28
|
+
|
|
29
|
+
export async function handleReviewViewerAction(
|
|
30
|
+
pi: ReviewActionPi,
|
|
31
|
+
ctx: ReviewActionContext,
|
|
32
|
+
action: ReviewIssueSelection | undefined,
|
|
33
|
+
issues: readonly ReviewIssue[],
|
|
34
|
+
context: ReviewContext | undefined,
|
|
35
|
+
resolveBaseline: ReviewBaselineResolver = resolveReviewWorktreeBaseline,
|
|
36
|
+
): Promise<LoadedWorkflow | undefined> {
|
|
37
|
+
if (!action || action.action === "close") return;
|
|
38
|
+
const selected = selectedIssues(issues, action.issueIds);
|
|
39
|
+
if (action.action === "fix") {
|
|
40
|
+
return await handleFixAction(ctx, selected, context, resolveBaseline);
|
|
41
|
+
}
|
|
42
|
+
await handleCommentAction(pi, ctx, selected, context, resolveBaseline);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function handleFixAction(
|
|
46
|
+
ctx: ReviewActionContext,
|
|
47
|
+
selected: readonly ReviewIssue[],
|
|
48
|
+
context: ReviewContext | undefined,
|
|
49
|
+
resolveBaseline: ReviewBaselineResolver,
|
|
50
|
+
): Promise<LoadedWorkflow | undefined> {
|
|
51
|
+
throwIfAborted(ctx.signal);
|
|
52
|
+
if (selected.length === 0) {
|
|
53
|
+
ctx.ui.notify("No selected findings to fix", "warning");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
ctx.ui.notify("Verifying the reviewed snapshot before generating patch previews", "info");
|
|
57
|
+
let baseline: WorktreeBaseline;
|
|
58
|
+
try {
|
|
59
|
+
baseline = await resolveBaseline(context, ctx.cwd, ctx.signal);
|
|
60
|
+
} catch (error) {
|
|
61
|
+
throwIfAborted(ctx.signal);
|
|
62
|
+
ctx.ui.notify(formatSnapshotError("Patch preview", error), "warning");
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
ctx.ui.notify(`Generating isolated patch previews for ${selected.length} selected finding(s)`, "info");
|
|
66
|
+
return createReviewFixWorkflow(selected, context, baseline);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function handleCommentAction(
|
|
70
|
+
pi: ReviewActionPi,
|
|
71
|
+
ctx: ReviewActionContext,
|
|
72
|
+
selected: readonly ReviewIssue[],
|
|
73
|
+
context: ReviewContext | undefined,
|
|
74
|
+
resolveBaseline: ReviewBaselineResolver,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
const commentable = selected.filter(isCommentableIssue);
|
|
77
|
+
if (commentable.length === 0) {
|
|
78
|
+
ctx.ui.notify("No selected findings have file and line information for inline PR comments", "warning");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
if (context?.diffTarget.kind !== "pull-request") {
|
|
82
|
+
ctx.ui.notify("PR comments are available only for findings captured from a verified pull-request diff.", "warning");
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
throwIfAborted(ctx.signal);
|
|
87
|
+
const confirmed = await ctx.ui.confirm(
|
|
88
|
+
"Post inline PR comments?",
|
|
89
|
+
`Post ${commentable.length} selected finding(s) to the upstream PR using GitHub CLI if available?`,
|
|
90
|
+
);
|
|
91
|
+
if (!confirmed) {
|
|
92
|
+
ctx.ui.notify("PR comment posting cancelled", "info");
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
ctx.ui.notify("Verifying the reviewed snapshot before posting PR comments", "info");
|
|
97
|
+
let baseline: WorktreeBaseline;
|
|
98
|
+
try {
|
|
99
|
+
baseline = await resolveBaseline(context, ctx.cwd, ctx.signal);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
throwIfAborted(ctx.signal);
|
|
102
|
+
ctx.ui.notify(formatSnapshotError("PR comments", error), "warning");
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const exec = toExecLike(pi);
|
|
107
|
+
const resolved = await resolveGitHubPrContext(exec, ctx.cwd, context, ctx.signal);
|
|
108
|
+
if (!resolved.ok) {
|
|
109
|
+
await queueCommentFallback(pi, ctx, commentable, context, baseline.ref, resolved.reason);
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (resolved.context.headSha !== baseline.ref) {
|
|
113
|
+
ctx.ui.notify("PR comments unavailable because the pull request head changed after snapshot verification.", "warning");
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const statuses = await postInlineComments(exec, ctx.cwd, resolved.context, commentable, ctx.signal);
|
|
118
|
+
const summary = summarizeStatuses(statuses);
|
|
119
|
+
ctx.ui.notify(`PR comments: ${summary.posted} posted, ${summary.skipped} skipped, ${summary.failed} failed`, summary.failed > 0 ? "warning" : "info");
|
|
120
|
+
if (summary.failed > 0) {
|
|
121
|
+
const failedIds = new Set(statuses.filter((status) => status.status === "failed").map((status) => status.issueId));
|
|
122
|
+
await queueCommentFallback(
|
|
123
|
+
pi,
|
|
124
|
+
ctx,
|
|
125
|
+
commentable.filter((issue) => failedIds.has(issue.id)),
|
|
126
|
+
context,
|
|
127
|
+
baseline.ref,
|
|
128
|
+
"Direct gh posting failed for one or more selected findings.",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
async function queueCommentFallback(
|
|
134
|
+
pi: ReviewActionPi,
|
|
135
|
+
ctx: ReviewActionContext,
|
|
136
|
+
issues: readonly ReviewIssue[],
|
|
137
|
+
context: ReviewContext | undefined,
|
|
138
|
+
verifiedHead: string,
|
|
139
|
+
reason: string,
|
|
140
|
+
): Promise<void> {
|
|
141
|
+
if (issues.length === 0) return;
|
|
142
|
+
const confirmed = await ctx.ui.confirm(
|
|
143
|
+
"Queue PR comment fallback?",
|
|
144
|
+
`${reason}\n\nQueue ${issues.length} finding(s) for the parent agent to post after rechecking the PR head?`,
|
|
145
|
+
);
|
|
146
|
+
throwIfAborted(ctx.signal);
|
|
147
|
+
if (!confirmed) {
|
|
148
|
+
ctx.ui.notify("PR comment fallback cancelled", "info");
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
pi.sendUserMessage(buildCommentHandoffPrompt(issues, context, verifiedHead, reason), { deliverAs: "followUp" });
|
|
152
|
+
ctx.ui.notify(`Queued PR comment request for ${issues.length} selected finding(s)`, "info");
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function toExecLike(pi: ReviewActionPi): ExecLike {
|
|
156
|
+
return async (command, args, options) => await pi.exec(command, args, options);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function selectedIssues(issues: readonly ReviewIssue[], issueIds: readonly string[]): ReviewIssue[] {
|
|
160
|
+
const selected = new Set(issueIds);
|
|
161
|
+
return issues.filter((issue) => selected.has(issue.id));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function summarizeStatuses(statuses: readonly InlineCommentStatus[]): { readonly posted: number; readonly skipped: number; readonly failed: number } {
|
|
165
|
+
let posted = 0;
|
|
166
|
+
let skipped = 0;
|
|
167
|
+
let failed = 0;
|
|
168
|
+
for (const status of statuses) {
|
|
169
|
+
if (status.status === "posted") posted++;
|
|
170
|
+
else if (status.status === "skipped") skipped++;
|
|
171
|
+
else failed++;
|
|
172
|
+
}
|
|
173
|
+
return { posted, skipped, failed };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function formatSnapshotError(action: "Patch preview" | "PR comments", error: unknown): string {
|
|
177
|
+
return error instanceof ReviewSnapshotUnavailableError
|
|
178
|
+
? `${action} unavailable because ${error.message}`
|
|
179
|
+
: `${action} unavailable: ${unknownErrorMessage(error)}`;
|
|
180
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { WorkflowUsageSnapshot } from "../usage.ts";
|
|
2
|
+
|
|
3
|
+
export type ReviewFixLease =
|
|
4
|
+
| { readonly ok: true; readonly release: () => void }
|
|
5
|
+
| { readonly ok: false; readonly reason: "busy" | "exhausted" };
|
|
6
|
+
|
|
7
|
+
/** Session-local budget ledger that serializes fix previews and records every finalized run. */
|
|
8
|
+
export class ReviewFixBudgetLedger {
|
|
9
|
+
private inFlight = false;
|
|
10
|
+
private remainingBudget: number | undefined;
|
|
11
|
+
|
|
12
|
+
constructor(budget: number | null | undefined, initialUsage: WorkflowUsageSnapshot | undefined) {
|
|
13
|
+
this.remainingBudget = remainingWorkflowBudget(budget, initialUsage);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
get remaining(): number | undefined {
|
|
17
|
+
return this.remainingBudget;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
acquire(): ReviewFixLease {
|
|
21
|
+
if (this.remainingBudget === 0) return { ok: false, reason: "exhausted" };
|
|
22
|
+
if (this.inFlight) return { ok: false, reason: "busy" };
|
|
23
|
+
this.inFlight = true;
|
|
24
|
+
let released = false;
|
|
25
|
+
return {
|
|
26
|
+
ok: true,
|
|
27
|
+
release: () => {
|
|
28
|
+
if (released) return;
|
|
29
|
+
released = true;
|
|
30
|
+
this.inFlight = false;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
record(usage: WorkflowUsageSnapshot): void {
|
|
36
|
+
this.remainingBudget = remainingWorkflowBudget(this.remainingBudget, usage);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function remainingWorkflowBudget(
|
|
41
|
+
budget: number | null | undefined,
|
|
42
|
+
usage: WorkflowUsageSnapshot | undefined,
|
|
43
|
+
): number | undefined {
|
|
44
|
+
return budget == null ? undefined : Math.max(0, budget - (usage?.totals.output ?? 0));
|
|
45
|
+
}
|