@diegopetrucci/pi-extensions 0.1.74 → 0.1.78
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/LICENSE +2 -0
- package/README.md +17 -12
- package/extensions/agent-workflow-audit/README.md +1 -1
- package/extensions/agent-workflow-audit/index.ts +1631 -1009
- package/extensions/agent-workflow-audit/package.json +1 -1
- package/extensions/annotate-git-diff/README.md +3 -1
- package/extensions/annotate-git-diff/clipboard.ts +109 -100
- package/extensions/annotate-git-diff/git.ts +846 -768
- package/extensions/annotate-git-diff/glimpseui.d.ts +87 -78
- package/extensions/annotate-git-diff/index.ts +712 -671
- package/extensions/annotate-git-diff/package.json +1 -1
- package/extensions/annotate-git-diff/prompt.ts +45 -45
- package/extensions/annotate-git-diff/quiet-glimpse.ts +161 -155
- package/extensions/annotate-git-diff/review-server.ts +337 -311
- package/extensions/annotate-git-diff/types.ts +132 -132
- package/extensions/annotate-git-diff/ui.ts +27 -27
- package/extensions/annotate-git-diff/watch.ts +74 -74
- package/extensions/annotate-git-diff/web/app.js +1947 -1884
- package/extensions/annotate-git-diff/web/index.html +5 -6
- package/extensions/annotate-last-message/README.md +1 -1
- package/extensions/annotate-last-message/glimpseui.d.ts +87 -78
- package/extensions/annotate-last-message/index.ts +210 -206
- package/extensions/annotate-last-message/package.json +1 -1
- package/extensions/annotate-last-message/prompt.ts +87 -66
- package/extensions/annotate-last-message/quiet-glimpse.ts +161 -155
- package/extensions/annotate-last-message/session.ts +94 -89
- package/extensions/annotate-last-message/theme.ts +199 -193
- package/extensions/annotate-last-message/types.ts +23 -23
- package/extensions/annotate-last-message/ui.ts +27 -25
- package/extensions/annotate-last-message/web/app.js +4 -7
- package/extensions/annotate-last-message/web/index.html +3 -3
- package/extensions/annotate-last-message/web/md-renderer.js +1 -1
- package/extensions/brrr/README.md +1 -1
- package/extensions/brrr/index.ts +213 -191
- package/extensions/brrr/package.json +1 -1
- package/extensions/claude-fast/README.md +5 -79
- package/extensions/claude-fast/index.ts +31 -289
- package/extensions/claude-fast/package.json +2 -3
- package/extensions/code-reviewer/README.md +6 -6
- package/extensions/code-reviewer/index.ts +1825 -1373
- package/extensions/code-reviewer/package.json +1 -1
- package/extensions/confirm-destructive/README.md +1 -1
- package/extensions/confirm-destructive/index.ts +50 -52
- package/extensions/confirm-destructive/package.json +1 -1
- package/extensions/context-cap/README.md +9 -9
- package/extensions/context-cap/index.ts +313 -305
- package/extensions/context-cap/package.json +2 -2
- package/extensions/context-inspector/index.ts +1211 -1064
- package/extensions/context-inspector/package.json +1 -1
- package/extensions/contrarian/README.md +4 -3
- package/extensions/contrarian/index.ts +1863 -1737
- package/extensions/contrarian/package.json +1 -1
- package/extensions/dirty-repo-guard/index.ts +37 -41
- package/extensions/dirty-repo-guard/package.json +1 -1
- package/extensions/dynamic-context-pruning/README.md +11 -43
- package/extensions/dynamic-context-pruning/index.ts +2131 -2105
- package/extensions/dynamic-context-pruning/package.json +1 -1
- package/extensions/fast/README.md +52 -14
- package/extensions/fast/index.ts +513 -317
- package/extensions/fast/package.json +1 -1
- package/extensions/git-footer/README.md +1 -1
- package/extensions/git-footer/index.ts +458 -411
- package/extensions/git-footer/package.json +2 -3
- package/extensions/gnosis/index.ts +180 -130
- package/extensions/gnosis/package.json +1 -1
- package/extensions/inline-bash/index.ts +87 -103
- package/extensions/inline-bash/package.json +1 -1
- package/extensions/librarian/README.md +1 -1
- package/extensions/librarian/index.ts +1877 -1419
- package/extensions/librarian/package.json +1 -1
- package/extensions/minimal-footer/README.md +5 -5
- package/extensions/minimal-footer/index.ts +987 -1012
- package/extensions/minimal-footer/minimal-footer.example.json +1 -1
- package/extensions/minimal-footer/openai-usage.ts +195 -190
- package/extensions/minimal-footer/package.json +1 -1
- package/extensions/notify/index.ts +222 -230
- package/extensions/notify/package.json +1 -1
- package/extensions/openai-fast/README.md +5 -73
- package/extensions/openai-fast/index.ts +32 -272
- package/extensions/openai-fast/package.json +2 -3
- package/extensions/oracle/README.md +4 -3
- package/extensions/oracle/index.ts +1740 -1627
- package/extensions/oracle/package.json +1 -1
- package/extensions/permission-gate/README.md +3 -0
- package/extensions/permission-gate/index.ts +785 -612
- package/extensions/permission-gate/package.json +1 -1
- package/extensions/permission-gate/powershell-safety.ts +840 -793
- package/extensions/project-mcp-json/.pi-fleet-tested-version +1 -0
- package/extensions/project-mcp-json/README.md +199 -0
- package/extensions/project-mcp-json/config.ts +564 -0
- package/extensions/project-mcp-json/index.ts +119 -0
- package/extensions/project-mcp-json/package.json +38 -0
- package/extensions/quiet-tools/.pi-fleet-tested-version +1 -1
- package/extensions/quiet-tools/README.md +30 -19
- package/extensions/quiet-tools/index.ts +376 -291
- package/extensions/quiet-tools/package.json +2 -2
- package/extensions/review/README.md +2 -2
- package/extensions/review/index.ts +1745 -1872
- package/extensions/review/package.json +1 -1
- package/extensions/todo/index.ts +280 -280
- package/extensions/todo/package.json +1 -1
- package/extensions/triage-comments/README.md +6 -4
- package/extensions/triage-comments/index.ts +2620 -1888
- package/extensions/triage-comments/package.json +1 -1
- package/package.json +9 -6
- package/assets/social-preview.png +0 -0
- package/extensions/claude-fast/claude-fast.example.json +0 -4
- package/extensions/openai-fast/openai-fast.example.json +0 -4
|
@@ -1,16 +1,26 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
3
|
+
import * as os from "node:os";
|
|
2
4
|
import * as path from "node:path";
|
|
3
|
-
|
|
4
|
-
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
AgentSession,
|
|
9
|
+
ExecResult,
|
|
10
|
+
ExtensionAPI,
|
|
11
|
+
ExtensionCommandContext,
|
|
12
|
+
ExtensionFactory,
|
|
13
|
+
} from "@earendil-works/pi-coding-agent";
|
|
5
14
|
import type { Usage } from "@earendil-works/pi-ai";
|
|
6
15
|
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
16
|
+
DefaultResourceLoader,
|
|
17
|
+
SessionManager,
|
|
18
|
+
SettingsManager,
|
|
19
|
+
createAgentSession,
|
|
20
|
+
getAgentDir,
|
|
21
|
+
getMarkdownTheme,
|
|
13
22
|
} from "@earendil-works/pi-coding-agent";
|
|
23
|
+
import * as piCodingAgent from "@earendil-works/pi-coding-agent";
|
|
14
24
|
import { Container, Markdown, Spacer, Text } from "@earendil-works/pi-tui";
|
|
15
25
|
import { Type } from "typebox";
|
|
16
26
|
|
|
@@ -21,7 +31,7 @@ const MAX_RUN_MS = 8 * 60 * 1000;
|
|
|
21
31
|
const DEFAULT_BASH_TIMEOUT_SECONDS = 30;
|
|
22
32
|
const COLLAPSED_PREVIEW_LINES = 18;
|
|
23
33
|
const GH_COMMAND_TIMEOUT_MS = 30_000;
|
|
24
|
-
const GH_PR_METADATA_FIELDS =
|
|
34
|
+
const GH_PR_METADATA_FIELDS = "number,title,url,headRefName,headRefOid,baseRefName,baseRefOid";
|
|
25
35
|
const GH_REVIEW_THREADS_GRAPHQL_QUERY = `
|
|
26
36
|
query TriageCommentsReviewThreads($owner: String!, $name: String!, $number: Int!, $after: String) {
|
|
27
37
|
repository(owner: $owner, name: $name) {
|
|
@@ -48,2225 +58,2947 @@ query TriageCommentsReviewThreads($owner: String!, $name: String!, $number: Int!
|
|
|
48
58
|
}`;
|
|
49
59
|
const COMMENT_DISPLAY_BODY_LIMIT = 1200;
|
|
50
60
|
const TRIAGE_COMMAND_USAGE =
|
|
51
|
-
|
|
61
|
+
"Usage: /triage-comments [paste | pr [<PR URL or number>] | <PR URL or number>]\nInteractive UI mode lets you paste feedback or fetch PR comments, optionally hide resolved/outdated inline review comments, then confirm all displayed comments or choose a subset such as 1,3-5. PR mode without a target first tries to detect an open PR for the current non-main branch.";
|
|
52
62
|
const IMPLEMENTATION_NOTE =
|
|
53
|
-
|
|
63
|
+
"Do not implement changes from this triage automatically; ask the parent/user which option to take before implementation.";
|
|
64
|
+
|
|
65
|
+
const SUBAGENT_MCP_TOOLS = [
|
|
66
|
+
"mcp__*",
|
|
67
|
+
"tool_search",
|
|
68
|
+
"list_mcp_resources",
|
|
69
|
+
"list_mcp_resource_templates",
|
|
70
|
+
"read_mcp_resource",
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
// Derive project-trust for the isolated subagent session from the host tool context.
|
|
74
|
+
// Fails closed: returns false if the host has not trusted the project, or if the subagent
|
|
75
|
+
// runs in a different directory, preventing an untrusted project's .pi/mcp.json from
|
|
76
|
+
// being loaded by the MCP extension.
|
|
77
|
+
function resolveSubagentProjectTrusted(
|
|
78
|
+
ctx: { cwd: string; isProjectTrusted(): boolean },
|
|
79
|
+
subagentCwd: string,
|
|
80
|
+
): boolean {
|
|
81
|
+
// Feature-detected for older Pi hosts that may lack isProjectTrusted.
|
|
82
|
+
const hostTrusted =
|
|
83
|
+
typeof (ctx as { isProjectTrusted?: unknown }).isProjectTrusted === "function"
|
|
84
|
+
? ctx.isProjectTrusted()
|
|
85
|
+
: false;
|
|
86
|
+
if (!hostTrusted) return false;
|
|
87
|
+
try {
|
|
88
|
+
return realpathSync(subagentCwd) === realpathSync(ctx.cwd);
|
|
89
|
+
} catch {
|
|
90
|
+
return false; // fail closed if realpath resolution fails
|
|
91
|
+
}
|
|
92
|
+
}
|
|
54
93
|
|
|
94
|
+
function isSubagentMcpTool(toolName: string): boolean {
|
|
95
|
+
return (
|
|
96
|
+
toolName.startsWith("mcp__") ||
|
|
97
|
+
toolName === "tool_search" ||
|
|
98
|
+
toolName === "list_mcp_resources" ||
|
|
99
|
+
toolName === "list_mcp_resource_templates" ||
|
|
100
|
+
toolName === "read_mcp_resource"
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Returns true when v is a semver string >=1.0.4 (ignores prerelease; missing/garbage -> false). */
|
|
105
|
+
function isMcpCompatibleVersion(v: string | undefined): boolean {
|
|
106
|
+
if (!v) return false;
|
|
107
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(v);
|
|
108
|
+
if (!m) return false;
|
|
109
|
+
const major = Number(m[1]),
|
|
110
|
+
minor = Number(m[2]),
|
|
111
|
+
patch = Number(m[3]);
|
|
112
|
+
if (major !== 1) return major > 1;
|
|
113
|
+
if (minor !== 0) return minor > 0;
|
|
114
|
+
return patch >= 4;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Gated on VERSION >=1.0.4: on older hosts mcp__* allowlist entries are exact-name
|
|
118
|
+
// matches that resolve nothing, so spawning MCP servers provides no benefit.
|
|
119
|
+
function createMcpSubagentFactories(): ExtensionFactory[] {
|
|
120
|
+
const ns = piCodingAgent as Record<string, unknown>;
|
|
121
|
+
if (!isMcpCompatibleVersion(ns["VERSION"] as string | undefined)) return [];
|
|
122
|
+
const factories: ExtensionFactory[] = [];
|
|
123
|
+
const mcp = ns["createMcpExtension"];
|
|
124
|
+
const toolSearch = ns["createToolSearchExtension"];
|
|
125
|
+
if (typeof mcp === "function") factories.push((mcp as () => ExtensionFactory)());
|
|
126
|
+
if (typeof toolSearch === "function") factories.push((toolSearch as () => ExtensionFactory)());
|
|
127
|
+
return factories;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// Emit session_shutdown before dispose so MCP closes server connections (no leaked child processes).
|
|
131
|
+
async function disposeSubagentSession(session: AgentSession | undefined): Promise<void> {
|
|
132
|
+
if (!session) return;
|
|
133
|
+
try {
|
|
134
|
+
const runner = (
|
|
135
|
+
session as unknown as {
|
|
136
|
+
extensionRunner?: {
|
|
137
|
+
hasHandlers(n: string): boolean;
|
|
138
|
+
emit(e: { type: string; reason: string }): Promise<unknown>;
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
).extensionRunner;
|
|
142
|
+
if (runner?.hasHandlers("session_shutdown")) {
|
|
143
|
+
await runner.emit({ type: "session_shutdown", reason: "quit" });
|
|
144
|
+
}
|
|
145
|
+
} catch {
|
|
146
|
+
// Shutdown errors must not prevent dispose.
|
|
147
|
+
}
|
|
148
|
+
session.dispose();
|
|
149
|
+
}
|
|
55
150
|
const READ_ONLY_TOOL_NAMES = new Set(["read", "grep", "find", "ls", "bash"]);
|
|
56
151
|
const READ_ONLY_BASH_COMMANDS = new Set(["gh", "git", "pwd"]);
|
|
57
152
|
const READ_ONLY_GIT_SUBCOMMANDS = new Set([
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
153
|
+
"blame",
|
|
154
|
+
"cat-file",
|
|
155
|
+
"describe",
|
|
156
|
+
"diff",
|
|
157
|
+
"for-each-ref",
|
|
158
|
+
"log",
|
|
159
|
+
"ls-files",
|
|
160
|
+
"ls-tree",
|
|
161
|
+
"merge-base",
|
|
162
|
+
"name-rev",
|
|
163
|
+
"remote",
|
|
164
|
+
"rev-parse",
|
|
165
|
+
"shortlog",
|
|
166
|
+
"show",
|
|
167
|
+
"show-ref",
|
|
168
|
+
"status",
|
|
169
|
+
"whatchanged",
|
|
75
170
|
]);
|
|
76
171
|
const SAFE_GIT_BRANCH_FLAGS = new Set([
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
172
|
+
"-a",
|
|
173
|
+
"--all",
|
|
174
|
+
"-r",
|
|
175
|
+
"--remotes",
|
|
176
|
+
"-v",
|
|
177
|
+
"-vv",
|
|
178
|
+
"--show-current",
|
|
179
|
+
"--list",
|
|
180
|
+
"--contains",
|
|
181
|
+
"--merged",
|
|
182
|
+
"--no-merged",
|
|
88
183
|
]);
|
|
89
184
|
const SAFE_GIT_GLOBAL_FLAGS = new Set(["--no-pager", "--no-optional-locks"]);
|
|
90
|
-
const
|
|
185
|
+
const SAFE_GIT_CONFIG_OVERRIDES = [
|
|
186
|
+
"core.pager=cat",
|
|
187
|
+
"core.fsmonitor=false",
|
|
188
|
+
"diff.external=",
|
|
189
|
+
"log.showSignature=false",
|
|
190
|
+
];
|
|
191
|
+
const GIT_SUBCOMMAND_SAFE_FLAGS: Partial<Record<string, string[]>> = {
|
|
192
|
+
blame: ["--no-textconv"],
|
|
193
|
+
diff: ["--no-ext-diff", "--no-textconv"],
|
|
194
|
+
log: ["--no-ext-diff", "--no-textconv"],
|
|
195
|
+
show: ["--no-ext-diff", "--no-textconv"],
|
|
196
|
+
whatchanged: ["--no-ext-diff", "--no-textconv"],
|
|
197
|
+
};
|
|
198
|
+
const GIT_OPTIONS_REQUIRING_LOCAL_FILE = [
|
|
199
|
+
{
|
|
200
|
+
flag: "--pathspec-from-file",
|
|
201
|
+
blockedPrefixes: ["--pathspec"],
|
|
202
|
+
reason:
|
|
203
|
+
"Triage bash blocks git --pathspec-from-file because it can read local files outside built-in path guards.",
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
flag: "--ignore-revs-file",
|
|
207
|
+
blockedPrefixes: ["--ignore-rev"],
|
|
208
|
+
reason:
|
|
209
|
+
"Triage bash blocks git --ignore-revs-file because it can read local files outside built-in path guards.",
|
|
210
|
+
},
|
|
211
|
+
] as const;
|
|
212
|
+
const GIT_SUBCOMMAND_OPTIONS_REQUIRING_LOCAL_FILE: Partial<
|
|
213
|
+
Record<string, Array<{ reason: string; matches: (token: string) => boolean }>>
|
|
214
|
+
> = {
|
|
215
|
+
blame: [
|
|
216
|
+
{
|
|
217
|
+
reason:
|
|
218
|
+
"Triage bash blocks git blame --contents because it can read local files outside built-in path guards.",
|
|
219
|
+
matches: (token) => {
|
|
220
|
+
if (!token.startsWith("--")) return false;
|
|
221
|
+
const option = token.split("=", 1)[0]?.toLowerCase() ?? "";
|
|
222
|
+
return option === "--contents" || option.startsWith("--con");
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
{
|
|
226
|
+
reason:
|
|
227
|
+
"Triage bash blocks git blame -S because it can read local revs files outside built-in path guards.",
|
|
228
|
+
matches: (token) => token === "-S" || (token.startsWith("-S") && token.length > 2),
|
|
229
|
+
},
|
|
230
|
+
],
|
|
231
|
+
"ls-files": [
|
|
232
|
+
{
|
|
233
|
+
reason:
|
|
234
|
+
"Triage bash blocks git ls-files -X/--exclude-from because it can read local files outside built-in path guards.",
|
|
235
|
+
matches: (token) => token === "-X" || (token.startsWith("-X") && token.length > 2),
|
|
236
|
+
},
|
|
237
|
+
{
|
|
238
|
+
reason:
|
|
239
|
+
"Triage bash blocks git ls-files -X/--exclude-from because it can read local files outside built-in path guards.",
|
|
240
|
+
matches: (token) => {
|
|
241
|
+
if (!token.startsWith("--")) return false;
|
|
242
|
+
const option = token.split("=", 1)[0]?.toLowerCase() ?? "";
|
|
243
|
+
return option === "--exclude-from" || option.startsWith("--exclude-f");
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
],
|
|
247
|
+
};
|
|
248
|
+
const GH_GLOBAL_OPTIONS_WITH_VALUE = new Set([
|
|
249
|
+
"--repo",
|
|
250
|
+
"-R",
|
|
251
|
+
"--hostname",
|
|
252
|
+
"--jq",
|
|
253
|
+
"-q",
|
|
254
|
+
"--template",
|
|
255
|
+
]);
|
|
91
256
|
const MUTATING_GH_API_METHODS = new Set(["POST", "PUT", "PATCH", "DELETE"]);
|
|
92
257
|
|
|
93
258
|
type TriageStatus = "running" | "done" | "error" | "aborted";
|
|
94
259
|
|
|
95
260
|
type CreateAgentSessionOptions = NonNullable<Parameters<typeof createAgentSession>[0]>;
|
|
96
261
|
|
|
97
|
-
function getModelRuntimeOption(ctx: {
|
|
98
|
-
|
|
99
|
-
|
|
262
|
+
function getModelRuntimeOption(ctx: {
|
|
263
|
+
modelRegistry?: unknown;
|
|
264
|
+
}): Pick<CreateAgentSessionOptions, "modelRuntime"> {
|
|
265
|
+
const modelRuntime = (
|
|
266
|
+
ctx.modelRegistry as { runtime?: CreateAgentSessionOptions["modelRuntime"] } | undefined
|
|
267
|
+
)?.runtime;
|
|
268
|
+
return modelRuntime ? { modelRuntime } : {};
|
|
100
269
|
}
|
|
101
270
|
|
|
102
271
|
type ToolCall = {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
272
|
+
id: string;
|
|
273
|
+
name: string;
|
|
274
|
+
args: unknown;
|
|
275
|
+
startedAt: number;
|
|
276
|
+
endedAt?: number;
|
|
277
|
+
isError?: boolean;
|
|
109
278
|
};
|
|
110
279
|
|
|
111
280
|
type TriageDetails = {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
281
|
+
status: TriageStatus;
|
|
282
|
+
cwd: string;
|
|
283
|
+
commentCount: number;
|
|
284
|
+
turns: number;
|
|
285
|
+
toolCalls: ToolCall[];
|
|
286
|
+
startedAt: number;
|
|
287
|
+
endedAt?: number;
|
|
288
|
+
error?: string;
|
|
120
289
|
};
|
|
121
290
|
|
|
122
291
|
type NormalizedComment = {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
292
|
+
index: number;
|
|
293
|
+
id?: string;
|
|
294
|
+
body: string;
|
|
295
|
+
path?: string;
|
|
296
|
+
line?: number;
|
|
297
|
+
startLine?: number;
|
|
298
|
+
side?: string;
|
|
299
|
+
diffHunk?: string;
|
|
300
|
+
author?: string;
|
|
301
|
+
url?: string;
|
|
302
|
+
createdAt?: string;
|
|
303
|
+
context?: string;
|
|
304
|
+
metadata?: Record<string, unknown>;
|
|
136
305
|
};
|
|
137
306
|
|
|
138
307
|
type NormalizedInput = {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
308
|
+
comments: NormalizedComment[];
|
|
309
|
+
pr?: Record<string, unknown>;
|
|
310
|
+
base?: Record<string, unknown>;
|
|
311
|
+
diff?: string;
|
|
312
|
+
context?: string;
|
|
144
313
|
};
|
|
145
314
|
|
|
146
315
|
const CommentSchema = Type.Object({
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
316
|
+
id: Type.Optional(
|
|
317
|
+
Type.String({ description: "Stable comment identifier from GitHub or the caller." }),
|
|
318
|
+
),
|
|
319
|
+
body: Type.String({ description: "The review comment text to triage." }),
|
|
320
|
+
path: Type.Optional(
|
|
321
|
+
Type.String({
|
|
322
|
+
description: "Repository-relative file path the comment refers to, when known.",
|
|
323
|
+
}),
|
|
324
|
+
),
|
|
325
|
+
line: Type.Optional(
|
|
326
|
+
Type.Number({ description: "1-indexed line number the comment refers to, when known." }),
|
|
327
|
+
),
|
|
328
|
+
startLine: Type.Optional(
|
|
329
|
+
Type.Number({ description: "Start line for a multi-line comment, when known." }),
|
|
330
|
+
),
|
|
331
|
+
side: Type.Optional(
|
|
332
|
+
Type.String({
|
|
333
|
+
description: "Diff side or thread side, for example RIGHT, LEFT, base, or head.",
|
|
334
|
+
}),
|
|
335
|
+
),
|
|
336
|
+
diffHunk: Type.Optional(
|
|
337
|
+
Type.String({ description: "GitHub diff hunk attached to the comment, when available." }),
|
|
338
|
+
),
|
|
339
|
+
author: Type.Optional(Type.String({ description: "Review comment author, when available." })),
|
|
340
|
+
url: Type.Optional(
|
|
341
|
+
Type.String({ description: "Permalink to the review comment, when available." }),
|
|
342
|
+
),
|
|
343
|
+
createdAt: Type.Optional(
|
|
344
|
+
Type.String({ description: "Comment creation timestamp, when available." }),
|
|
345
|
+
),
|
|
346
|
+
context: Type.Optional(
|
|
347
|
+
Type.String({ description: "Any extra per-comment context supplied by the caller." }),
|
|
348
|
+
),
|
|
349
|
+
metadata: Type.Optional(
|
|
350
|
+
Type.Record(
|
|
351
|
+
Type.String(),
|
|
352
|
+
Type.Unknown({ description: "Structured per-comment metadata from the caller." }),
|
|
353
|
+
),
|
|
354
|
+
),
|
|
161
355
|
});
|
|
162
356
|
|
|
163
357
|
const TriageCommentsParams = Type.Object({
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
358
|
+
comments: Type.Array(Type.Union([Type.String(), CommentSchema]), {
|
|
359
|
+
description:
|
|
360
|
+
"Selected PR review comments to classify. Prefer objects with body, path, line, diffHunk, author, and url when available; plain strings are accepted for quick manual calls.",
|
|
361
|
+
minItems: 1,
|
|
362
|
+
maxItems: MAX_COMMENTS,
|
|
363
|
+
}),
|
|
364
|
+
pr: Type.Optional(
|
|
365
|
+
Type.Object({
|
|
366
|
+
number: Type.Optional(Type.Number({ description: "Pull request number." })),
|
|
367
|
+
title: Type.Optional(Type.String({ description: "Pull request title." })),
|
|
368
|
+
url: Type.Optional(Type.String({ description: "Pull request URL." })),
|
|
369
|
+
repository: Type.Optional(
|
|
370
|
+
Type.String({ description: "Repository in owner/name form, when known." }),
|
|
371
|
+
),
|
|
372
|
+
headRef: Type.Optional(Type.String({ description: "PR head ref name, when known." })),
|
|
373
|
+
headSha: Type.Optional(Type.String({ description: "PR head SHA, when known." })),
|
|
374
|
+
baseRef: Type.Optional(Type.String({ description: "PR base ref name, when known." })),
|
|
375
|
+
baseSha: Type.Optional(Type.String({ description: "PR base SHA, when known." })),
|
|
376
|
+
}),
|
|
377
|
+
),
|
|
378
|
+
base: Type.Optional(
|
|
379
|
+
Type.Object({
|
|
380
|
+
branch: Type.Optional(Type.String({ description: "Base branch name, when known." })),
|
|
381
|
+
sha: Type.Optional(Type.String({ description: "Base SHA, when known." })),
|
|
382
|
+
mergeBase: Type.Optional(Type.String({ description: "Merge-base SHA, when known." })),
|
|
383
|
+
}),
|
|
384
|
+
),
|
|
385
|
+
diff: Type.Optional(
|
|
386
|
+
Type.String({
|
|
387
|
+
description:
|
|
388
|
+
"Optional PR diff, selected hunks, or command output that helps locate comments. The triage agent still verifies against the local checkout when possible.",
|
|
389
|
+
maxLength: 200000,
|
|
390
|
+
}),
|
|
391
|
+
),
|
|
392
|
+
context: Type.Optional(
|
|
393
|
+
Type.String({
|
|
394
|
+
description:
|
|
395
|
+
"Optional caller notes, constraints, or already-collected read-only context for this triage run.",
|
|
396
|
+
maxLength: 50000,
|
|
397
|
+
}),
|
|
398
|
+
),
|
|
200
399
|
});
|
|
201
400
|
|
|
202
401
|
function asRecord(value: unknown): Record<string, unknown> | undefined {
|
|
203
|
-
|
|
402
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
403
|
+
? (value as Record<string, unknown>)
|
|
404
|
+
: undefined;
|
|
204
405
|
}
|
|
205
406
|
|
|
206
407
|
function asTrimmedString(value: unknown): string | undefined {
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
408
|
+
if (typeof value !== "string") return undefined;
|
|
409
|
+
const trimmed = value.trim();
|
|
410
|
+
return trimmed || undefined;
|
|
210
411
|
}
|
|
211
412
|
|
|
212
413
|
function asFiniteNumber(value: unknown): number | undefined {
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
414
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
415
|
+
if (typeof value === "string" && value.trim()) {
|
|
416
|
+
const parsed = Number(value);
|
|
417
|
+
if (Number.isFinite(parsed)) return parsed;
|
|
418
|
+
}
|
|
419
|
+
return undefined;
|
|
219
420
|
}
|
|
220
421
|
|
|
221
422
|
function asBoolean(value: unknown): boolean | undefined {
|
|
222
|
-
|
|
423
|
+
return typeof value === "boolean" ? value : undefined;
|
|
223
424
|
}
|
|
224
425
|
|
|
225
426
|
function normalizeComment(raw: unknown, index: number): NormalizedComment | undefined {
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
427
|
+
if (typeof raw === "string") {
|
|
428
|
+
const body = raw.trim();
|
|
429
|
+
return body ? { index, body } : undefined;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const record = asRecord(raw);
|
|
433
|
+
if (!record) return undefined;
|
|
434
|
+
const body = asTrimmedString(record.body ?? record.text ?? record.comment);
|
|
435
|
+
if (!body) return undefined;
|
|
436
|
+
|
|
437
|
+
return {
|
|
438
|
+
index,
|
|
439
|
+
id: asStableIdPart(record.id, record.databaseId, record.nodeId),
|
|
440
|
+
body,
|
|
441
|
+
path: asTrimmedString(record.path ?? record.file ?? record.filePath),
|
|
442
|
+
line: asFiniteNumber(record.line ?? record.position),
|
|
443
|
+
startLine: asFiniteNumber(record.startLine ?? record.start_line),
|
|
444
|
+
side: asTrimmedString(record.side),
|
|
445
|
+
diffHunk: asTrimmedString(record.diffHunk ?? record.diff_hunk ?? record.hunk),
|
|
446
|
+
author: githubLogin(record.author) ?? githubLogin(record.user) ?? asTrimmedString(record.login),
|
|
447
|
+
url: asTrimmedString(record.url ?? record.htmlUrl ?? record.html_url),
|
|
448
|
+
createdAt: asTrimmedString(record.createdAt ?? record.created_at),
|
|
449
|
+
context: asTrimmedString(record.context ?? record.extraContext),
|
|
450
|
+
metadata: asRecord(record.metadata ?? record.meta),
|
|
451
|
+
};
|
|
251
452
|
}
|
|
252
453
|
|
|
253
454
|
function normalizeInput(params: unknown): NormalizedInput {
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
455
|
+
const record = asRecord(params) ?? {};
|
|
456
|
+
const rawComments = Array.isArray(record.comments) ? record.comments : [];
|
|
457
|
+
const comments = rawComments
|
|
458
|
+
.map((comment, index) => normalizeComment(comment, index + 1))
|
|
459
|
+
.filter((comment): comment is NormalizedComment => Boolean(comment));
|
|
460
|
+
|
|
461
|
+
if (comments.length === 0) {
|
|
462
|
+
throw new Error(
|
|
463
|
+
"Invalid parameters: expected comments to include at least one non-empty comment body.",
|
|
464
|
+
);
|
|
465
|
+
}
|
|
263
466
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
467
|
+
return {
|
|
468
|
+
comments,
|
|
469
|
+
pr: asRecord(record.pr),
|
|
470
|
+
base: asRecord(record.base),
|
|
471
|
+
diff: asTrimmedString(record.diff ?? record.diffContext),
|
|
472
|
+
context: asTrimmedString(record.context ?? record.extraContext),
|
|
473
|
+
};
|
|
271
474
|
}
|
|
272
475
|
|
|
273
476
|
function prepareArguments(args: unknown): any {
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
477
|
+
const record = asRecord(args);
|
|
478
|
+
if (!record) return args;
|
|
479
|
+
const prepared: Record<string, unknown> = { ...record };
|
|
480
|
+
if (!("comments" in prepared) && "selectedComments" in prepared)
|
|
481
|
+
prepared.comments = prepared.selectedComments;
|
|
482
|
+
if (!("comments" in prepared) && "comment" in prepared) prepared.comments = [prepared.comment];
|
|
483
|
+
if (!("pr" in prepared) && "prContext" in prepared) prepared.pr = prepared.prContext;
|
|
484
|
+
if (!("base" in prepared) && "baseContext" in prepared) prepared.base = prepared.baseContext;
|
|
485
|
+
if (!("diff" in prepared) && "diffContext" in prepared) prepared.diff = prepared.diffContext;
|
|
486
|
+
return prepared;
|
|
283
487
|
}
|
|
284
488
|
|
|
285
489
|
function shorten(text: string, max: number): string {
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
490
|
+
const oneLine = text.replace(/\s+/g, " ").trim();
|
|
491
|
+
if (oneLine.length <= max) return oneLine;
|
|
492
|
+
return `${oneLine.slice(0, Math.max(1, max - 1))}…`;
|
|
289
493
|
}
|
|
290
494
|
|
|
291
495
|
function formatJsonForPrompt(value: unknown): string {
|
|
292
|
-
|
|
293
|
-
|
|
496
|
+
if (value === undefined) return "(not provided)";
|
|
497
|
+
return JSON.stringify(value, null, 2);
|
|
294
498
|
}
|
|
295
499
|
|
|
296
500
|
function formatCommentForPrompt(comment: NormalizedComment): string {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
function buildSystemPrompt(options: {
|
|
319
|
-
|
|
501
|
+
const fields = [
|
|
502
|
+
`index: ${comment.index}`,
|
|
503
|
+
comment.id ? `id: ${comment.id}` : undefined,
|
|
504
|
+
comment.author ? `author: ${comment.author}` : undefined,
|
|
505
|
+
comment.path ? `path: ${comment.path}` : undefined,
|
|
506
|
+
comment.startLine ? `startLine: ${comment.startLine}` : undefined,
|
|
507
|
+
comment.line ? `line: ${comment.line}` : undefined,
|
|
508
|
+
comment.side ? `side: ${comment.side}` : undefined,
|
|
509
|
+
comment.url ? `url: ${comment.url}` : undefined,
|
|
510
|
+
comment.createdAt ? `createdAt: ${comment.createdAt}` : undefined,
|
|
511
|
+
]
|
|
512
|
+
.filter(Boolean)
|
|
513
|
+
.join("\n");
|
|
514
|
+
|
|
515
|
+
const sections = [`Comment ${comment.index}:`, fields, `body:\n${comment.body}`];
|
|
516
|
+
if (comment.diffHunk) sections.push(`diffHunk:\n${comment.diffHunk}`);
|
|
517
|
+
if (comment.metadata) sections.push(`metadata:\n${formatJsonForPrompt(comment.metadata)}`);
|
|
518
|
+
if (comment.context) sections.push(`extraContext:\n${comment.context}`);
|
|
519
|
+
return sections.filter(Boolean).join("\n");
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function buildSystemPrompt(options: {
|
|
523
|
+
cwd: string;
|
|
524
|
+
maxTurns: number;
|
|
525
|
+
maxRunSeconds: number;
|
|
526
|
+
}): string {
|
|
527
|
+
return `You are Triage Comments, an isolated read-only code-review investigation agent running inside the triage-comments package. Your job is to evaluate selected PR review comments against the local checkout and local git context.\n\nWorking directory: ${options.cwd}\nTurn budget: ${options.maxTurns} turns total, including your final answer.\nWall-clock budget: ${options.maxRunSeconds} seconds.\n\nAvailable tools are intended to be read-only: read, grep, find, ls, and bash. Use the built-in read, grep, find, and ls tools for local file inspection. Use bash only for a single read-only git, gh, or pwd invocation, such as git status/diff/show/log/blame, gh pr view/diff/list/status/checks, gh api GET calls, or pwd. A runtime guard blocks write/edit tools, shell pipelines/control operators, local filesystem utility commands, mutating git, and mutating gh/GitHub API calls. Configured MCP tools are also available and run without confirmation; use them only to gather evidence or context, not for state-changing actions unless the task explicitly requires it. MCP access requires host Pi >=1.0.4; on older hosts MCP tools are absent with no error.\n\nNon-negotiable constraints:\n- Never implement changes. Never edit, write, move, delete, format, commit, checkout, reset, clean, push, publish, or mutate GitHub.\n- Keep all inspection local to the checkout unless a read-only gh call is necessary for supplied PR metadata.\n- Treat the supplied comments as claims to verify, not as facts. Cite file paths, line ranges, git diff/status/log output, or supplied diff hunks as evidence.\n- If evidence is missing, stale, contradictory, or the local checkout does not match the PR context, say so and classify accordingly.\n- Distinguish objective correctness issues from style/preferences.\n- For valid or partially valid comments, propose one or more handling options, but do not choose implementation without parent/user confirmation.\n\nVerdicts must be one of: valid, invalid, partially valid, subjective, needs clarification.\n\nOutput format, exact order:\n## Summary\n1-3 concise sentences summarizing the triage.\n\n## Per-comment triage\nFor each selected comment, use:\n### Comment <index or id> — <verdict>\n- **What the reviewer asked:** concise paraphrase.\n- **Evidence:** path/line or command-output citations. If evidence is insufficient, state exactly what is missing.\n- **Reasoning:** why the evidence supports the verdict.\n- **Suggested response:** a short review-thread reply the parent/user can post or adapt.\n- **Handling options:** one or more options for valid/partially valid comments; for invalid/subjective/unclear comments, give the appropriate response/clarification path.\n\n## Read-only checks performed\nList the files/commands/probes used, or \`- (none)\`.\n\n## Before implementation\nState explicitly: \"Do not implement changes from this triage automatically; ask the parent/user which option to take before implementation.\"`;
|
|
320
528
|
}
|
|
321
529
|
|
|
322
530
|
function buildUserPrompt(input: NormalizedInput, cwd: string): string {
|
|
323
|
-
|
|
531
|
+
return `Task: triage the selected PR review comments against the local checkout.\n\nLocal checkout: ${cwd}\n\nSelected comments:\n${input.comments.map(formatCommentForPrompt).join("\n\n---\n\n")}\n\nPR context:\n${formatJsonForPrompt(input.pr)}\n\nBase context:\n${formatJsonForPrompt(input.base)}\n\nOptional diff context:\n${input.diff ?? "(not provided)"}\n\nOptional caller context:\n${input.context ?? "(not provided)"}\n\nInspect only as much as needed to classify each comment with evidence. Do not implement anything. Respond using the required triage format.`;
|
|
324
532
|
}
|
|
325
533
|
|
|
326
534
|
function ensureImplementationNote(text: string): string {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
535
|
+
const trimmed = text.trim();
|
|
536
|
+
if (!trimmed) return trimmed;
|
|
537
|
+
if (trimmed.toLowerCase().includes(IMPLEMENTATION_NOTE.toLowerCase())) return trimmed;
|
|
538
|
+
return `${trimmed}\n\n---\n${IMPLEMENTATION_NOTE}`;
|
|
331
539
|
}
|
|
332
540
|
|
|
333
541
|
function createEmptyUsage(): Usage {
|
|
334
|
-
|
|
542
|
+
return {
|
|
543
|
+
input: 0,
|
|
544
|
+
output: 0,
|
|
545
|
+
cacheRead: 0,
|
|
546
|
+
cacheWrite: 0,
|
|
547
|
+
totalTokens: 0,
|
|
548
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
|
549
|
+
};
|
|
335
550
|
}
|
|
336
551
|
|
|
337
552
|
function addUsage(total: Usage, usage: Usage): void {
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
553
|
+
total.input += usage.input || 0;
|
|
554
|
+
total.output += usage.output || 0;
|
|
555
|
+
total.cacheRead += usage.cacheRead || 0;
|
|
556
|
+
total.cacheWrite += usage.cacheWrite || 0;
|
|
557
|
+
if (usage.cacheWrite1h !== undefined)
|
|
558
|
+
total.cacheWrite1h = (total.cacheWrite1h ?? 0) + usage.cacheWrite1h;
|
|
559
|
+
if (usage.reasoning !== undefined) total.reasoning = (total.reasoning ?? 0) + usage.reasoning;
|
|
560
|
+
total.totalTokens += usage.totalTokens || 0;
|
|
561
|
+
total.cost.input += usage.cost?.input || 0;
|
|
562
|
+
total.cost.output += usage.cost?.output || 0;
|
|
563
|
+
total.cost.cacheRead += usage.cost?.cacheRead || 0;
|
|
564
|
+
total.cost.cacheWrite += usage.cost?.cacheWrite || 0;
|
|
565
|
+
total.cost.total += usage.cost?.total || 0;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
type AssistantLikeMessage = {
|
|
569
|
+
role?: string;
|
|
570
|
+
content?: unknown;
|
|
571
|
+
stopReason?: unknown;
|
|
572
|
+
errorMessage?: unknown;
|
|
573
|
+
usage?: unknown;
|
|
574
|
+
};
|
|
353
575
|
|
|
354
576
|
function addAssistantMessageUsage(total: Usage, message: unknown): void {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
577
|
+
const assistant = message as AssistantLikeMessage;
|
|
578
|
+
if (assistant?.role !== "assistant" || !assistant.usage || typeof assistant.usage !== "object")
|
|
579
|
+
return;
|
|
580
|
+
addUsage(total, assistant.usage as Usage);
|
|
358
581
|
}
|
|
359
582
|
|
|
360
583
|
function aggregateAssistantUsage(messages: unknown[]): Usage {
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
584
|
+
const total = createEmptyUsage();
|
|
585
|
+
for (const message of messages) addAssistantMessageUsage(total, message);
|
|
586
|
+
return total;
|
|
364
587
|
}
|
|
365
588
|
|
|
366
589
|
function addSessionEventUsage(total: Usage, event: unknown): void {
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
590
|
+
const sessionEvent = event as { type?: string; message?: unknown; result?: { usage?: Usage } };
|
|
591
|
+
if (sessionEvent?.type === "message_end")
|
|
592
|
+
return void addAssistantMessageUsage(total, sessionEvent.message);
|
|
593
|
+
if (sessionEvent?.type === "compaction_end" && sessionEvent.result?.usage)
|
|
594
|
+
addUsage(total, sessionEvent.result.usage);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function inspectFinalAssistant(
|
|
598
|
+
messages: unknown[],
|
|
599
|
+
):
|
|
600
|
+
| { ok: true; answer: string; stopReason?: string }
|
|
601
|
+
| { ok: false; reason: string; stopReason?: string; errorMessage?: string } {
|
|
602
|
+
for (let i = messages.length - 1; i >= 0; i -= 1) {
|
|
603
|
+
const message = messages[i] as AssistantLikeMessage;
|
|
604
|
+
if (message?.role !== "assistant") continue;
|
|
605
|
+
const stopReason = typeof message.stopReason === "string" ? message.stopReason.trim() : "";
|
|
606
|
+
const errorMessage =
|
|
607
|
+
typeof message.errorMessage === "string" ? message.errorMessage.trim() : "";
|
|
608
|
+
const answer = Array.isArray(message.content)
|
|
609
|
+
? message.content
|
|
610
|
+
.map((part) =>
|
|
611
|
+
part &&
|
|
612
|
+
typeof part === "object" &&
|
|
613
|
+
(part as { type?: string }).type === "text" &&
|
|
614
|
+
typeof (part as { text?: unknown }).text === "string"
|
|
615
|
+
? (part as { text: string }).text
|
|
616
|
+
: "",
|
|
617
|
+
)
|
|
618
|
+
.join("")
|
|
619
|
+
.trim()
|
|
620
|
+
: "";
|
|
621
|
+
if (stopReason === "error")
|
|
622
|
+
return {
|
|
623
|
+
ok: false,
|
|
624
|
+
reason: `triage_comments subagent error: ${errorMessage || "provider/model error"}`,
|
|
625
|
+
stopReason,
|
|
626
|
+
errorMessage,
|
|
627
|
+
};
|
|
628
|
+
if (stopReason === "aborted")
|
|
629
|
+
return {
|
|
630
|
+
ok: false,
|
|
631
|
+
reason: "triage_comments subagent aborted before producing a usable answer",
|
|
632
|
+
stopReason,
|
|
633
|
+
};
|
|
634
|
+
if (answer) return { ok: true, answer, stopReason: stopReason || undefined };
|
|
635
|
+
return {
|
|
636
|
+
ok: false,
|
|
637
|
+
reason: stopReason
|
|
638
|
+
? `triage_comments subagent produced no final assistant text (stopReason: ${stopReason})`
|
|
639
|
+
: "triage_comments subagent produced no final assistant text",
|
|
640
|
+
stopReason: stopReason || undefined,
|
|
641
|
+
errorMessage: errorMessage || undefined,
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
return { ok: false, reason: "triage_comments subagent produced no assistant message" };
|
|
399
645
|
}
|
|
400
646
|
|
|
401
|
-
function
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
647
|
+
function classifyRunFailure(
|
|
648
|
+
error: unknown,
|
|
649
|
+
aborted: boolean,
|
|
650
|
+
): { status: "aborted" | "error"; message: string; error?: string } {
|
|
651
|
+
const message = aborted ? "Aborted" : error instanceof Error ? error.message : String(error);
|
|
652
|
+
return { status: aborted ? "aborted" : "error", message, error: aborted ? undefined : message };
|
|
405
653
|
}
|
|
406
654
|
|
|
407
|
-
function
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
655
|
+
function isInside(parent: string, child: string): boolean {
|
|
656
|
+
const parentResolved = path.resolve(parent);
|
|
657
|
+
const childResolved = path.resolve(child);
|
|
658
|
+
return (
|
|
659
|
+
childResolved === parentResolved || childResolved.startsWith(`${parentResolved}${path.sep}`)
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
const UNICODE_SPACES_PATTERN = /[\u00A0\u2000-\u200A\u202F\u205F\u3000]/g;
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Mirrors normalizeWindowsShellPath from dist/utils/paths.js. No-op on
|
|
667
|
+
* non-win32 paths.
|
|
668
|
+
*/
|
|
669
|
+
function normalizeWindowsShellPathLocal(filePath: string): string {
|
|
670
|
+
if (!filePath.startsWith("/") || filePath.startsWith("//") || filePath.includes("\\"))
|
|
671
|
+
return filePath;
|
|
672
|
+
const match = filePath.match(/^\/(?:mnt\/|cygdrive\/)?([a-z])(?:\/(.*))?$/i);
|
|
673
|
+
if (!match) return filePath;
|
|
674
|
+
const suffix = match[2]?.replaceAll("/", "\\");
|
|
675
|
+
return `${match[1].toUpperCase()}:\\${suffix ?? ""}`;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* Mirrors Pi's normalizePath (dist/utils/paths.js). On win32, Git Bash / WSL
|
|
680
|
+
* drive-path normalisation always runs; only tilde expansion is gated by
|
|
681
|
+
* expandTilde.
|
|
682
|
+
*/
|
|
683
|
+
function normalizePath(
|
|
684
|
+
input: string,
|
|
685
|
+
opts: { normalizeUnicodeSpaces?: boolean; stripAtPrefix?: boolean; expandTilde?: boolean } = {},
|
|
686
|
+
): string {
|
|
687
|
+
const { normalizeUnicodeSpaces = false, stripAtPrefix = false, expandTilde = true } = opts;
|
|
688
|
+
let p = input;
|
|
689
|
+
if (normalizeUnicodeSpaces) p = p.replace(UNICODE_SPACES_PATTERN, " ");
|
|
690
|
+
if (stripAtPrefix && p.startsWith("@")) p = p.slice(1);
|
|
691
|
+
if (process.platform === "win32") p = normalizeWindowsShellPathLocal(p);
|
|
692
|
+
if (expandTilde) {
|
|
693
|
+
const home = os.homedir();
|
|
694
|
+
if (p === "~") p = home;
|
|
695
|
+
else if (p.startsWith("~/") || (process.platform === "win32" && p.startsWith("~\\")))
|
|
696
|
+
p = path.join(home, p.slice(2));
|
|
697
|
+
}
|
|
698
|
+
if (/^file:\/\//.test(p)) p = fileURLToPath(p);
|
|
699
|
+
return p;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
/**
|
|
703
|
+
* Mirrors Pi's resolveToCwd (dist/core/tools/path-utils.js). Exposed via
|
|
704
|
+
* __test__ for parity tests; not imported by other extensions.
|
|
705
|
+
*/
|
|
706
|
+
function resolveToCwd(rawPath: string, cwd: string): string {
|
|
707
|
+
const normalized = normalizePath(rawPath, { normalizeUnicodeSpaces: true, stripAtPrefix: true });
|
|
708
|
+
const base = normalizePath(cwd);
|
|
709
|
+
return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(base, normalized);
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
/** Mirrors tryMacOSScreenshotPath from dist/core/tools/path-utils.js */
|
|
713
|
+
function tryMacOSScreenshotPathVariant(filePath: string): string {
|
|
714
|
+
return filePath.replace(/ (AM|PM)\./gi, " $1.");
|
|
715
|
+
}
|
|
716
|
+
/** Mirrors tryNFDVariant from dist/core/tools/path-utils.js */
|
|
717
|
+
function tryNFDPathVariant(filePath: string): string {
|
|
718
|
+
return filePath.normalize("NFD");
|
|
719
|
+
}
|
|
720
|
+
/** Mirrors tryCurlyQuoteVariant from dist/core/tools/path-utils.js */
|
|
721
|
+
function tryCurlyQuotePathVariant(filePath: string): string {
|
|
722
|
+
return filePath.replace(/'/g, "’");
|
|
723
|
+
}
|
|
724
|
+
/**
|
|
725
|
+
* Returns all read path candidates including Pi's resolveReadPathAsync fallback
|
|
726
|
+
* variants. Used only for the read tool guard; grep/find/ls use resolveToCwd only.
|
|
727
|
+
*/
|
|
728
|
+
function getReadPathCandidates(resolved: string): string[] {
|
|
729
|
+
const nfd = tryNFDPathVariant(resolved);
|
|
730
|
+
return [
|
|
731
|
+
resolved,
|
|
732
|
+
tryMacOSScreenshotPathVariant(resolved),
|
|
733
|
+
nfd,
|
|
734
|
+
tryCurlyQuotePathVariant(resolved),
|
|
735
|
+
tryCurlyQuotePathVariant(nfd),
|
|
736
|
+
];
|
|
411
737
|
}
|
|
412
738
|
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
739
|
+
function resolveToolPath(cwd: string, rawPath: string | undefined): string {
|
|
740
|
+
const input = rawPath?.trim() || ".";
|
|
741
|
+
const normalized = input.startsWith("@") ? input.slice(1) : input;
|
|
742
|
+
return path.isAbsolute(normalized) ? path.resolve(normalized) : path.resolve(cwd, normalized);
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
async function assertToolPathInsideCwd(
|
|
746
|
+
cwd: string,
|
|
747
|
+
rawPath: unknown,
|
|
748
|
+
toolName: string,
|
|
749
|
+
): Promise<string | undefined> {
|
|
750
|
+
if (rawPath !== undefined && typeof rawPath !== "string")
|
|
751
|
+
return `${toolName} path must be a string.`;
|
|
752
|
+
// A path is allowed if it resolves inside EITHER the realpath'd root (root) OR
|
|
753
|
+
// the lexical cwd (lexicalCwd). This handles missing files in symlinked workspaces
|
|
754
|
+
// where realpath fails and the lexical fallback starts with the alias prefix.
|
|
755
|
+
const root = await fs.realpath(cwd).catch(() => path.resolve(cwd));
|
|
756
|
+
const lexicalCwd = path.resolve(cwd);
|
|
757
|
+
const resolved = resolveToolPath(cwd, rawPath);
|
|
758
|
+
const realPath = await fs.realpath(resolved).catch(() => resolved);
|
|
759
|
+
if (!isInside(root, realPath) && !isInside(lexicalCwd, realPath))
|
|
760
|
+
return `${toolName} is limited to the local checkout: ${realPath}`;
|
|
761
|
+
// Pi-effective path check (monotonic: block if either resolution is outside).
|
|
762
|
+
// For the read tool, also check Pi's resolveReadPathAsync fallback variants so
|
|
763
|
+
// a missing file whose variant resolves outside is not silently allowed.
|
|
764
|
+
try {
|
|
765
|
+
const piResolved = resolveToCwd(typeof rawPath === "string" ? rawPath : ".", cwd);
|
|
766
|
+
const candidates = toolName === "read" ? getReadPathCandidates(piResolved) : [piResolved];
|
|
767
|
+
// Deliberate policy: all candidates are checked regardless of existence (not
|
|
768
|
+
// Pi's existence-ordered selection) so the guard cannot be raced by creating
|
|
769
|
+
// a variant between the hook and the read. Trade-off: a read is blocked when
|
|
770
|
+
// an escaping variant symlink sits next to a safe primary file.
|
|
771
|
+
for (const candidate of candidates) {
|
|
772
|
+
const candidateRealPath = await fs.realpath(candidate).catch(() => candidate);
|
|
773
|
+
if (!isInside(root, candidateRealPath) && !isInside(lexicalCwd, candidateRealPath))
|
|
774
|
+
return `${toolName} is limited to the local checkout: ${candidateRealPath}`;
|
|
775
|
+
}
|
|
776
|
+
} catch {
|
|
777
|
+
return `${toolName} path could not be resolved safely.`;
|
|
778
|
+
}
|
|
779
|
+
return undefined;
|
|
420
780
|
}
|
|
421
781
|
|
|
422
782
|
function getUnsafePathPatternReason(value: unknown, label: string): string | undefined {
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
783
|
+
if (value === undefined) return undefined;
|
|
784
|
+
if (typeof value !== "string") return `${label} must be a string.`;
|
|
785
|
+
if (path.isAbsolute(value)) return `${label} must be relative to the local checkout.`;
|
|
786
|
+
if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(value))
|
|
787
|
+
return `${label} must not traverse outside the local checkout.`;
|
|
788
|
+
return undefined;
|
|
428
789
|
}
|
|
429
790
|
|
|
430
791
|
function stripTokenQuotes(token: string): string {
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
792
|
+
if (
|
|
793
|
+
(token.startsWith("'") && token.endsWith("'")) ||
|
|
794
|
+
(token.startsWith('"') && token.endsWith('"'))
|
|
795
|
+
) {
|
|
796
|
+
return token.slice(1, -1);
|
|
797
|
+
}
|
|
798
|
+
return token;
|
|
435
799
|
}
|
|
436
800
|
|
|
437
801
|
type ShellTokenizeResult = {
|
|
438
|
-
|
|
439
|
-
|
|
802
|
+
tokens: string[];
|
|
803
|
+
reason?: string;
|
|
440
804
|
};
|
|
441
805
|
|
|
442
806
|
function tokenizeShellCommand(command: string): ShellTokenizeResult {
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
807
|
+
const tokens: string[] = [];
|
|
808
|
+
let token = "";
|
|
809
|
+
let tokenStarted = false;
|
|
810
|
+
let inSingleQuote = false;
|
|
811
|
+
let inDoubleQuote = false;
|
|
812
|
+
|
|
813
|
+
const pushToken = () => {
|
|
814
|
+
if (!tokenStarted) return;
|
|
815
|
+
tokens.push(token);
|
|
816
|
+
token = "";
|
|
817
|
+
tokenStarted = false;
|
|
818
|
+
};
|
|
819
|
+
|
|
820
|
+
for (let index = 0; index < command.length; index += 1) {
|
|
821
|
+
const char = command[index];
|
|
822
|
+
const next = command[index + 1] ?? "";
|
|
823
|
+
|
|
824
|
+
if (inSingleQuote) {
|
|
825
|
+
if (char === "'") {
|
|
826
|
+
inSingleQuote = false;
|
|
827
|
+
continue;
|
|
828
|
+
}
|
|
829
|
+
token += char;
|
|
830
|
+
tokenStarted = true;
|
|
831
|
+
continue;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
if (char === "\\") {
|
|
835
|
+
return {
|
|
836
|
+
tokens,
|
|
837
|
+
reason:
|
|
838
|
+
"Triage bash blocks shell escape sequences; pass plain git, gh, or pwd arguments without backslashes.",
|
|
839
|
+
};
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
if (inDoubleQuote) {
|
|
843
|
+
if (char === '"') {
|
|
844
|
+
inDoubleQuote = false;
|
|
845
|
+
continue;
|
|
846
|
+
}
|
|
847
|
+
if (char === "`" || char === "$") {
|
|
848
|
+
return {
|
|
849
|
+
tokens,
|
|
850
|
+
reason:
|
|
851
|
+
char === "`" || next === "("
|
|
852
|
+
? "Triage bash blocks command substitution."
|
|
853
|
+
: "Triage bash blocks shell expansion in double quotes.",
|
|
854
|
+
};
|
|
855
|
+
}
|
|
856
|
+
token += char;
|
|
857
|
+
tokenStarted = true;
|
|
858
|
+
continue;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
if (char === "\n" || char === "\r") {
|
|
862
|
+
return {
|
|
863
|
+
tokens,
|
|
864
|
+
reason:
|
|
865
|
+
"Triage bash allows one git, gh, or pwd invocation only; pipelines, shell control operators, and multiple commands are blocked.",
|
|
866
|
+
};
|
|
867
|
+
}
|
|
868
|
+
if (/\s/.test(char)) {
|
|
869
|
+
pushToken();
|
|
870
|
+
continue;
|
|
871
|
+
}
|
|
872
|
+
if (char === "'") {
|
|
873
|
+
inSingleQuote = true;
|
|
874
|
+
tokenStarted = true;
|
|
875
|
+
continue;
|
|
876
|
+
}
|
|
877
|
+
if (char === '"') {
|
|
878
|
+
inDoubleQuote = true;
|
|
879
|
+
tokenStarted = true;
|
|
880
|
+
continue;
|
|
881
|
+
}
|
|
882
|
+
if (char === "`") {
|
|
883
|
+
return { tokens, reason: "Triage bash blocks command substitution." };
|
|
884
|
+
}
|
|
885
|
+
if (char === "$") {
|
|
886
|
+
return {
|
|
887
|
+
tokens,
|
|
888
|
+
reason:
|
|
889
|
+
next === "'" || next === '"'
|
|
890
|
+
? "Triage bash blocks ANSI-C and localized shell quotes."
|
|
891
|
+
: next === "("
|
|
892
|
+
? "Triage bash blocks command substitution."
|
|
893
|
+
: "Triage bash blocks shell expansion.",
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
if (/[;&|()]/.test(char)) {
|
|
897
|
+
return {
|
|
898
|
+
tokens,
|
|
899
|
+
reason:
|
|
900
|
+
"Triage bash allows one git, gh, or pwd invocation only; pipelines, shell control operators, and multiple commands are blocked.",
|
|
901
|
+
};
|
|
902
|
+
}
|
|
903
|
+
if (char === ">" || char === "<") {
|
|
904
|
+
return {
|
|
905
|
+
tokens,
|
|
906
|
+
reason: "Triage bash blocks shell redirection to keep inspection read-only.",
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
if (char === "{" || char === "}") {
|
|
910
|
+
return { tokens, reason: "Triage bash blocks shell brace expansion." };
|
|
911
|
+
}
|
|
912
|
+
if (char === "*" || char === "?" || char === "[" || char === "]") {
|
|
913
|
+
return { tokens, reason: "Triage bash blocks shell glob expansion." };
|
|
914
|
+
}
|
|
915
|
+
if (char === "~" && !tokenStarted) {
|
|
916
|
+
return { tokens, reason: "Triage bash blocks shell home-directory expansion." };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
token += char;
|
|
920
|
+
tokenStarted = true;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
if (inSingleQuote || inDoubleQuote)
|
|
924
|
+
return { tokens, reason: "Triage bash blocks unterminated or malformed shell quoting." };
|
|
925
|
+
pushToken();
|
|
926
|
+
return { tokens };
|
|
558
927
|
}
|
|
559
928
|
|
|
560
929
|
function tokenizeSegment(segment: string): string[] {
|
|
561
|
-
|
|
930
|
+
return tokenizeShellCommand(segment).tokens;
|
|
562
931
|
}
|
|
563
932
|
|
|
564
933
|
function getExecutableName(token: string): string {
|
|
565
|
-
|
|
934
|
+
return path.basename(token).toLowerCase();
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function shellQuoteToken(token: string): string {
|
|
938
|
+
return `'${token.replace(/'/g, `'"'"'`)}'`;
|
|
566
939
|
}
|
|
567
940
|
|
|
568
941
|
function getShellSyntaxReason(command: string): string | undefined {
|
|
569
|
-
|
|
942
|
+
return tokenizeShellCommand(command).reason;
|
|
570
943
|
}
|
|
571
944
|
|
|
572
945
|
function getUnsafeGitTokenPathReason(token: string): string | undefined {
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
946
|
+
const valueParts = [token];
|
|
947
|
+
const equalsIndex = token.indexOf("=");
|
|
948
|
+
if (equalsIndex >= 0 && equalsIndex < token.length - 1)
|
|
949
|
+
valueParts.push(token.slice(equalsIndex + 1));
|
|
950
|
+
for (const value of valueParts) {
|
|
951
|
+
const normalized = value.startsWith("@") ? value.slice(1) : value;
|
|
952
|
+
if (normalized === "~" || normalized.startsWith("~/") || /^~[^/\\]*/.test(normalized)) {
|
|
953
|
+
return "Triage bash git arguments must not use home-directory paths; use built-in read/grep/find/ls for local files.";
|
|
954
|
+
}
|
|
955
|
+
if (path.isAbsolute(normalized)) {
|
|
956
|
+
return "Triage bash git arguments must not use absolute filesystem paths; use built-in read/grep/find/ls for local files.";
|
|
957
|
+
}
|
|
958
|
+
if (/(^|[/\\])\.\.(?:[/\\]|$)/.test(normalized)) {
|
|
959
|
+
return "Triage bash git arguments must not traverse outside the local checkout.";
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
return undefined;
|
|
589
963
|
}
|
|
590
964
|
|
|
591
965
|
function getBlockedPwdReason(tokens: string[]): string | undefined {
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
}
|
|
597
|
-
|
|
598
|
-
function getGitSubcommand(tokens: string[]): {
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
966
|
+
const args = tokens.slice(1);
|
|
967
|
+
if (args.length === 0) return undefined;
|
|
968
|
+
if (args.length === 1 && (args[0] === "-P" || args[0] === "-L")) return undefined;
|
|
969
|
+
return "Triage bash allows pwd only with no arguments or -P/-L.";
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
function getGitSubcommand(tokens: string[]): {
|
|
973
|
+
subcommand?: string;
|
|
974
|
+
index: number;
|
|
975
|
+
reason?: string;
|
|
976
|
+
} {
|
|
977
|
+
let index = 1;
|
|
978
|
+
while (index < tokens.length) {
|
|
979
|
+
const token = tokens[index];
|
|
980
|
+
if (!token.startsWith("-")) break;
|
|
981
|
+
if (token === "--git-dir" || token.startsWith("--git-dir=")) {
|
|
982
|
+
return {
|
|
983
|
+
index,
|
|
984
|
+
reason:
|
|
985
|
+
"Triage bash blocks git --git-dir because it can inspect or mutate outside the checkout.",
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
if (token === "--work-tree" || token.startsWith("--work-tree=")) {
|
|
989
|
+
return {
|
|
990
|
+
index,
|
|
991
|
+
reason:
|
|
992
|
+
"Triage bash blocks git --work-tree because it can inspect or mutate outside the checkout.",
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
if (token === "-C") {
|
|
996
|
+
const gitCwd = tokens[index + 1];
|
|
997
|
+
if (gitCwd !== ".") {
|
|
998
|
+
return {
|
|
999
|
+
index,
|
|
1000
|
+
reason: "Triage bash only allows git -C .; run git from the local checkout.",
|
|
1001
|
+
};
|
|
1002
|
+
}
|
|
1003
|
+
index += 2;
|
|
1004
|
+
continue;
|
|
1005
|
+
}
|
|
1006
|
+
if (token.startsWith("-C")) {
|
|
1007
|
+
if (token !== "-C.") {
|
|
1008
|
+
return {
|
|
1009
|
+
index,
|
|
1010
|
+
reason: "Triage bash only allows git -C .; run git from the local checkout.",
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
index += 1;
|
|
1014
|
+
continue;
|
|
1015
|
+
}
|
|
1016
|
+
if (token === "--paginate" || token === "-p") {
|
|
1017
|
+
return {
|
|
1018
|
+
index,
|
|
1019
|
+
reason: "Triage bash blocks git pagination because pagers can execute local utilities.",
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
if (SAFE_GIT_GLOBAL_FLAGS.has(token)) {
|
|
1023
|
+
index += 1;
|
|
1024
|
+
continue;
|
|
1025
|
+
}
|
|
1026
|
+
return {
|
|
1027
|
+
index,
|
|
1028
|
+
reason: `Triage bash blocks git global option ${token}; use direct read-only git inspection commands from the checkout.`,
|
|
1029
|
+
};
|
|
1030
|
+
}
|
|
1031
|
+
return { subcommand: tokens[index]?.toLowerCase(), index };
|
|
634
1032
|
}
|
|
635
1033
|
|
|
636
1034
|
function isSafeGitBranchCommand(tokens: string[], subcommandIndex: number): boolean {
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
1035
|
+
const args = tokens.slice(subcommandIndex + 1);
|
|
1036
|
+
if (args.length === 0) return true;
|
|
1037
|
+
if (
|
|
1038
|
+
args.some(
|
|
1039
|
+
(arg) =>
|
|
1040
|
+
/^-(?:d|D|m|M|c|C|f)$/.test(arg) ||
|
|
1041
|
+
/^--(?:delete|move|copy|force|set-upstream-to|unset-upstream)$/.test(arg),
|
|
1042
|
+
)
|
|
1043
|
+
) {
|
|
1044
|
+
return false;
|
|
1045
|
+
}
|
|
1046
|
+
return args.some(
|
|
1047
|
+
(arg) =>
|
|
1048
|
+
SAFE_GIT_BRANCH_FLAGS.has(arg) ||
|
|
1049
|
+
arg.startsWith("--contains=") ||
|
|
1050
|
+
arg.startsWith("--merged=") ||
|
|
1051
|
+
arg.startsWith("--no-merged="),
|
|
1052
|
+
);
|
|
643
1053
|
}
|
|
644
1054
|
|
|
645
1055
|
function isSafeGitRemoteCommand(tokens: string[], subcommandIndex: number): boolean {
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
}
|
|
652
|
-
|
|
653
|
-
function
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
1056
|
+
const args = tokens.slice(subcommandIndex + 1);
|
|
1057
|
+
if (args.length === 0) return true;
|
|
1058
|
+
if (args.length === 1 && (args[0] === "-v" || args[0] === "--verbose")) return true;
|
|
1059
|
+
const remoteAction = args.find((arg) => !arg.startsWith("-"));
|
|
1060
|
+
return remoteAction === "show" || remoteAction === "get-url";
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
function isBlockedGitLocalFileOption(
|
|
1064
|
+
token: string,
|
|
1065
|
+
flag: string,
|
|
1066
|
+
blockedPrefixes: readonly string[],
|
|
1067
|
+
): boolean {
|
|
1068
|
+
if (!token.startsWith("--")) return false;
|
|
1069
|
+
const option = token.split("=", 1)[0]?.toLowerCase() ?? "";
|
|
1070
|
+
if (option === flag) return true;
|
|
1071
|
+
return blockedPrefixes.some((prefix) => option.startsWith(prefix));
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function getUnsafeGitArgumentReason(
|
|
1075
|
+
tokens: string[],
|
|
1076
|
+
parsed: { subcommand?: string; index: number },
|
|
1077
|
+
): string | undefined {
|
|
1078
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
1079
|
+
const token = tokens[index];
|
|
1080
|
+
const lowerToken = token.toLowerCase();
|
|
1081
|
+
if (token === "--no-index" || token.startsWith("--no-index=")) {
|
|
1082
|
+
return "Triage bash blocks git --no-index because it can inspect arbitrary filesystem paths outside the checkout.";
|
|
1083
|
+
}
|
|
1084
|
+
if (token === "--paginate" || token.startsWith("--paginate=")) {
|
|
1085
|
+
return "Triage bash blocks git --paginate because pagers can execute local utilities.";
|
|
1086
|
+
}
|
|
1087
|
+
if (token === "--open-files-in-pager" || token.startsWith("--open-files-in-pager=")) {
|
|
1088
|
+
return "Triage bash blocks git --open-files-in-pager because it can execute local utilities.";
|
|
1089
|
+
}
|
|
1090
|
+
if (token === "-O" || token.startsWith("-O")) {
|
|
1091
|
+
return "Triage bash blocks git -O because it can execute local utilities for some subcommands.";
|
|
1092
|
+
}
|
|
1093
|
+
if (token === "--ext-diff" || token.startsWith("--ext-diff=")) {
|
|
1094
|
+
return "Triage bash blocks git --ext-diff because it can execute external diff helpers.";
|
|
1095
|
+
}
|
|
1096
|
+
if (token === "--textconv" || token.startsWith("--textconv=")) {
|
|
1097
|
+
return "Triage bash blocks git --textconv because it can execute external text conversion filters.";
|
|
1098
|
+
}
|
|
1099
|
+
if (token === "--filters" || token.startsWith("--filters=")) {
|
|
1100
|
+
return "Triage bash blocks git --filters because it can execute clean/smudge filters from local config.";
|
|
1101
|
+
}
|
|
1102
|
+
if (token === "--show-signature" || lowerToken.startsWith("--show-signat")) {
|
|
1103
|
+
return "Triage bash blocks git signature verification flags because they can execute configured GPG helpers.";
|
|
1104
|
+
}
|
|
1105
|
+
if (token === "--help" || token.startsWith("--help=")) {
|
|
1106
|
+
return "Triage bash blocks git help output because it can execute configured help, man, or browser helpers.";
|
|
1107
|
+
}
|
|
1108
|
+
if (token.includes("%G") || /%\((?:[^)]*signature[^)]*)\)/i.test(token)) {
|
|
1109
|
+
return "Triage bash blocks git signature format atoms because they can execute configured GPG helpers.";
|
|
1110
|
+
}
|
|
1111
|
+
if (token === "--output" || token.startsWith("--output=")) {
|
|
1112
|
+
return "Triage bash blocks git output-writing flags.";
|
|
1113
|
+
}
|
|
1114
|
+
for (const { flag, blockedPrefixes, reason } of GIT_OPTIONS_REQUIRING_LOCAL_FILE) {
|
|
1115
|
+
if (isBlockedGitLocalFileOption(token, flag, blockedPrefixes)) return reason;
|
|
1116
|
+
}
|
|
1117
|
+
if (index > parsed.index) {
|
|
1118
|
+
for (const option of GIT_SUBCOMMAND_OPTIONS_REQUIRING_LOCAL_FILE[parsed.subcommand ?? ""] ??
|
|
1119
|
+
[]) {
|
|
1120
|
+
if (option.matches(token)) return option.reason;
|
|
1121
|
+
}
|
|
1122
|
+
}
|
|
1123
|
+
const pathReason = getUnsafeGitTokenPathReason(token);
|
|
1124
|
+
if (pathReason) return pathReason;
|
|
1125
|
+
}
|
|
1126
|
+
return undefined;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
function buildSafeGitCommand(tokens: string[]): string {
|
|
1130
|
+
const parsed = getGitSubcommand(tokens);
|
|
1131
|
+
const prefix = [
|
|
1132
|
+
"git",
|
|
1133
|
+
"--no-pager",
|
|
1134
|
+
"--no-optional-locks",
|
|
1135
|
+
...SAFE_GIT_CONFIG_OVERRIDES.flatMap((value) => ["-c", value]),
|
|
1136
|
+
];
|
|
1137
|
+
const subcommand = parsed.subcommand;
|
|
1138
|
+
if (!subcommand) return prefix.map(shellQuoteToken).join(" ");
|
|
1139
|
+
const args = tokens.slice(parsed.index + 1);
|
|
1140
|
+
const injectedFlags = (GIT_SUBCOMMAND_SAFE_FLAGS[subcommand] ?? []).filter(
|
|
1141
|
+
(flag) => !args.includes(flag),
|
|
1142
|
+
);
|
|
1143
|
+
return [...prefix, subcommand, ...injectedFlags, ...args].map(shellQuoteToken).join(" ");
|
|
680
1144
|
}
|
|
681
1145
|
|
|
682
1146
|
function getBlockedGitReason(command: string): string | undefined {
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
1147
|
+
const tokens = tokenizeSegment(command);
|
|
1148
|
+
if (getExecutableName(tokens[0] ?? "") !== "git") return undefined;
|
|
1149
|
+
const parsed = getGitSubcommand(tokens);
|
|
1150
|
+
if (parsed.reason) return parsed.reason;
|
|
1151
|
+
if (parsed.subcommand === "help") {
|
|
1152
|
+
return "Triage bash blocks git help because it can execute configured help, man, or browser helpers.";
|
|
1153
|
+
}
|
|
1154
|
+
const argumentReason = getUnsafeGitArgumentReason(tokens, parsed);
|
|
1155
|
+
if (argumentReason) return argumentReason;
|
|
1156
|
+
if (!parsed.subcommand) return undefined;
|
|
1157
|
+
if (parsed.subcommand === "branch") {
|
|
1158
|
+
if (!isSafeGitBranchCommand(tokens, parsed.index)) {
|
|
1159
|
+
return "Triage bash allows git branch only for read-only listing/show-current/contains/merged queries.";
|
|
1160
|
+
}
|
|
1161
|
+
return undefined;
|
|
1162
|
+
}
|
|
1163
|
+
if (parsed.subcommand === "remote") {
|
|
1164
|
+
if (!isSafeGitRemoteCommand(tokens, parsed.index)) {
|
|
1165
|
+
return "Triage bash allows git remote only for read-only list/show/get-url queries.";
|
|
1166
|
+
}
|
|
1167
|
+
return undefined;
|
|
1168
|
+
}
|
|
1169
|
+
if (!READ_ONLY_GIT_SUBCOMMANDS.has(parsed.subcommand)) {
|
|
1170
|
+
return `Triage bash blocks git ${parsed.subcommand}; only known read-only git subcommands are allowed.`;
|
|
1171
|
+
}
|
|
1172
|
+
return undefined;
|
|
706
1173
|
}
|
|
707
1174
|
|
|
708
1175
|
function getGhCommand(tokens: string[]): { command?: string; subcommand?: string } {
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
1176
|
+
let index = 1;
|
|
1177
|
+
while (index < tokens.length) {
|
|
1178
|
+
const token = tokens[index];
|
|
1179
|
+
if (!token.startsWith("-")) break;
|
|
1180
|
+
if (GH_GLOBAL_OPTIONS_WITH_VALUE.has(token)) {
|
|
1181
|
+
index += 2;
|
|
1182
|
+
continue;
|
|
1183
|
+
}
|
|
1184
|
+
index += 1;
|
|
1185
|
+
}
|
|
1186
|
+
return { command: tokens[index]?.toLowerCase(), subcommand: tokens[index + 1]?.toLowerCase() };
|
|
720
1187
|
}
|
|
721
1188
|
|
|
722
1189
|
function normalizeFlagValue(value: string | undefined): string | undefined {
|
|
723
|
-
|
|
1190
|
+
return value ? stripTokenQuotes(value).trim() : undefined;
|
|
724
1191
|
}
|
|
725
1192
|
|
|
726
1193
|
function getBlockedGhApiReason(tokens: string[]): string | undefined {
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1194
|
+
const parsed = getGhCommand(tokens);
|
|
1195
|
+
if (parsed.command !== "api") return undefined;
|
|
1196
|
+
if (tokens.find((token) => token.toLowerCase() === "graphql")) {
|
|
1197
|
+
return "Triage bash blocks gh api graphql because it uses POST/body fields.";
|
|
1198
|
+
}
|
|
1199
|
+
for (let index = 1; index < tokens.length; index += 1) {
|
|
1200
|
+
const token = tokens[index];
|
|
1201
|
+
const lowerToken = token.toLowerCase();
|
|
1202
|
+
if (
|
|
1203
|
+
token === "-f" ||
|
|
1204
|
+
token === "-F" ||
|
|
1205
|
+
token === "--field" ||
|
|
1206
|
+
token === "--raw-field" ||
|
|
1207
|
+
token === "--input" ||
|
|
1208
|
+
lowerToken.startsWith("-f") ||
|
|
1209
|
+
lowerToken.startsWith("--field=") ||
|
|
1210
|
+
lowerToken.startsWith("--raw-field=") ||
|
|
1211
|
+
lowerToken.startsWith("--input=")
|
|
1212
|
+
) {
|
|
1213
|
+
return "Triage bash allows read-only gh api calls only; request fields and input files are blocked.";
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
let method: string | undefined;
|
|
1217
|
+
if (lowerToken === "-x" || lowerToken === "--method") {
|
|
1218
|
+
method = normalizeFlagValue(tokens[index + 1]);
|
|
1219
|
+
} else if (lowerToken.startsWith("-x") && token.length > 2) {
|
|
1220
|
+
method = normalizeFlagValue(token.slice(2).replace(/^=/, ""));
|
|
1221
|
+
} else if (lowerToken.startsWith("--method=")) {
|
|
1222
|
+
method = normalizeFlagValue(token.slice("--method=".length));
|
|
1223
|
+
}
|
|
1224
|
+
if (method && MUTATING_GH_API_METHODS.has(method.toUpperCase())) {
|
|
1225
|
+
return "Triage bash allows read-only gh api calls only; mutating methods are blocked.";
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
return undefined;
|
|
762
1229
|
}
|
|
763
1230
|
|
|
764
1231
|
function isReadOnlyGhCommand(command: string | undefined, subcommand: string | undefined): boolean {
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
1232
|
+
if (!command) return true;
|
|
1233
|
+
if (command === "api") return true;
|
|
1234
|
+
if (command === "search") return true;
|
|
1235
|
+
if (command === "repo") return subcommand === "view" || subcommand === "list";
|
|
1236
|
+
if (command === "pr")
|
|
1237
|
+
return (
|
|
1238
|
+
subcommand === "view" ||
|
|
1239
|
+
subcommand === "list" ||
|
|
1240
|
+
subcommand === "diff" ||
|
|
1241
|
+
subcommand === "status" ||
|
|
1242
|
+
subcommand === "checks"
|
|
1243
|
+
);
|
|
1244
|
+
if (command === "issue")
|
|
1245
|
+
return subcommand === "view" || subcommand === "list" || subcommand === "status";
|
|
1246
|
+
if (command === "release") return subcommand === "view" || subcommand === "list";
|
|
1247
|
+
if (command === "run") return subcommand === "view" || subcommand === "list";
|
|
1248
|
+
if (command === "workflow") return subcommand === "view" || subcommand === "list";
|
|
1249
|
+
if (command === "label") return subcommand === "list";
|
|
1250
|
+
if (command === "milestone") return subcommand === "list";
|
|
1251
|
+
return false;
|
|
777
1252
|
}
|
|
778
1253
|
|
|
779
1254
|
function getBlockedGhReason(command: string): string | undefined {
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
1255
|
+
const tokens = tokenizeSegment(command);
|
|
1256
|
+
if (getExecutableName(tokens[0] ?? "") !== "gh") return undefined;
|
|
1257
|
+
if (/\bgh\s+auth\s+(?:login|logout|refresh|setup-git|token)\b/i.test(command)) {
|
|
1258
|
+
return "Triage bash blocks gh auth commands and token inspection.";
|
|
1259
|
+
}
|
|
1260
|
+
const apiReason = getBlockedGhApiReason(tokens);
|
|
1261
|
+
if (apiReason) return apiReason;
|
|
1262
|
+
if (
|
|
1263
|
+
/\bgh\s+(?:repo\s+(?:archive|clone|create|delete|edit|fork|rename|sync)|pr\s+(?:checkout|close|comment|create|edit|lock|merge|ready|reopen|review|unlock)|issue\s+(?:close|comment|create|delete|edit|lock|reopen|transfer|unlock)|release\s+(?:create|delete|edit|upload)|workflow\s+run|run\s+(?:cancel|delete|rerun)|gist\s+(?:create|delete|edit))\b/i.test(
|
|
1264
|
+
command,
|
|
1265
|
+
)
|
|
1266
|
+
) {
|
|
1267
|
+
return "Triage bash blocks mutating gh commands.";
|
|
1268
|
+
}
|
|
1269
|
+
const parsed = getGhCommand(tokens);
|
|
1270
|
+
if (!isReadOnlyGhCommand(parsed.command, parsed.subcommand)) {
|
|
1271
|
+
return `Triage bash blocks gh ${[parsed.command, parsed.subcommand].filter(Boolean).join(" ")}; only known read-only gh commands are allowed.`;
|
|
1272
|
+
}
|
|
1273
|
+
return undefined;
|
|
795
1274
|
}
|
|
796
1275
|
|
|
797
1276
|
function getBlockedBashReason(command: string): string | undefined {
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
1277
|
+
const trimmed = command.trim();
|
|
1278
|
+
if (!trimmed) return "Triage bash requires a non-empty command.";
|
|
1279
|
+
if (/`|\$\(/.test(trimmed)) return "Triage bash blocks command substitution.";
|
|
1280
|
+
const syntaxReason = getShellSyntaxReason(trimmed);
|
|
1281
|
+
if (syntaxReason) return syntaxReason;
|
|
1282
|
+
|
|
1283
|
+
const tokens = tokenizeSegment(trimmed);
|
|
1284
|
+
if (tokens.length === 0) return "Triage bash requires a non-empty command.";
|
|
1285
|
+
if (/^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0]))
|
|
1286
|
+
return "Triage bash blocks inline environment assignment.";
|
|
1287
|
+
if (/[\\/]/.test(tokens[0])) {
|
|
1288
|
+
return "Triage bash requires direct git, gh, or pwd invocation without path-qualified executables.";
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
const executable = getExecutableName(tokens[0]);
|
|
1292
|
+
if (!READ_ONLY_BASH_COMMANDS.has(executable)) {
|
|
1293
|
+
return `Triage bash blocks ${executable || "this command"}; use built-in read/grep/find/ls for local files or read-only git/gh inspection commands.`;
|
|
1294
|
+
}
|
|
1295
|
+
if (executable === "pwd") return getBlockedPwdReason(tokens);
|
|
1296
|
+
if (executable === "git") return getBlockedGitReason(trimmed);
|
|
1297
|
+
if (executable === "gh") return getBlockedGhReason(trimmed);
|
|
1298
|
+
return undefined;
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
function createTriageRuntimeGuardExtension(options: {
|
|
1302
|
+
cwd: string;
|
|
1303
|
+
maxTurns: number;
|
|
1304
|
+
}): ExtensionFactory {
|
|
1305
|
+
return (pi) => {
|
|
1306
|
+
let currentTurn = 0;
|
|
1307
|
+
|
|
1308
|
+
pi.on("turn_start", async (event) => {
|
|
1309
|
+
currentTurn = event.turnIndex;
|
|
1310
|
+
});
|
|
1311
|
+
|
|
1312
|
+
pi.on("tool_call", async (event) => {
|
|
1313
|
+
if (!READ_ONLY_TOOL_NAMES.has(event.toolName) && !isSubagentMcpTool(event.toolName)) {
|
|
1314
|
+
return {
|
|
1315
|
+
block: true,
|
|
1316
|
+
reason: `triage_comments exposes read-only built-in tools and MCP tools; ${event.toolName} is not allowed.`,
|
|
1317
|
+
};
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
if (currentTurn >= options.maxTurns - 1) {
|
|
1321
|
+
return {
|
|
1322
|
+
block: true,
|
|
1323
|
+
reason: `Tool use is disabled on final triage_comments turn ${options.maxTurns}/${options.maxTurns}. Answer now with the evidence already gathered.`,
|
|
1324
|
+
};
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
if (event.toolName === "read") {
|
|
1328
|
+
const reason = await assertToolPathInsideCwd(
|
|
1329
|
+
options.cwd,
|
|
1330
|
+
(event.input as { path?: unknown }).path,
|
|
1331
|
+
"read",
|
|
1332
|
+
);
|
|
1333
|
+
if (reason) return { block: true, reason };
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
if (event.toolName === "grep" || event.toolName === "find" || event.toolName === "ls") {
|
|
1337
|
+
const reason = await assertToolPathInsideCwd(
|
|
1338
|
+
options.cwd,
|
|
1339
|
+
(event.input as { path?: unknown }).path,
|
|
1340
|
+
event.toolName,
|
|
1341
|
+
);
|
|
1342
|
+
if (reason) return { block: true, reason };
|
|
1343
|
+
if (event.toolName === "grep") {
|
|
1344
|
+
const globReason = getUnsafePathPatternReason(
|
|
1345
|
+
(event.input as { glob?: unknown }).glob,
|
|
1346
|
+
"grep glob",
|
|
1347
|
+
);
|
|
1348
|
+
if (globReason) return { block: true, reason: globReason };
|
|
1349
|
+
}
|
|
1350
|
+
if (event.toolName === "find") {
|
|
1351
|
+
const patternReason = getUnsafePathPatternReason(
|
|
1352
|
+
(event.input as { pattern?: unknown }).pattern,
|
|
1353
|
+
"find pattern",
|
|
1354
|
+
);
|
|
1355
|
+
if (patternReason) return { block: true, reason: patternReason };
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
if (event.toolName === "bash") {
|
|
1360
|
+
const input = event.input as { command?: unknown; timeout?: unknown };
|
|
1361
|
+
if (typeof input.timeout !== "number") input.timeout = DEFAULT_BASH_TIMEOUT_SECONDS;
|
|
1362
|
+
const command = typeof input.command === "string" ? input.command : "";
|
|
1363
|
+
const reason = getBlockedBashReason(command);
|
|
1364
|
+
if (reason) return { block: true, reason };
|
|
1365
|
+
const tokens = tokenizeSegment(command.trim());
|
|
1366
|
+
if (getExecutableName(tokens[0] ?? "") === "git")
|
|
1367
|
+
input.command = buildSafeGitCommand(tokens);
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
return undefined;
|
|
1371
|
+
});
|
|
1372
|
+
|
|
1373
|
+
pi.on("tool_result", async (event) => ({
|
|
1374
|
+
content: [
|
|
1375
|
+
...(event.content ?? []),
|
|
1376
|
+
{
|
|
1377
|
+
type: "text",
|
|
1378
|
+
text: `\n\n[triage_comments turn budget] turn ${Math.min(currentTurn + 1, options.maxTurns)}/${options.maxTurns}`,
|
|
1379
|
+
},
|
|
1380
|
+
],
|
|
1381
|
+
}));
|
|
1382
|
+
};
|
|
880
1383
|
}
|
|
881
1384
|
|
|
882
1385
|
function formatToolCall(call: ToolCall): string {
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
1386
|
+
const args =
|
|
1387
|
+
call.args && typeof call.args === "object" ? (call.args as Record<string, unknown>) : {};
|
|
1388
|
+
if (call.name === "read") {
|
|
1389
|
+
const readPath = typeof args.path === "string" ? args.path : "";
|
|
1390
|
+
const offset = typeof args.offset === "number" ? args.offset : undefined;
|
|
1391
|
+
const limit = typeof args.limit === "number" ? args.limit : undefined;
|
|
1392
|
+
const range =
|
|
1393
|
+
offset || limit ? `:${offset ?? 1}${limit ? `-${(offset ?? 1) + limit - 1}` : ""}` : "";
|
|
1394
|
+
return `read ${readPath}${range}`.trim();
|
|
1395
|
+
}
|
|
1396
|
+
if (call.name === "grep") {
|
|
1397
|
+
const pattern = typeof args.pattern === "string" ? args.pattern : "";
|
|
1398
|
+
const grepPath = typeof args.path === "string" ? args.path : ".";
|
|
1399
|
+
return `grep ${shorten(pattern, 50)} ${grepPath}`.trim();
|
|
1400
|
+
}
|
|
1401
|
+
if (call.name === "find") {
|
|
1402
|
+
const pattern = typeof args.pattern === "string" ? args.pattern : "";
|
|
1403
|
+
const findPath = typeof args.path === "string" ? args.path : ".";
|
|
1404
|
+
return `find ${shorten(pattern, 50)} ${findPath}`.trim();
|
|
1405
|
+
}
|
|
1406
|
+
if (call.name === "ls") {
|
|
1407
|
+
return `ls ${typeof args.path === "string" ? args.path : "."}`.trim();
|
|
1408
|
+
}
|
|
1409
|
+
if (call.name === "bash") {
|
|
1410
|
+
const command = typeof args.command === "string" ? args.command : "";
|
|
1411
|
+
return `bash ${shorten(command, 120)}`.trim();
|
|
1412
|
+
}
|
|
1413
|
+
return call.name;
|
|
909
1414
|
}
|
|
910
1415
|
|
|
911
1416
|
function renderAnswer(details: TriageDetails): string {
|
|
912
|
-
|
|
913
|
-
|
|
1417
|
+
if (details.error) return details.error;
|
|
1418
|
+
return details.status === "running" ? "(triaging comments...)" : "(no output)";
|
|
914
1419
|
}
|
|
915
1420
|
|
|
916
|
-
type TriageCommandMode =
|
|
1421
|
+
type TriageCommandMode = "paste" | "pr";
|
|
917
1422
|
|
|
918
1423
|
type ParsedTriageCommandArgs = {
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
1424
|
+
mode?: TriageCommandMode;
|
|
1425
|
+
target?: string;
|
|
1426
|
+
pastePrefill?: string;
|
|
1427
|
+
help?: boolean;
|
|
1428
|
+
error?: string;
|
|
924
1429
|
};
|
|
925
1430
|
|
|
926
1431
|
type PullRequestUrlParts = {
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
1432
|
+
host: string;
|
|
1433
|
+
owner: string;
|
|
1434
|
+
repo: string;
|
|
1435
|
+
number: number;
|
|
931
1436
|
};
|
|
932
1437
|
|
|
933
1438
|
type PullRequestContext = {
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
1439
|
+
number: number;
|
|
1440
|
+
title?: string;
|
|
1441
|
+
url?: string;
|
|
1442
|
+
repository: string;
|
|
1443
|
+
host?: string;
|
|
1444
|
+
headRef?: string;
|
|
1445
|
+
headSha?: string;
|
|
1446
|
+
baseRef?: string;
|
|
1447
|
+
baseSha?: string;
|
|
943
1448
|
};
|
|
944
1449
|
|
|
945
1450
|
type ReviewThreadState = {
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1451
|
+
threadId?: string;
|
|
1452
|
+
isResolved?: boolean;
|
|
1453
|
+
isOutdated?: boolean;
|
|
1454
|
+
metadataAvailable: boolean;
|
|
950
1455
|
};
|
|
951
1456
|
|
|
952
1457
|
type ReviewThreadStateLookup = {
|
|
953
|
-
|
|
954
|
-
|
|
1458
|
+
byDatabaseId: Map<number, ReviewThreadState>;
|
|
1459
|
+
byNodeId: Map<string, ReviewThreadState>;
|
|
955
1460
|
};
|
|
956
1461
|
|
|
957
1462
|
type InlineCommentFilter = {
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
1463
|
+
hideResolved: boolean;
|
|
1464
|
+
hideOutdated: boolean;
|
|
1465
|
+
label: string;
|
|
961
1466
|
};
|
|
962
1467
|
|
|
963
1468
|
type AppliedInlineCommentFilter = {
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1469
|
+
filter: InlineCommentFilter;
|
|
1470
|
+
originalCount: number;
|
|
1471
|
+
displayedCount: number;
|
|
1472
|
+
hiddenInlineCount: number;
|
|
1473
|
+
hiddenResolvedInlineCount: number;
|
|
1474
|
+
hiddenOutdatedInlineCount: number;
|
|
1475
|
+
keptInlineWithoutThreadMetadataCount: number;
|
|
971
1476
|
};
|
|
972
1477
|
|
|
973
1478
|
type CommandComment = {
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
1479
|
+
id: string;
|
|
1480
|
+
body: string;
|
|
1481
|
+
path?: string;
|
|
1482
|
+
line?: number;
|
|
1483
|
+
startLine?: number;
|
|
1484
|
+
side?: string;
|
|
1485
|
+
diffHunk?: string;
|
|
1486
|
+
author?: string;
|
|
1487
|
+
url?: string;
|
|
1488
|
+
createdAt?: string;
|
|
1489
|
+
reviewThread?: ReviewThreadState;
|
|
1490
|
+
metadata: Record<string, unknown>;
|
|
1491
|
+
sourceLabel: string;
|
|
1492
|
+
displayNumber?: number;
|
|
1493
|
+
sortTimestamp?: string;
|
|
1494
|
+
sortIndex: number;
|
|
990
1495
|
};
|
|
991
1496
|
|
|
992
1497
|
type SelectionParseResult = { ok: true; indices: number[] } | { ok: false; error: string };
|
|
993
1498
|
|
|
994
1499
|
type TriageCommandPayload = {
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
1500
|
+
comments: Record<string, unknown>[];
|
|
1501
|
+
pr?: Record<string, unknown>;
|
|
1502
|
+
base?: Record<string, unknown>;
|
|
1503
|
+
context?: string;
|
|
999
1504
|
};
|
|
1000
1505
|
|
|
1001
1506
|
function notifyCommand(
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1507
|
+
ctx: ExtensionCommandContext,
|
|
1508
|
+
message: string,
|
|
1509
|
+
type: "info" | "warning" | "error" = "info",
|
|
1005
1510
|
): void {
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1511
|
+
if (ctx.hasUI) {
|
|
1512
|
+
ctx.ui.notify(message, type);
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1010
1515
|
|
|
1011
|
-
|
|
1012
|
-
|
|
1516
|
+
const writer = type === "error" ? console.error : console.log;
|
|
1517
|
+
writer(message);
|
|
1013
1518
|
}
|
|
1014
1519
|
|
|
1015
1520
|
function formatErrorMessage(error: unknown): string {
|
|
1016
|
-
|
|
1521
|
+
return error instanceof Error ? error.message : String(error);
|
|
1017
1522
|
}
|
|
1018
1523
|
|
|
1019
1524
|
function compactRecord(record: Record<string, unknown>): Record<string, unknown> {
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1525
|
+
const compacted: Record<string, unknown> = {};
|
|
1526
|
+
for (const [key, value] of Object.entries(record)) {
|
|
1527
|
+
if (value === undefined || value === null) continue;
|
|
1528
|
+
if (typeof value === "string" && value.trim() === "") continue;
|
|
1529
|
+
if (Array.isArray(value) && value.length === 0) continue;
|
|
1530
|
+
if (
|
|
1531
|
+
value &&
|
|
1532
|
+
typeof value === "object" &&
|
|
1533
|
+
!Array.isArray(value) &&
|
|
1534
|
+
Object.keys(value as Record<string, unknown>).length === 0
|
|
1535
|
+
) {
|
|
1536
|
+
continue;
|
|
1537
|
+
}
|
|
1538
|
+
compacted[key] = value;
|
|
1539
|
+
}
|
|
1540
|
+
return compacted;
|
|
1031
1541
|
}
|
|
1032
1542
|
|
|
1033
1543
|
function optionalRecord(record: Record<string, unknown>): Record<string, unknown> | undefined {
|
|
1034
|
-
|
|
1035
|
-
|
|
1544
|
+
const compacted = compactRecord(record);
|
|
1545
|
+
return Object.keys(compacted).length > 0 ? compacted : undefined;
|
|
1036
1546
|
}
|
|
1037
1547
|
|
|
1038
1548
|
function asStableIdPart(...values: unknown[]): string | undefined {
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1549
|
+
for (const value of values) {
|
|
1550
|
+
if (typeof value === "number" && Number.isFinite(value)) return String(value);
|
|
1551
|
+
const trimmed = asTrimmedString(value);
|
|
1552
|
+
if (trimmed) return trimmed;
|
|
1553
|
+
}
|
|
1554
|
+
return undefined;
|
|
1045
1555
|
}
|
|
1046
1556
|
|
|
1047
1557
|
function parsePullRequestUrl(raw: string): PullRequestUrlParts | undefined {
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1558
|
+
const trimmed = raw.trim();
|
|
1559
|
+
if (!trimmed) return undefined;
|
|
1560
|
+
|
|
1561
|
+
try {
|
|
1562
|
+
const url = new URL(trimmed);
|
|
1563
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") return undefined;
|
|
1564
|
+
const segments = url.pathname
|
|
1565
|
+
.split("/")
|
|
1566
|
+
.filter(Boolean)
|
|
1567
|
+
.map((segment) => decodeURIComponent(segment));
|
|
1568
|
+
if (segments.length < 4 || segments[2] !== "pull") return undefined;
|
|
1569
|
+
const number = Number(segments[3]);
|
|
1570
|
+
if (!Number.isInteger(number) || number <= 0) return undefined;
|
|
1571
|
+
return {
|
|
1572
|
+
host: url.hostname.replace(/^www\./i, ""),
|
|
1573
|
+
owner: segments[0],
|
|
1574
|
+
repo: segments[1],
|
|
1575
|
+
number,
|
|
1576
|
+
};
|
|
1577
|
+
} catch {
|
|
1578
|
+
return undefined;
|
|
1579
|
+
}
|
|
1067
1580
|
}
|
|
1068
1581
|
|
|
1069
1582
|
function looksLikePrTarget(value: string): boolean {
|
|
1070
|
-
|
|
1071
|
-
|
|
1583
|
+
const trimmed = value.trim();
|
|
1584
|
+
return /^#?\d+$/.test(trimmed) || Boolean(parsePullRequestUrl(trimmed));
|
|
1072
1585
|
}
|
|
1073
1586
|
|
|
1074
1587
|
function normalizePrTarget(value: string): string | undefined {
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1588
|
+
const trimmed = value.trim();
|
|
1589
|
+
if (!trimmed) return undefined;
|
|
1590
|
+
if (/^#?\d+$/.test(trimmed)) return trimmed.replace(/^#/, "");
|
|
1591
|
+
if (parsePullRequestUrl(trimmed)) return trimmed;
|
|
1592
|
+
return undefined;
|
|
1080
1593
|
}
|
|
1081
1594
|
|
|
1082
1595
|
function parseTriageCommandArgs(args: string): ParsedTriageCommandArgs {
|
|
1083
|
-
|
|
1084
|
-
|
|
1596
|
+
const trimmed = args.trim();
|
|
1597
|
+
if (!trimmed) return {};
|
|
1085
1598
|
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1599
|
+
const [first = ""] = trimmed.split(/\s+/, 1);
|
|
1600
|
+
const normalized = first.toLowerCase();
|
|
1601
|
+
const rest = trimmed.slice(first.length).trim();
|
|
1089
1602
|
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1603
|
+
if (normalized === "--help" || normalized === "-h" || normalized === "help")
|
|
1604
|
+
return { help: true };
|
|
1605
|
+
if (normalized === "paste") return { mode: "paste", pastePrefill: rest || undefined };
|
|
1606
|
+
if (normalized === "pr") return { mode: "pr", target: rest || undefined };
|
|
1607
|
+
if (looksLikePrTarget(trimmed)) return { mode: "pr", target: trimmed };
|
|
1094
1608
|
|
|
1095
|
-
|
|
1609
|
+
return { error: `Unknown /triage-comments option: ${first}\n${TRIAGE_COMMAND_USAGE}` };
|
|
1096
1610
|
}
|
|
1097
1611
|
|
|
1098
1612
|
function formatExecFailure(result: ExecResult): string {
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1613
|
+
const details = (result.stderr || result.stdout).trim();
|
|
1614
|
+
if (!details) return `exit code ${result.code}`;
|
|
1615
|
+
const lines = details.split("\n").filter(Boolean);
|
|
1616
|
+
return lines.slice(-6).join("\n");
|
|
1103
1617
|
}
|
|
1104
1618
|
|
|
1105
1619
|
async function execChecked(
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1620
|
+
pi: ExtensionAPI,
|
|
1621
|
+
ctx: ExtensionCommandContext,
|
|
1622
|
+
command: string,
|
|
1623
|
+
args: string[],
|
|
1624
|
+
label: string,
|
|
1111
1625
|
): Promise<string> {
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1626
|
+
const result = await pi.exec(command, args, {
|
|
1627
|
+
cwd: ctx.cwd,
|
|
1628
|
+
signal: ctx.signal,
|
|
1629
|
+
timeout: GH_COMMAND_TIMEOUT_MS,
|
|
1630
|
+
});
|
|
1631
|
+
if (result.killed) throw new Error(`${label} timed out or was cancelled.`);
|
|
1632
|
+
if (result.code !== 0) throw new Error(`${label} failed: ${formatExecFailure(result)}`);
|
|
1633
|
+
return result.stdout;
|
|
1116
1634
|
}
|
|
1117
1635
|
|
|
1118
1636
|
async function assertGitRepo(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1637
|
+
const result = await pi.exec("git", ["rev-parse", "--show-toplevel"], {
|
|
1638
|
+
cwd: ctx.cwd,
|
|
1639
|
+
signal: ctx.signal,
|
|
1640
|
+
timeout: GH_COMMAND_TIMEOUT_MS,
|
|
1641
|
+
});
|
|
1642
|
+
if (result.killed) throw new Error("Checking the git repository timed out or was cancelled.");
|
|
1643
|
+
if (result.code !== 0) {
|
|
1644
|
+
throw new Error(
|
|
1645
|
+
"/triage-comments PR mode must run inside a git repository. Run /triage-comments in a checkout and retry.",
|
|
1646
|
+
);
|
|
1647
|
+
}
|
|
1128
1648
|
}
|
|
1129
1649
|
|
|
1130
1650
|
async function assertGhReady(pi: ExtensionAPI, ctx: ExtensionCommandContext): Promise<void> {
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1651
|
+
const version = await pi.exec("gh", ["--version"], {
|
|
1652
|
+
cwd: ctx.cwd,
|
|
1653
|
+
signal: ctx.signal,
|
|
1654
|
+
timeout: GH_COMMAND_TIMEOUT_MS,
|
|
1655
|
+
});
|
|
1656
|
+
if (version.killed)
|
|
1657
|
+
throw new Error("Checking GitHub CLI availability timed out or was cancelled.");
|
|
1658
|
+
if (version.code !== 0) {
|
|
1659
|
+
throw new Error(
|
|
1660
|
+
"GitHub CLI `gh` is required for PR mode but was not found or could not run. Install `gh`, then authenticate with `gh auth login`.",
|
|
1661
|
+
);
|
|
1662
|
+
}
|
|
1136
1663
|
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1664
|
+
const auth = await pi.exec("gh", ["auth", "status"], {
|
|
1665
|
+
cwd: ctx.cwd,
|
|
1666
|
+
signal: ctx.signal,
|
|
1667
|
+
timeout: GH_COMMAND_TIMEOUT_MS,
|
|
1668
|
+
});
|
|
1669
|
+
if (auth.killed)
|
|
1670
|
+
throw new Error("Checking GitHub CLI authentication timed out or was cancelled.");
|
|
1671
|
+
if (auth.code !== 0) {
|
|
1672
|
+
throw new Error(
|
|
1673
|
+
`GitHub CLI ` +
|
|
1674
|
+
"`gh`" +
|
|
1675
|
+
` is not authenticated. Run ` +
|
|
1676
|
+
"`gh auth login`" +
|
|
1677
|
+
` and retry. Details: ${formatExecFailure(auth)}`,
|
|
1678
|
+
);
|
|
1679
|
+
}
|
|
1142
1680
|
}
|
|
1143
1681
|
|
|
1144
1682
|
function parseJsonOutput(stdout: string, label: string): unknown {
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1683
|
+
const trimmed = stdout.trim();
|
|
1684
|
+
if (!trimmed) throw new Error(`${label} returned empty output; expected JSON.`);
|
|
1685
|
+
try {
|
|
1686
|
+
return JSON.parse(trimmed);
|
|
1687
|
+
} catch (error) {
|
|
1688
|
+
throw new Error(`${label} returned invalid JSON: ${formatErrorMessage(error)}`);
|
|
1689
|
+
}
|
|
1152
1690
|
}
|
|
1153
1691
|
|
|
1154
1692
|
function flattenGhArray(value: unknown, label: string): unknown[] {
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1693
|
+
if (!Array.isArray(value)) throw new Error(`${label} returned JSON that was not an array.`);
|
|
1694
|
+
if (value.every((item) => Array.isArray(item))) return value.flatMap((item) => item as unknown[]);
|
|
1695
|
+
return value;
|
|
1158
1696
|
}
|
|
1159
1697
|
|
|
1160
1698
|
function normalizePullRequestContext(raw: unknown, target: string): PullRequestContext {
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1699
|
+
const record = asRecord(raw);
|
|
1700
|
+
if (!record)
|
|
1701
|
+
throw new Error("Could not parse PR metadata from `gh pr view`: expected a JSON object.");
|
|
1702
|
+
|
|
1703
|
+
const number = asFiniteNumber(record.number) ?? parsePullRequestUrl(target)?.number;
|
|
1704
|
+
const url = asTrimmedString(record.url);
|
|
1705
|
+
const parsedUrl = (url ? parsePullRequestUrl(url) : undefined) ?? parsePullRequestUrl(target);
|
|
1706
|
+
if (!number || !Number.isInteger(number) || number <= 0) {
|
|
1707
|
+
throw new Error("Could not parse the PR number from `gh pr view` output.");
|
|
1708
|
+
}
|
|
1709
|
+
if (!parsedUrl) {
|
|
1710
|
+
throw new Error(
|
|
1711
|
+
"Could not parse the PR repository from `gh pr view` output. Use a GitHub PR URL or run from a checkout with a GitHub remote.",
|
|
1712
|
+
);
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
return {
|
|
1716
|
+
number,
|
|
1717
|
+
title: asTrimmedString(record.title),
|
|
1718
|
+
url,
|
|
1719
|
+
repository: `${parsedUrl.owner}/${parsedUrl.repo}`,
|
|
1720
|
+
host: parsedUrl.host,
|
|
1721
|
+
headRef: asTrimmedString(record.headRefName ?? record.headRef),
|
|
1722
|
+
headSha: asTrimmedString(record.headRefOid ?? record.headSha),
|
|
1723
|
+
baseRef: asTrimmedString(record.baseRefName ?? record.baseRef),
|
|
1724
|
+
baseSha: asTrimmedString(record.baseRefOid ?? record.baseSha),
|
|
1725
|
+
};
|
|
1185
1726
|
}
|
|
1186
1727
|
|
|
1187
1728
|
async function ghApiArray(
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1729
|
+
pi: ExtensionAPI,
|
|
1730
|
+
ctx: ExtensionCommandContext,
|
|
1731
|
+
pr: PullRequestContext,
|
|
1732
|
+
endpointSuffix: string,
|
|
1733
|
+
label: string,
|
|
1193
1734
|
): Promise<unknown[]> {
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1735
|
+
const [owner, repo] = pr.repository.split("/");
|
|
1736
|
+
if (!owner || !repo)
|
|
1737
|
+
throw new Error(`Could not build GitHub API path from repository ${pr.repository}.`);
|
|
1738
|
+
const endpoint = `repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/${endpointSuffix}`;
|
|
1739
|
+
const args = ["api"];
|
|
1740
|
+
if (pr.host) args.push("--hostname", pr.host);
|
|
1741
|
+
args.push("--paginate", "--slurp", endpoint);
|
|
1742
|
+
const stdout = await execChecked(pi, ctx, "gh", args, `Fetching ${label} with gh`);
|
|
1743
|
+
return flattenGhArray(parseJsonOutput(stdout, `Fetching ${label} with gh`), label);
|
|
1202
1744
|
}
|
|
1203
1745
|
|
|
1204
1746
|
function githubLogin(value: unknown): string | undefined {
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1747
|
+
if (typeof value === "string") return asTrimmedString(value);
|
|
1748
|
+
const record = asRecord(value);
|
|
1749
|
+
return record ? asTrimmedString(record.login ?? record.name) : undefined;
|
|
1208
1750
|
}
|
|
1209
1751
|
|
|
1210
1752
|
function reviewHtmlUrl(record: Record<string, unknown>): string | undefined {
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1753
|
+
const links = asRecord(record._links);
|
|
1754
|
+
const html = asRecord(links?.html);
|
|
1755
|
+
return asTrimmedString(record.html_url ?? record.htmlUrl ?? html?.href);
|
|
1214
1756
|
}
|
|
1215
1757
|
|
|
1216
1758
|
function createReviewThreadStateLookup(): ReviewThreadStateLookup {
|
|
1217
|
-
|
|
1759
|
+
return { byDatabaseId: new Map(), byNodeId: new Map() };
|
|
1218
1760
|
}
|
|
1219
1761
|
|
|
1220
|
-
function reviewThreadStateForComment(
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1762
|
+
function reviewThreadStateForComment(
|
|
1763
|
+
raw: unknown,
|
|
1764
|
+
lookup: ReviewThreadStateLookup,
|
|
1765
|
+
): ReviewThreadState {
|
|
1766
|
+
const record = asRecord(raw);
|
|
1767
|
+
const databaseId = asFiniteNumber(record?.id);
|
|
1768
|
+
const nodeId = asTrimmedString(record?.node_id ?? record?.nodeId);
|
|
1769
|
+
return (
|
|
1770
|
+
(databaseId !== undefined ? lookup.byDatabaseId.get(databaseId) : undefined) ??
|
|
1771
|
+
(nodeId ? lookup.byNodeId.get(nodeId) : undefined) ?? { metadataAvailable: false }
|
|
1772
|
+
);
|
|
1229
1773
|
}
|
|
1230
1774
|
|
|
1231
1775
|
async function fetchReviewThreadStateLookup(
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1776
|
+
pi: ExtensionAPI,
|
|
1777
|
+
ctx: ExtensionCommandContext,
|
|
1778
|
+
pr: PullRequestContext,
|
|
1235
1779
|
): Promise<ReviewThreadStateLookup> {
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1780
|
+
const [owner, repo] = pr.repository.split("/");
|
|
1781
|
+
if (!owner || !repo)
|
|
1782
|
+
throw new Error(`Could not build GitHub GraphQL variables from repository ${pr.repository}.`);
|
|
1783
|
+
|
|
1784
|
+
const lookup = createReviewThreadStateLookup();
|
|
1785
|
+
let after: string | undefined;
|
|
1786
|
+
|
|
1787
|
+
while (true) {
|
|
1788
|
+
const args = ["api"];
|
|
1789
|
+
if (pr.host) args.push("--hostname", pr.host);
|
|
1790
|
+
args.push(
|
|
1791
|
+
"graphql",
|
|
1792
|
+
"-f",
|
|
1793
|
+
`owner=${owner}`,
|
|
1794
|
+
"-f",
|
|
1795
|
+
`name=${repo}`,
|
|
1796
|
+
"-F",
|
|
1797
|
+
`number=${pr.number}`,
|
|
1798
|
+
"-f",
|
|
1799
|
+
`query=${GH_REVIEW_THREADS_GRAPHQL_QUERY}`,
|
|
1800
|
+
);
|
|
1801
|
+
if (after) args.push("-f", `after=${after}`);
|
|
1802
|
+
|
|
1803
|
+
const label = "Fetching review thread resolved/outdated metadata with gh";
|
|
1804
|
+
const stdout = await execChecked(pi, ctx, "gh", args, label);
|
|
1805
|
+
const raw = parseJsonOutput(stdout, label);
|
|
1806
|
+
const data = asRecord(raw)?.data;
|
|
1807
|
+
const repository = asRecord(asRecord(data)?.repository);
|
|
1808
|
+
const pullRequest = asRecord(repository?.pullRequest);
|
|
1809
|
+
const reviewThreads = asRecord(pullRequest?.reviewThreads);
|
|
1810
|
+
if (!reviewThreads) throw new Error("GitHub GraphQL response did not include reviewThreads.");
|
|
1811
|
+
|
|
1812
|
+
const nodes = Array.isArray(reviewThreads.nodes) ? reviewThreads.nodes : [];
|
|
1813
|
+
for (const rawThread of nodes) {
|
|
1814
|
+
const thread = asRecord(rawThread);
|
|
1815
|
+
if (!thread) continue;
|
|
1816
|
+
const state: ReviewThreadState = {
|
|
1817
|
+
threadId: asTrimmedString(thread.id),
|
|
1818
|
+
isResolved: asBoolean(thread.isResolved),
|
|
1819
|
+
isOutdated: asBoolean(thread.isOutdated),
|
|
1820
|
+
metadataAvailable: true,
|
|
1821
|
+
};
|
|
1822
|
+
const comments = asRecord(thread.comments);
|
|
1823
|
+
const commentNodes = Array.isArray(comments?.nodes) ? comments.nodes : [];
|
|
1824
|
+
for (const rawComment of commentNodes) {
|
|
1825
|
+
const comment = asRecord(rawComment);
|
|
1826
|
+
if (!comment) continue;
|
|
1827
|
+
const databaseId = asFiniteNumber(comment.databaseId);
|
|
1828
|
+
const nodeId = asTrimmedString(comment.id);
|
|
1829
|
+
if (databaseId !== undefined) lookup.byDatabaseId.set(databaseId, state);
|
|
1830
|
+
if (nodeId) lookup.byNodeId.set(nodeId, state);
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
|
|
1834
|
+
const pageInfo = asRecord(reviewThreads.pageInfo);
|
|
1835
|
+
if (!asBoolean(pageInfo?.hasNextPage)) break;
|
|
1836
|
+
const endCursor = asTrimmedString(pageInfo?.endCursor);
|
|
1837
|
+
if (!endCursor) break;
|
|
1838
|
+
after = endCursor;
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
return lookup;
|
|
1297
1842
|
}
|
|
1298
1843
|
|
|
1299
1844
|
async function fetchReviewThreadStateLookupBestEffort(
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1845
|
+
pi: ExtensionAPI,
|
|
1846
|
+
ctx: ExtensionCommandContext,
|
|
1847
|
+
pr: PullRequestContext,
|
|
1303
1848
|
): Promise<ReviewThreadStateLookup> {
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
}
|
|
1315
|
-
|
|
1316
|
-
function normalizeReviewComment(
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
1849
|
+
try {
|
|
1850
|
+
return await fetchReviewThreadStateLookup(pi, ctx, pr);
|
|
1851
|
+
} catch (error) {
|
|
1852
|
+
notifyCommand(
|
|
1853
|
+
ctx,
|
|
1854
|
+
`Could not fetch inline review-thread resolved/outdated metadata; inline review comments without thread metadata will stay visible. ${formatErrorMessage(error)}`,
|
|
1855
|
+
"warning",
|
|
1856
|
+
);
|
|
1857
|
+
return createReviewThreadStateLookup();
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
function normalizeReviewComment(
|
|
1862
|
+
raw: unknown,
|
|
1863
|
+
pr: PullRequestContext,
|
|
1864
|
+
sortIndex: number,
|
|
1865
|
+
reviewThread: ReviewThreadState,
|
|
1866
|
+
): CommandComment | undefined {
|
|
1867
|
+
const record = asRecord(raw);
|
|
1868
|
+
if (!record) return undefined;
|
|
1869
|
+
const body = asTrimmedString(record.body);
|
|
1870
|
+
if (!body) return undefined;
|
|
1871
|
+
|
|
1872
|
+
const databaseId = asFiniteNumber(record.id);
|
|
1873
|
+
const nodeId = asTrimmedString(record.node_id ?? record.nodeId);
|
|
1874
|
+
const stableId =
|
|
1875
|
+
asStableIdPart(record.id, nodeId) ??
|
|
1876
|
+
`${pr.repository}#${pr.number}:review-comment:${sortIndex}`;
|
|
1877
|
+
const createdAt = asTrimmedString(record.created_at ?? record.createdAt);
|
|
1878
|
+
const originalLine = asFiniteNumber(record.original_line ?? record.originalLine);
|
|
1879
|
+
const originalStartLine = asFiniteNumber(record.original_start_line ?? record.originalStartLine);
|
|
1880
|
+
const line = asFiniteNumber(record.line) ?? originalLine;
|
|
1881
|
+
const startLine = asFiniteNumber(record.start_line ?? record.startLine) ?? originalStartLine;
|
|
1882
|
+
|
|
1883
|
+
return {
|
|
1884
|
+
id: `github-review-comment:${stableId}`,
|
|
1885
|
+
body,
|
|
1886
|
+
path: asTrimmedString(record.path),
|
|
1887
|
+
line,
|
|
1888
|
+
startLine,
|
|
1889
|
+
side: asTrimmedString(record.side ?? record.start_side ?? record.startSide),
|
|
1890
|
+
diffHunk: asTrimmedString(record.diff_hunk ?? record.diffHunk),
|
|
1891
|
+
author: githubLogin(record.user),
|
|
1892
|
+
url: asTrimmedString(record.html_url ?? record.htmlUrl),
|
|
1893
|
+
createdAt,
|
|
1894
|
+
reviewThread,
|
|
1895
|
+
metadata: compactRecord({
|
|
1896
|
+
source: "pull_request_review_comment",
|
|
1897
|
+
repository: pr.repository,
|
|
1898
|
+
prNumber: pr.number,
|
|
1899
|
+
host: pr.host,
|
|
1900
|
+
databaseId,
|
|
1901
|
+
nodeId,
|
|
1902
|
+
reviewThread: compactRecord({
|
|
1903
|
+
threadId: reviewThread.threadId,
|
|
1904
|
+
isResolved: reviewThread.isResolved,
|
|
1905
|
+
isOutdated: reviewThread.isOutdated,
|
|
1906
|
+
metadataAvailable: reviewThread.metadataAvailable,
|
|
1907
|
+
}),
|
|
1908
|
+
pullRequestReviewId: asFiniteNumber(
|
|
1909
|
+
record.pull_request_review_id ?? record.pullRequestReviewId,
|
|
1910
|
+
),
|
|
1911
|
+
commitId: asTrimmedString(record.commit_id ?? record.commitId),
|
|
1912
|
+
originalCommitId: asTrimmedString(record.original_commit_id ?? record.originalCommitId),
|
|
1913
|
+
originalLine,
|
|
1914
|
+
originalStartLine,
|
|
1915
|
+
position: asFiniteNumber(record.position),
|
|
1916
|
+
originalPosition: asFiniteNumber(record.original_position ?? record.originalPosition),
|
|
1917
|
+
subjectType: asTrimmedString(record.subject_type ?? record.subjectType),
|
|
1918
|
+
authorAssociation: asTrimmedString(record.author_association ?? record.authorAssociation),
|
|
1919
|
+
inReplyToId: asFiniteNumber(record.in_reply_to_id ?? record.inReplyToId),
|
|
1920
|
+
updatedAt: asTrimmedString(record.updated_at ?? record.updatedAt),
|
|
1921
|
+
}),
|
|
1922
|
+
sourceLabel: "review comment",
|
|
1923
|
+
sortTimestamp: createdAt,
|
|
1924
|
+
sortIndex,
|
|
1925
|
+
};
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
function normalizeIssueComment(
|
|
1929
|
+
raw: unknown,
|
|
1930
|
+
pr: PullRequestContext,
|
|
1931
|
+
sortIndex: number,
|
|
1932
|
+
): CommandComment | undefined {
|
|
1933
|
+
const record = asRecord(raw);
|
|
1934
|
+
if (!record) return undefined;
|
|
1935
|
+
const body = asTrimmedString(record.body);
|
|
1936
|
+
if (!body) return undefined;
|
|
1937
|
+
|
|
1938
|
+
const databaseId = asFiniteNumber(record.id);
|
|
1939
|
+
const nodeId = asTrimmedString(record.node_id ?? record.nodeId);
|
|
1940
|
+
const stableId =
|
|
1941
|
+
asStableIdPart(record.id, nodeId) ?? `${pr.repository}#${pr.number}:issue-comment:${sortIndex}`;
|
|
1942
|
+
const createdAt = asTrimmedString(record.created_at ?? record.createdAt);
|
|
1943
|
+
|
|
1944
|
+
return {
|
|
1945
|
+
id: `github-issue-comment:${stableId}`,
|
|
1946
|
+
body,
|
|
1947
|
+
author: githubLogin(record.user),
|
|
1948
|
+
url: asTrimmedString(record.html_url ?? record.htmlUrl),
|
|
1949
|
+
createdAt,
|
|
1950
|
+
metadata: compactRecord({
|
|
1951
|
+
source: "pull_request_issue_comment",
|
|
1952
|
+
repository: pr.repository,
|
|
1953
|
+
prNumber: pr.number,
|
|
1954
|
+
host: pr.host,
|
|
1955
|
+
databaseId,
|
|
1956
|
+
nodeId,
|
|
1957
|
+
authorAssociation: asTrimmedString(record.author_association ?? record.authorAssociation),
|
|
1958
|
+
updatedAt: asTrimmedString(record.updated_at ?? record.updatedAt),
|
|
1959
|
+
}),
|
|
1960
|
+
sourceLabel: "issue comment",
|
|
1961
|
+
sortTimestamp: createdAt,
|
|
1962
|
+
sortIndex,
|
|
1963
|
+
};
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
function normalizeReviewBody(
|
|
1967
|
+
raw: unknown,
|
|
1968
|
+
pr: PullRequestContext,
|
|
1969
|
+
sortIndex: number,
|
|
1970
|
+
): CommandComment | undefined {
|
|
1971
|
+
const record = asRecord(raw);
|
|
1972
|
+
if (!record) return undefined;
|
|
1973
|
+
const body = asTrimmedString(record.body);
|
|
1974
|
+
if (!body) return undefined;
|
|
1975
|
+
|
|
1976
|
+
const databaseId = asFiniteNumber(record.id);
|
|
1977
|
+
const nodeId = asTrimmedString(record.node_id ?? record.nodeId);
|
|
1978
|
+
const stableId =
|
|
1979
|
+
asStableIdPart(record.id, nodeId) ?? `${pr.repository}#${pr.number}:review:${sortIndex}`;
|
|
1980
|
+
const submittedAt = asTrimmedString(record.submitted_at ?? record.submittedAt);
|
|
1981
|
+
const createdAt = submittedAt ?? asTrimmedString(record.created_at ?? record.createdAt);
|
|
1982
|
+
|
|
1983
|
+
return {
|
|
1984
|
+
id: `github-review:${stableId}`,
|
|
1985
|
+
body,
|
|
1986
|
+
author: githubLogin(record.user),
|
|
1987
|
+
url: reviewHtmlUrl(record),
|
|
1988
|
+
createdAt,
|
|
1989
|
+
metadata: compactRecord({
|
|
1990
|
+
source: "pull_request_review_body",
|
|
1991
|
+
repository: pr.repository,
|
|
1992
|
+
prNumber: pr.number,
|
|
1993
|
+
host: pr.host,
|
|
1994
|
+
databaseId,
|
|
1995
|
+
nodeId,
|
|
1996
|
+
state: asTrimmedString(record.state),
|
|
1997
|
+
commitId: asTrimmedString(record.commit_id ?? record.commitId),
|
|
1998
|
+
submittedAt,
|
|
1999
|
+
authorAssociation: asTrimmedString(record.author_association ?? record.authorAssociation),
|
|
2000
|
+
}),
|
|
2001
|
+
sourceLabel: "review body",
|
|
2002
|
+
sortTimestamp: createdAt,
|
|
2003
|
+
sortIndex,
|
|
2004
|
+
};
|
|
1441
2005
|
}
|
|
1442
2006
|
|
|
1443
2007
|
function compareCommandComments(a: CommandComment, b: CommandComment): number {
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
2008
|
+
const aTime = a.sortTimestamp ? Date.parse(a.sortTimestamp) : NaN;
|
|
2009
|
+
const bTime = b.sortTimestamp ? Date.parse(b.sortTimestamp) : NaN;
|
|
2010
|
+
if (Number.isFinite(aTime) && Number.isFinite(bTime) && aTime !== bTime) return aTime - bTime;
|
|
2011
|
+
if (Number.isFinite(aTime) && !Number.isFinite(bTime)) return -1;
|
|
2012
|
+
if (!Number.isFinite(aTime) && Number.isFinite(bTime)) return 1;
|
|
2013
|
+
return a.sortIndex - b.sortIndex;
|
|
1450
2014
|
}
|
|
1451
2015
|
|
|
1452
2016
|
function assignDisplayNumbers(comments: CommandComment[]): CommandComment[] {
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
2017
|
+
return comments.map((comment, index) => ({
|
|
2018
|
+
...comment,
|
|
2019
|
+
displayNumber: index + 1,
|
|
2020
|
+
metadata: compactRecord({ ...comment.metadata, displayNumber: index + 1 }),
|
|
2021
|
+
}));
|
|
1458
2022
|
}
|
|
1459
2023
|
|
|
1460
2024
|
function prContextFallbackTarget(raw: unknown, explicitTarget?: string): string {
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
2025
|
+
const record = asRecord(raw);
|
|
2026
|
+
const number = asFiniteNumber(record?.number);
|
|
2027
|
+
return explicitTarget ?? asTrimmedString(record?.url) ?? (number ? String(number) : "");
|
|
1464
2028
|
}
|
|
1465
2029
|
|
|
1466
2030
|
async function fetchPullRequestMetadata(
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
2031
|
+
pi: ExtensionAPI,
|
|
2032
|
+
ctx: ExtensionCommandContext,
|
|
2033
|
+
label: string,
|
|
2034
|
+
target?: string,
|
|
1471
2035
|
): Promise<PullRequestContext> {
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
2036
|
+
const args = ["pr", "view"];
|
|
2037
|
+
if (target) args.push(target);
|
|
2038
|
+
args.push("--json", GH_PR_METADATA_FIELDS);
|
|
1475
2039
|
|
|
1476
|
-
|
|
1477
|
-
|
|
1478
|
-
|
|
2040
|
+
const stdout = await execChecked(pi, ctx, "gh", args, label);
|
|
2041
|
+
const raw = parseJsonOutput(stdout, label);
|
|
2042
|
+
return normalizePullRequestContext(raw, prContextFallbackTarget(raw, target));
|
|
1479
2043
|
}
|
|
1480
2044
|
|
|
1481
2045
|
async function detectCurrentBranchPullRequest(
|
|
1482
|
-
|
|
1483
|
-
|
|
2046
|
+
pi: ExtensionAPI,
|
|
2047
|
+
ctx: ExtensionCommandContext,
|
|
1484
2048
|
): Promise<PullRequestContext | undefined> {
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1488
|
-
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
|
|
1499
|
-
|
|
2049
|
+
try {
|
|
2050
|
+
const branch = await pi.exec("git", ["branch", "--show-current"], {
|
|
2051
|
+
cwd: ctx.cwd,
|
|
2052
|
+
signal: ctx.signal,
|
|
2053
|
+
timeout: GH_COMMAND_TIMEOUT_MS,
|
|
2054
|
+
});
|
|
2055
|
+
if (branch.killed || branch.code !== 0) return undefined;
|
|
2056
|
+
|
|
2057
|
+
const branchName = branch.stdout.trim();
|
|
2058
|
+
if (!branchName || branchName === "main") return undefined;
|
|
2059
|
+
|
|
2060
|
+
return await fetchPullRequestMetadata(pi, ctx, "Detecting current branch PR with gh");
|
|
2061
|
+
} catch {
|
|
2062
|
+
return undefined;
|
|
2063
|
+
}
|
|
1500
2064
|
}
|
|
1501
2065
|
|
|
1502
2066
|
async function fetchCommentsForPullRequest(
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
2067
|
+
pi: ExtensionAPI,
|
|
2068
|
+
ctx: ExtensionCommandContext,
|
|
2069
|
+
pr: PullRequestContext,
|
|
1506
2070
|
): Promise<CommandComment[]> {
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
2071
|
+
const reviewComments = await ghApiArray(
|
|
2072
|
+
pi,
|
|
2073
|
+
ctx,
|
|
2074
|
+
pr,
|
|
2075
|
+
`pulls/${pr.number}/comments?per_page=100`,
|
|
2076
|
+
"review comments",
|
|
2077
|
+
);
|
|
2078
|
+
const reviewThreadLookup =
|
|
2079
|
+
reviewComments.length > 0
|
|
2080
|
+
? await fetchReviewThreadStateLookupBestEffort(pi, ctx, pr)
|
|
2081
|
+
: createReviewThreadStateLookup();
|
|
2082
|
+
const issueComments = await ghApiArray(
|
|
2083
|
+
pi,
|
|
2084
|
+
ctx,
|
|
2085
|
+
pr,
|
|
2086
|
+
`issues/${pr.number}/comments?per_page=100`,
|
|
2087
|
+
"issue comments",
|
|
2088
|
+
);
|
|
2089
|
+
const reviews = await ghApiArray(
|
|
2090
|
+
pi,
|
|
2091
|
+
ctx,
|
|
2092
|
+
pr,
|
|
2093
|
+
`pulls/${pr.number}/reviews?per_page=100`,
|
|
2094
|
+
"review bodies",
|
|
2095
|
+
);
|
|
2096
|
+
|
|
2097
|
+
let sortIndex = 0;
|
|
2098
|
+
const comments: CommandComment[] = [];
|
|
2099
|
+
for (const raw of reviewComments) {
|
|
2100
|
+
const comment = normalizeReviewComment(
|
|
2101
|
+
raw,
|
|
2102
|
+
pr,
|
|
2103
|
+
++sortIndex,
|
|
2104
|
+
reviewThreadStateForComment(raw, reviewThreadLookup),
|
|
2105
|
+
);
|
|
2106
|
+
if (comment) comments.push(comment);
|
|
2107
|
+
}
|
|
2108
|
+
for (const raw of issueComments) {
|
|
2109
|
+
const comment = normalizeIssueComment(raw, pr, ++sortIndex);
|
|
2110
|
+
if (comment) comments.push(comment);
|
|
2111
|
+
}
|
|
2112
|
+
for (const raw of reviews) {
|
|
2113
|
+
const comment = normalizeReviewBody(raw, pr, ++sortIndex);
|
|
2114
|
+
if (comment) comments.push(comment);
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
return assignDisplayNumbers(comments.sort(compareCommandComments));
|
|
1530
2118
|
}
|
|
1531
2119
|
|
|
1532
2120
|
async function fetchPrCommentsForTriage(
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
2121
|
+
pi: ExtensionAPI,
|
|
2122
|
+
ctx: ExtensionCommandContext,
|
|
2123
|
+
target: string,
|
|
1536
2124
|
): Promise<{ pr: PullRequestContext; comments: CommandComment[] }> {
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
2125
|
+
const normalizedTarget = normalizePrTarget(target);
|
|
2126
|
+
if (!normalizedTarget) {
|
|
2127
|
+
throw new Error(
|
|
2128
|
+
"PR input must be a PR number (for example, 123 or #123) or a GitHub pull request URL.",
|
|
2129
|
+
);
|
|
2130
|
+
}
|
|
1541
2131
|
|
|
1542
|
-
|
|
1543
|
-
|
|
2132
|
+
await assertGitRepo(pi, ctx);
|
|
2133
|
+
await assertGhReady(pi, ctx);
|
|
1544
2134
|
|
|
1545
|
-
|
|
1546
|
-
|
|
1547
|
-
|
|
2135
|
+
const pr = await fetchPullRequestMetadata(
|
|
2136
|
+
pi,
|
|
2137
|
+
ctx,
|
|
2138
|
+
"Fetching PR metadata with gh",
|
|
2139
|
+
normalizedTarget,
|
|
2140
|
+
);
|
|
2141
|
+
const comments = await fetchCommentsForPullRequest(pi, ctx, pr);
|
|
2142
|
+
return { pr, comments };
|
|
1548
2143
|
}
|
|
1549
2144
|
|
|
1550
2145
|
function truncateForDisplay(text: string, max: number): string {
|
|
1551
|
-
|
|
1552
|
-
|
|
2146
|
+
if (text.length <= max) return text;
|
|
2147
|
+
return `${text.slice(0, Math.max(1, max)).trimEnd()}\n… (truncated; full text will be sent to triage_comments)`;
|
|
1553
2148
|
}
|
|
1554
2149
|
|
|
1555
2150
|
function indentLines(text: string, prefix: string): string {
|
|
1556
|
-
|
|
2151
|
+
return text
|
|
2152
|
+
.split("\n")
|
|
2153
|
+
.map((line) => `${prefix}${line}`)
|
|
2154
|
+
.join("\n");
|
|
1557
2155
|
}
|
|
1558
2156
|
|
|
1559
2157
|
function formatCommentLocation(comment: CommandComment): string {
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
2158
|
+
if (!comment.path) return "PR discussion";
|
|
2159
|
+
const lineRange =
|
|
2160
|
+
comment.startLine && comment.line && comment.startLine !== comment.line
|
|
2161
|
+
? `${comment.startLine}-${comment.line}`
|
|
2162
|
+
: (comment.line ?? comment.startLine);
|
|
2163
|
+
return lineRange ? `${comment.path}:${lineRange}` : comment.path;
|
|
1565
2164
|
}
|
|
1566
2165
|
|
|
1567
2166
|
function isInlineReviewComment(comment: CommandComment): boolean {
|
|
1568
|
-
|
|
2167
|
+
return asTrimmedString(comment.metadata.source) === "pull_request_review_comment";
|
|
1569
2168
|
}
|
|
1570
2169
|
|
|
1571
2170
|
function formatReviewThreadState(comment: CommandComment): string | undefined {
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
2171
|
+
if (!isInlineReviewComment(comment)) return undefined;
|
|
2172
|
+
const reviewThread = comment.reviewThread;
|
|
2173
|
+
if (!reviewThread?.metadataAvailable)
|
|
2174
|
+
return "thread: resolved/outdated state unavailable (kept visible)";
|
|
2175
|
+
const resolved =
|
|
2176
|
+
reviewThread.isResolved === true
|
|
2177
|
+
? "resolved"
|
|
2178
|
+
: reviewThread.isResolved === false
|
|
2179
|
+
? "unresolved"
|
|
2180
|
+
: "resolved state unavailable";
|
|
2181
|
+
const outdated =
|
|
2182
|
+
reviewThread.isOutdated === true
|
|
2183
|
+
? "outdated"
|
|
2184
|
+
: reviewThread.isOutdated === false
|
|
2185
|
+
? "current"
|
|
2186
|
+
: "outdated state unavailable";
|
|
2187
|
+
return `thread: ${resolved}, ${outdated}`;
|
|
1586
2188
|
}
|
|
1587
2189
|
|
|
1588
2190
|
function formatInlineFilterNotice(summary: AppliedInlineCommentFilter): string {
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
1598
|
-
|
|
1599
|
-
|
|
1600
|
-
|
|
2191
|
+
const scope =
|
|
2192
|
+
"Only inline review comments can be filtered; PR issue comments, review bodies, and inline comments without thread metadata remain visible.";
|
|
2193
|
+
if (!summary.filter.hideResolved && !summary.filter.hideOutdated) {
|
|
2194
|
+
return `Filter: showing all fetched comments. ${scope}`;
|
|
2195
|
+
}
|
|
2196
|
+
const hiddenParts = [
|
|
2197
|
+
summary.hiddenResolvedInlineCount > 0
|
|
2198
|
+
? `${summary.hiddenResolvedInlineCount} resolved`
|
|
2199
|
+
: undefined,
|
|
2200
|
+
summary.hiddenOutdatedInlineCount > 0
|
|
2201
|
+
? `${summary.hiddenOutdatedInlineCount} outdated`
|
|
2202
|
+
: undefined,
|
|
2203
|
+
]
|
|
2204
|
+
.filter(Boolean)
|
|
2205
|
+
.join(", ");
|
|
2206
|
+
const hiddenDetail = hiddenParts ? ` (${hiddenParts})` : "";
|
|
2207
|
+
return `Filter: ${summary.filter.label}. Hidden ${summary.hiddenInlineCount} inline review comment(s)${hiddenDetail}; displaying ${summary.displayedCount} of ${summary.originalCount} fetched comment(s). ${scope}`;
|
|
1601
2208
|
}
|
|
1602
2209
|
|
|
1603
2210
|
function formatInlineFilterContext(summary: AppliedInlineCommentFilter): string {
|
|
1604
|
-
|
|
1605
|
-
}
|
|
1606
|
-
|
|
1607
|
-
function formatFetchedCommentsForSelection(
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
}
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
2211
|
+
return `${formatInlineFilterNotice(summary)} Inline comments kept without thread metadata: ${summary.keptInlineWithoutThreadMetadataCount}.`;
|
|
2212
|
+
}
|
|
2213
|
+
|
|
2214
|
+
function formatFetchedCommentsForSelection(
|
|
2215
|
+
pr: PullRequestContext,
|
|
2216
|
+
comments: CommandComment[],
|
|
2217
|
+
filterSummary?: AppliedInlineCommentFilter,
|
|
2218
|
+
): string {
|
|
2219
|
+
const title = pr.title ? ` — ${pr.title}` : "";
|
|
2220
|
+
const limitNotice =
|
|
2221
|
+
comments.length > MAX_COMMENTS
|
|
2222
|
+
? `\n\ntriage_comments can investigate at most ${MAX_COMMENTS} comments per run, so choose a subset.`
|
|
2223
|
+
: "\n\nChoose whether to investigate all displayed comments or select a subset.";
|
|
2224
|
+
const fetchedCount = filterSummary?.originalCount ?? comments.length;
|
|
2225
|
+
const displayedCount = comments.length;
|
|
2226
|
+
const countSummary = filterSummary
|
|
2227
|
+
? `Fetched ${fetchedCount} comment(s) from ${pr.repository}#${pr.number}${title}; displaying ${displayedCount} numbered comment(s) after filtering.`
|
|
2228
|
+
: `Fetched ${displayedCount} numbered comment(s) from ${pr.repository}#${pr.number}${title}.`;
|
|
2229
|
+
const filterNotice = filterSummary ? `\n\n${formatInlineFilterNotice(filterSummary)}` : "";
|
|
2230
|
+
const header = `${countSummary}${filterNotice}${limitNotice}`;
|
|
2231
|
+
const body = comments.map((comment) => {
|
|
2232
|
+
const author = comment.author ? ` by @${comment.author}` : "";
|
|
2233
|
+
const url = comment.url ? `\n url: ${comment.url}` : "";
|
|
2234
|
+
const threadState = formatReviewThreadState(comment);
|
|
2235
|
+
const threadLine = threadState ? `\n ${threadState}` : "";
|
|
2236
|
+
const preview = indentLines(
|
|
2237
|
+
truncateForDisplay(comment.body, COMMENT_DISPLAY_BODY_LIMIT),
|
|
2238
|
+
" ",
|
|
2239
|
+
);
|
|
2240
|
+
return `${comment.displayNumber ?? "?"}. ${comment.sourceLabel}${author} — ${formatCommentLocation(comment)}\n id: ${comment.id}${url}${threadLine}\n${preview}`;
|
|
2241
|
+
});
|
|
2242
|
+
return [header, ...body].join("\n\n");
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
async function promptForInlineCommentFilter(
|
|
2246
|
+
ctx: ExtensionCommandContext,
|
|
2247
|
+
): Promise<InlineCommentFilter | undefined> {
|
|
2248
|
+
const prompt = [
|
|
2249
|
+
"/triage-comments: filter inline review comments before display",
|
|
2250
|
+
"GitHub exposes resolved/outdated state only for inline review threads. PR issue comments and review bodies will stay visible, and inline comments without thread metadata will stay visible.",
|
|
2251
|
+
].join("\n\n");
|
|
2252
|
+
const showAll = "Show all fetched comments";
|
|
2253
|
+
const hideResolved = "Hide resolved inline review comments";
|
|
2254
|
+
const hideOutdated = "Hide outdated inline review comments";
|
|
2255
|
+
const hideBoth = "Hide resolved and outdated inline review comments";
|
|
2256
|
+
const cancel = "Cancel";
|
|
2257
|
+
const decision = await ctx.ui.select(prompt, [
|
|
2258
|
+
showAll,
|
|
2259
|
+
hideResolved,
|
|
2260
|
+
hideOutdated,
|
|
2261
|
+
hideBoth,
|
|
2262
|
+
cancel,
|
|
2263
|
+
]);
|
|
2264
|
+
if (!decision || decision === cancel) return undefined;
|
|
2265
|
+
if (decision === hideResolved)
|
|
2266
|
+
return {
|
|
2267
|
+
hideResolved: true,
|
|
2268
|
+
hideOutdated: false,
|
|
2269
|
+
label: "hiding resolved inline review comments",
|
|
2270
|
+
};
|
|
2271
|
+
if (decision === hideOutdated)
|
|
2272
|
+
return {
|
|
2273
|
+
hideResolved: false,
|
|
2274
|
+
hideOutdated: true,
|
|
2275
|
+
label: "hiding outdated inline review comments",
|
|
2276
|
+
};
|
|
2277
|
+
if (decision === hideBoth)
|
|
2278
|
+
return {
|
|
2279
|
+
hideResolved: true,
|
|
2280
|
+
hideOutdated: true,
|
|
2281
|
+
label: "hiding resolved or outdated inline review comments",
|
|
2282
|
+
};
|
|
2283
|
+
return { hideResolved: false, hideOutdated: false, label: "showing all fetched comments" };
|
|
1646
2284
|
}
|
|
1647
2285
|
|
|
1648
2286
|
function applyInlineCommentFilter(
|
|
1649
|
-
|
|
1650
|
-
|
|
2287
|
+
comments: CommandComment[],
|
|
2288
|
+
filter: InlineCommentFilter,
|
|
1651
2289
|
): { comments: CommandComment[]; summary: AppliedInlineCommentFilter } {
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
1669
|
-
|
|
1670
|
-
|
|
1671
|
-
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
displayedCount: kept.length,
|
|
1691
|
-
hiddenInlineCount,
|
|
1692
|
-
hiddenResolvedInlineCount,
|
|
1693
|
-
hiddenOutdatedInlineCount,
|
|
1694
|
-
keptInlineWithoutThreadMetadataCount,
|
|
1695
|
-
};
|
|
1696
|
-
|
|
1697
|
-
return { comments: assignDisplayNumbers(commentsWithFilterMetadata), summary };
|
|
2290
|
+
const kept: CommandComment[] = [];
|
|
2291
|
+
let hiddenInlineCount = 0;
|
|
2292
|
+
let hiddenResolvedInlineCount = 0;
|
|
2293
|
+
let hiddenOutdatedInlineCount = 0;
|
|
2294
|
+
let keptInlineWithoutThreadMetadataCount = 0;
|
|
2295
|
+
|
|
2296
|
+
for (const comment of comments) {
|
|
2297
|
+
if (!isInlineReviewComment(comment)) {
|
|
2298
|
+
kept.push(comment);
|
|
2299
|
+
continue;
|
|
2300
|
+
}
|
|
2301
|
+
|
|
2302
|
+
const reviewThread = comment.reviewThread;
|
|
2303
|
+
const hasThreadMetadata = Boolean(reviewThread?.metadataAvailable);
|
|
2304
|
+
const hideForResolved = filter.hideResolved && reviewThread?.isResolved === true;
|
|
2305
|
+
const hideForOutdated = filter.hideOutdated && reviewThread?.isOutdated === true;
|
|
2306
|
+
if (hideForResolved || hideForOutdated) {
|
|
2307
|
+
hiddenInlineCount += 1;
|
|
2308
|
+
if (hideForResolved) hiddenResolvedInlineCount += 1;
|
|
2309
|
+
if (hideForOutdated) hiddenOutdatedInlineCount += 1;
|
|
2310
|
+
continue;
|
|
2311
|
+
}
|
|
2312
|
+
|
|
2313
|
+
if (!hasThreadMetadata) keptInlineWithoutThreadMetadataCount += 1;
|
|
2314
|
+
kept.push(comment);
|
|
2315
|
+
}
|
|
2316
|
+
|
|
2317
|
+
const summary: AppliedInlineCommentFilter = {
|
|
2318
|
+
filter,
|
|
2319
|
+
originalCount: comments.length,
|
|
2320
|
+
displayedCount: kept.length,
|
|
2321
|
+
hiddenInlineCount,
|
|
2322
|
+
hiddenResolvedInlineCount,
|
|
2323
|
+
hiddenOutdatedInlineCount,
|
|
2324
|
+
keptInlineWithoutThreadMetadataCount,
|
|
2325
|
+
};
|
|
2326
|
+
|
|
2327
|
+
return { comments: assignDisplayNumbers(kept), summary };
|
|
1698
2328
|
}
|
|
1699
2329
|
|
|
1700
2330
|
function parseSelectionList(input: string, max: number): SelectionParseResult {
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
|
|
1715
|
-
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
2331
|
+
const trimmed = input.trim();
|
|
2332
|
+
if (!trimmed) return { ok: false, error: "Enter comment numbers such as 1,3-5." };
|
|
2333
|
+
if (trimmed.toLowerCase() === "all")
|
|
2334
|
+
return { ok: true, indices: Array.from({ length: max }, (_value, index) => index) };
|
|
2335
|
+
|
|
2336
|
+
const selected: number[] = [];
|
|
2337
|
+
const seen = new Set<number>();
|
|
2338
|
+
const addNumber = (value: number): string | undefined => {
|
|
2339
|
+
if (!Number.isInteger(value) || value <= 0) return `Invalid comment number: ${value}`;
|
|
2340
|
+
if (value > max) return `Comment number ${value} is outside the available range 1-${max}.`;
|
|
2341
|
+
const index = value - 1;
|
|
2342
|
+
if (!seen.has(index)) {
|
|
2343
|
+
seen.add(index);
|
|
2344
|
+
selected.push(index);
|
|
2345
|
+
}
|
|
2346
|
+
return undefined;
|
|
2347
|
+
};
|
|
2348
|
+
|
|
2349
|
+
for (const rawPart of trimmed.split(",")) {
|
|
2350
|
+
const part = rawPart.trim();
|
|
2351
|
+
if (!part)
|
|
2352
|
+
return { ok: false, error: "Selection contains an empty item. Use a format like 1,3-5." };
|
|
2353
|
+
const range = part.match(/^(\d+)\s*-\s*(\d+)$/);
|
|
2354
|
+
if (range) {
|
|
2355
|
+
const start = Number(range[1]);
|
|
2356
|
+
const end = Number(range[2]);
|
|
2357
|
+
if (start > end)
|
|
2358
|
+
return { ok: false, error: `Range ${part} goes backwards; use ${end}-${start} instead.` };
|
|
2359
|
+
for (let value = start; value <= end; value += 1) {
|
|
2360
|
+
const error = addNumber(value);
|
|
2361
|
+
if (error) return { ok: false, error };
|
|
2362
|
+
}
|
|
2363
|
+
continue;
|
|
2364
|
+
}
|
|
2365
|
+
|
|
2366
|
+
if (/^\d+$/.test(part)) {
|
|
2367
|
+
const error = addNumber(Number(part));
|
|
2368
|
+
if (error) return { ok: false, error };
|
|
2369
|
+
continue;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
return {
|
|
2373
|
+
ok: false,
|
|
2374
|
+
error: `Could not parse selection item: ${part}. Use numbers and ranges like 1,3-5.`,
|
|
2375
|
+
};
|
|
2376
|
+
}
|
|
2377
|
+
|
|
2378
|
+
return selected.length > 0
|
|
2379
|
+
? { ok: true, indices: selected }
|
|
2380
|
+
: { ok: false, error: "Select at least one comment." };
|
|
1743
2381
|
}
|
|
1744
2382
|
|
|
1745
2383
|
async function promptForCommentSubset(
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
2384
|
+
ctx: ExtensionCommandContext,
|
|
2385
|
+
comments: CommandComment[],
|
|
2386
|
+
selectionPrompt: string,
|
|
1749
2387
|
): Promise<CommandComment[] | undefined> {
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
1761
|
-
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
2388
|
+
const inputPrompt = `${selectionPrompt}\n\nEnter comment numbers to investigate.`;
|
|
2389
|
+
const inputHint =
|
|
2390
|
+
comments.length <= MAX_COMMENTS
|
|
2391
|
+
? `Examples: 1,3-5 or all (max ${MAX_COMMENTS})`
|
|
2392
|
+
: `Examples: 1,3-5 (choose up to ${MAX_COMMENTS})`;
|
|
2393
|
+
|
|
2394
|
+
while (true) {
|
|
2395
|
+
const input = await ctx.ui.input(inputPrompt, inputHint);
|
|
2396
|
+
if (input === undefined) return undefined;
|
|
2397
|
+
const parsed = parseSelectionList(input, comments.length);
|
|
2398
|
+
if (!parsed.ok) {
|
|
2399
|
+
notifyCommand(ctx, parsed.error, "error");
|
|
2400
|
+
continue;
|
|
2401
|
+
}
|
|
2402
|
+
if (parsed.indices.length > MAX_COMMENTS) {
|
|
2403
|
+
notifyCommand(
|
|
2404
|
+
ctx,
|
|
2405
|
+
`triage_comments accepts at most ${MAX_COMMENTS} comments per run; choose a smaller subset.`,
|
|
2406
|
+
"error",
|
|
2407
|
+
);
|
|
2408
|
+
continue;
|
|
2409
|
+
}
|
|
2410
|
+
return parsed.indices.map((index) => comments[index]);
|
|
2411
|
+
}
|
|
1769
2412
|
}
|
|
1770
2413
|
|
|
1771
2414
|
async function choosePrComments(
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
2415
|
+
ctx: ExtensionCommandContext,
|
|
2416
|
+
pr: PullRequestContext,
|
|
2417
|
+
comments: CommandComment[],
|
|
2418
|
+
filterSummary?: AppliedInlineCommentFilter,
|
|
1776
2419
|
): Promise<CommandComment[] | undefined> {
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
1784
|
-
|
|
2420
|
+
const options =
|
|
2421
|
+
comments.length <= MAX_COMMENTS
|
|
2422
|
+
? ["Investigate all displayed comments", "Choose a subset", "Cancel"]
|
|
2423
|
+
: ["Choose a subset", "Cancel"];
|
|
2424
|
+
const selectionPrompt = formatFetchedCommentsForSelection(pr, comments, filterSummary);
|
|
2425
|
+
const decision = await ctx.ui.select(selectionPrompt, options);
|
|
2426
|
+
if (!decision || decision === "Cancel") return undefined;
|
|
2427
|
+
if (decision === "Investigate all displayed comments") return comments;
|
|
2428
|
+
return promptForCommentSubset(ctx, comments, selectionPrompt);
|
|
1785
2429
|
}
|
|
1786
2430
|
|
|
1787
2431
|
function createPastedComment(body: string): CommandComment {
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
2432
|
+
return {
|
|
2433
|
+
id: "pasted-feedback:1",
|
|
2434
|
+
body,
|
|
2435
|
+
metadata: {
|
|
2436
|
+
source: "pasted_feedback",
|
|
2437
|
+
displayNumber: 1,
|
|
2438
|
+
capturedBy: "/triage-comments",
|
|
2439
|
+
},
|
|
2440
|
+
sourceLabel: "pasted feedback",
|
|
2441
|
+
displayNumber: 1,
|
|
2442
|
+
sortIndex: 1,
|
|
2443
|
+
};
|
|
1800
2444
|
}
|
|
1801
2445
|
|
|
1802
2446
|
function toPayloadComment(comment: CommandComment): Record<string, unknown> {
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
2447
|
+
return compactRecord({
|
|
2448
|
+
id: comment.id,
|
|
2449
|
+
body: comment.body,
|
|
2450
|
+
path: comment.path,
|
|
2451
|
+
line: comment.line,
|
|
2452
|
+
startLine: comment.startLine,
|
|
2453
|
+
side: comment.side,
|
|
2454
|
+
diffHunk: comment.diffHunk,
|
|
2455
|
+
author: comment.author,
|
|
2456
|
+
url: comment.url,
|
|
2457
|
+
createdAt: comment.createdAt,
|
|
2458
|
+
metadata: comment.metadata,
|
|
2459
|
+
});
|
|
1816
2460
|
}
|
|
1817
2461
|
|
|
1818
2462
|
function buildCommandPayload(
|
|
1819
|
-
|
|
1820
|
-
|
|
2463
|
+
comments: CommandComment[],
|
|
2464
|
+
options: {
|
|
2465
|
+
pr?: PullRequestContext;
|
|
2466
|
+
totalDisplayed: number;
|
|
2467
|
+
source: "paste" | "pr";
|
|
2468
|
+
filterSummary?: AppliedInlineCommentFilter;
|
|
2469
|
+
},
|
|
1821
2470
|
): TriageCommandPayload {
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
}
|
|
1850
|
-
|
|
1851
|
-
function buildTriageUserPrompt(
|
|
1852
|
-
|
|
1853
|
-
|
|
2471
|
+
const prSelectionContext = options.filterSummary
|
|
2472
|
+
? `Selected by /triage-comments after fetching ${options.filterSummary.originalCount} PR comment(s), applying the inline review-comment filter, displaying ${options.totalDisplayed} comment(s), and receiving explicit user selection. ${formatInlineFilterContext(options.filterSummary)}`
|
|
2473
|
+
: `Selected by /triage-comments after displaying ${options.totalDisplayed} fetched PR comment(s) and receiving explicit user selection.`;
|
|
2474
|
+
const payload: TriageCommandPayload = {
|
|
2475
|
+
comments: comments.map(toPayloadComment),
|
|
2476
|
+
context:
|
|
2477
|
+
options.source === "pr"
|
|
2478
|
+
? `${prSelectionContext} Do not implement changes until the user chooses a handling option.`
|
|
2479
|
+
: "Pasted feedback captured by /triage-comments. Do not implement changes until the user chooses a handling option.",
|
|
2480
|
+
};
|
|
2481
|
+
|
|
2482
|
+
if (options.pr) {
|
|
2483
|
+
payload.pr = compactRecord({
|
|
2484
|
+
number: options.pr.number,
|
|
2485
|
+
title: options.pr.title,
|
|
2486
|
+
url: options.pr.url,
|
|
2487
|
+
repository: options.pr.repository,
|
|
2488
|
+
headRef: options.pr.headRef,
|
|
2489
|
+
headSha: options.pr.headSha,
|
|
2490
|
+
baseRef: options.pr.baseRef,
|
|
2491
|
+
baseSha: options.pr.baseSha,
|
|
2492
|
+
});
|
|
2493
|
+
const base = optionalRecord({ branch: options.pr.baseRef, sha: options.pr.baseSha });
|
|
2494
|
+
if (base) payload.base = base;
|
|
2495
|
+
}
|
|
2496
|
+
|
|
2497
|
+
return payload;
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
function buildTriageUserPrompt(
|
|
2501
|
+
payload: TriageCommandPayload,
|
|
2502
|
+
selectedCount: number,
|
|
2503
|
+
totalDisplayed: number,
|
|
2504
|
+
): string {
|
|
2505
|
+
const selectionNote =
|
|
2506
|
+
selectedCount === totalDisplayed
|
|
2507
|
+
? `${selectedCount} selected item(s)`
|
|
2508
|
+
: `${selectedCount} selected item(s) out of ${totalDisplayed} displayed`;
|
|
2509
|
+
return `Task: start read-only review-feedback triage for ${selectionNote}.\n\nCall the triage_comments tool with exactly this JSON payload before doing any analysis:\n\n\`\`\`json\n${JSON.stringify(payload, null, 2)}\n\`\`\`\n\nDo not edit files or implement changes as part of this request. After triage_comments returns, summarize its findings and ask which handling option to take before any implementation.`;
|
|
1854
2510
|
}
|
|
1855
2511
|
|
|
1856
2512
|
function sendTriageUserMessage(
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
2513
|
+
pi: ExtensionAPI,
|
|
2514
|
+
ctx: ExtensionCommandContext,
|
|
2515
|
+
payload: TriageCommandPayload,
|
|
2516
|
+
selectedCount: number,
|
|
2517
|
+
totalDisplayed: number,
|
|
1862
2518
|
): void {
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
1895
|
-
|
|
1896
|
-
|
|
1897
|
-
|
|
1898
|
-
|
|
1899
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
2519
|
+
const prompt = buildTriageUserPrompt(payload, selectedCount, totalDisplayed);
|
|
2520
|
+
if (ctx.isIdle()) pi.sendUserMessage(prompt);
|
|
2521
|
+
else pi.sendUserMessage(prompt, { deliverAs: "followUp" });
|
|
2522
|
+
notifyCommand(
|
|
2523
|
+
ctx,
|
|
2524
|
+
`Sent ${selectedCount} selected comment(s) to the main agent for triage_comments.`,
|
|
2525
|
+
"info",
|
|
2526
|
+
);
|
|
2527
|
+
}
|
|
2528
|
+
|
|
2529
|
+
async function runPasteMode(
|
|
2530
|
+
pi: ExtensionAPI,
|
|
2531
|
+
ctx: ExtensionCommandContext,
|
|
2532
|
+
prefill?: string,
|
|
2533
|
+
): Promise<void> {
|
|
2534
|
+
const feedback = await ctx.ui.editor("Paste feedback for /triage-comments", prefill ?? "");
|
|
2535
|
+
if (feedback === undefined) return;
|
|
2536
|
+
const body = feedback.trim();
|
|
2537
|
+
if (!body) {
|
|
2538
|
+
notifyCommand(
|
|
2539
|
+
ctx,
|
|
2540
|
+
"No feedback was provided; /triage-comments paste mode was cancelled.",
|
|
2541
|
+
"warning",
|
|
2542
|
+
);
|
|
2543
|
+
return;
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
const comments = [createPastedComment(body)];
|
|
2547
|
+
const payload = buildCommandPayload(comments, { totalDisplayed: 1, source: "paste" });
|
|
2548
|
+
sendTriageUserMessage(pi, ctx, payload, comments.length, 1);
|
|
2549
|
+
}
|
|
2550
|
+
|
|
2551
|
+
async function runPrMode(
|
|
2552
|
+
pi: ExtensionAPI,
|
|
2553
|
+
ctx: ExtensionCommandContext,
|
|
2554
|
+
target?: string,
|
|
2555
|
+
): Promise<void> {
|
|
2556
|
+
let prTarget = target?.trim();
|
|
2557
|
+
let detectedPr: PullRequestContext | undefined;
|
|
2558
|
+
|
|
2559
|
+
if (!prTarget) {
|
|
2560
|
+
ctx.ui.setStatus("triage-comments", "triage-comments: checking current branch PR…");
|
|
2561
|
+
detectedPr = await detectCurrentBranchPullRequest(pi, ctx);
|
|
2562
|
+
ctx.ui.setStatus("triage-comments", undefined);
|
|
2563
|
+
if (detectedPr) {
|
|
2564
|
+
prTarget = detectedPr.url ?? String(detectedPr.number);
|
|
2565
|
+
notifyCommand(
|
|
2566
|
+
ctx,
|
|
2567
|
+
`Detected PR ${detectedPr.repository}#${detectedPr.number} from the current branch.`,
|
|
2568
|
+
"info",
|
|
2569
|
+
);
|
|
2570
|
+
}
|
|
2571
|
+
}
|
|
2572
|
+
|
|
2573
|
+
if (!prTarget) {
|
|
2574
|
+
const input = await ctx.ui.input(
|
|
2575
|
+
"PR URL or number",
|
|
2576
|
+
"For example: 123, #123, or https://github.com/owner/repo/pull/123",
|
|
2577
|
+
);
|
|
2578
|
+
if (input === undefined) return;
|
|
2579
|
+
prTarget = input.trim();
|
|
2580
|
+
}
|
|
2581
|
+
|
|
2582
|
+
if (!normalizePrTarget(prTarget)) {
|
|
2583
|
+
notifyCommand(
|
|
2584
|
+
ctx,
|
|
2585
|
+
"PR input must be a PR number (for example, 123 or #123) or a GitHub pull request URL.",
|
|
2586
|
+
"error",
|
|
2587
|
+
);
|
|
2588
|
+
return;
|
|
2589
|
+
}
|
|
2590
|
+
|
|
2591
|
+
ctx.ui.setStatus("triage-comments", "triage-comments: fetching PR comments…");
|
|
2592
|
+
try {
|
|
2593
|
+
notifyCommand(ctx, "Fetching PR comments with gh (read-only)…", "info");
|
|
2594
|
+
const { pr, comments } = detectedPr
|
|
2595
|
+
? { pr: detectedPr, comments: await fetchCommentsForPullRequest(pi, ctx, detectedPr) }
|
|
2596
|
+
: await fetchPrCommentsForTriage(pi, ctx, prTarget);
|
|
2597
|
+
if (comments.length === 0) {
|
|
2598
|
+
notifyCommand(
|
|
2599
|
+
ctx,
|
|
2600
|
+
`No review comments, issue comments, or review bodies were found for ${pr.repository}#${pr.number}.`,
|
|
2601
|
+
"info",
|
|
2602
|
+
);
|
|
2603
|
+
return;
|
|
2604
|
+
}
|
|
2605
|
+
|
|
2606
|
+
const filter = await promptForInlineCommentFilter(ctx);
|
|
2607
|
+
if (!filter) return;
|
|
2608
|
+
const filtered = applyInlineCommentFilter(comments, filter);
|
|
2609
|
+
if (filtered.comments.length === 0) {
|
|
2610
|
+
notifyCommand(
|
|
2611
|
+
ctx,
|
|
2612
|
+
`${formatInlineFilterNotice(filtered.summary)} No comments remain to send to triage_comments.`,
|
|
2613
|
+
"info",
|
|
2614
|
+
);
|
|
2615
|
+
return;
|
|
2616
|
+
}
|
|
2617
|
+
|
|
2618
|
+
const selected = await choosePrComments(ctx, pr, filtered.comments, filtered.summary);
|
|
2619
|
+
if (!selected || selected.length === 0) return;
|
|
2620
|
+
const payload = buildCommandPayload(selected, {
|
|
2621
|
+
pr,
|
|
2622
|
+
totalDisplayed: filtered.comments.length,
|
|
2623
|
+
source: "pr",
|
|
2624
|
+
filterSummary: filtered.summary,
|
|
2625
|
+
});
|
|
2626
|
+
sendTriageUserMessage(pi, ctx, payload, selected.length, filtered.comments.length);
|
|
2627
|
+
} catch (error) {
|
|
2628
|
+
notifyCommand(ctx, formatErrorMessage(error), "error");
|
|
2629
|
+
} finally {
|
|
2630
|
+
ctx.ui.setStatus("triage-comments", undefined);
|
|
2631
|
+
}
|
|
1936
2632
|
}
|
|
1937
2633
|
|
|
1938
2634
|
export const __test__ = {
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
2635
|
+
SUBAGENT_MCP_TOOLS,
|
|
2636
|
+
isMcpCompatibleVersion,
|
|
2637
|
+
resolveSubagentProjectTrusted,
|
|
2638
|
+
createMcpSubagentFactories,
|
|
2639
|
+
disposeSubagentSession,
|
|
2640
|
+
createTriageRuntimeGuardExtension,
|
|
2641
|
+
applyInlineCommentFilter,
|
|
2642
|
+
assertToolPathInsideCwd,
|
|
2643
|
+
resolveToCwd,
|
|
2644
|
+
buildSafeGitCommand,
|
|
2645
|
+
buildCommandPayload,
|
|
2646
|
+
buildSystemPrompt,
|
|
2647
|
+
buildUserPrompt,
|
|
2648
|
+
ensureImplementationNote,
|
|
2649
|
+
formatFetchedCommentsForSelection,
|
|
2650
|
+
formatInlineFilterContext,
|
|
2651
|
+
formatToolCall,
|
|
2652
|
+
getBlockedBashReason,
|
|
2653
|
+
normalizeInput,
|
|
2654
|
+
parseSelectionList,
|
|
2655
|
+
parseTriageCommandArgs,
|
|
2656
|
+
prepareArguments,
|
|
2657
|
+
aggregateAssistantUsage,
|
|
2658
|
+
addSessionEventUsage,
|
|
2659
|
+
inspectFinalAssistant,
|
|
2660
|
+
classifyRunFailure,
|
|
1959
2661
|
};
|
|
1960
2662
|
|
|
1961
2663
|
export default function triageCommentsExtension(pi: ExtensionAPI) {
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2047
|
-
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
|
|
2052
|
-
|
|
2053
|
-
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
2062
|
-
|
|
2063
|
-
|
|
2064
|
-
|
|
2065
|
-
|
|
2066
|
-
|
|
2067
|
-
|
|
2068
|
-
|
|
2069
|
-
|
|
2070
|
-
|
|
2071
|
-
|
|
2072
|
-
|
|
2073
|
-
|
|
2074
|
-
|
|
2075
|
-
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
2079
|
-
|
|
2080
|
-
|
|
2081
|
-
|
|
2082
|
-
|
|
2083
|
-
|
|
2084
|
-
|
|
2085
|
-
|
|
2086
|
-
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2126
|
-
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
2203
|
-
|
|
2204
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2664
|
+
pi.on("tool_result", async (event) => {
|
|
2665
|
+
if (event.toolName !== "triage_comments") return undefined;
|
|
2666
|
+
const details = event.details as { status?: unknown } | undefined;
|
|
2667
|
+
if (details?.status !== "error") return undefined;
|
|
2668
|
+
return { isError: true };
|
|
2669
|
+
});
|
|
2670
|
+
|
|
2671
|
+
pi.registerCommand("triage-comments", {
|
|
2672
|
+
description:
|
|
2673
|
+
"Collect pasted feedback or PR comments, then start a triage_comments investigation",
|
|
2674
|
+
getArgumentCompletions: (prefix) => {
|
|
2675
|
+
const commands = ["paste", "pr", "help"];
|
|
2676
|
+
const normalized = prefix.trim().toLowerCase();
|
|
2677
|
+
const matches = commands.filter((command) => command.startsWith(normalized));
|
|
2678
|
+
return matches.length > 0 ? matches.map((value) => ({ value, label: value })) : null;
|
|
2679
|
+
},
|
|
2680
|
+
handler: async (args, ctx) => {
|
|
2681
|
+
const parsed = parseTriageCommandArgs(args);
|
|
2682
|
+
if (parsed.help) {
|
|
2683
|
+
notifyCommand(ctx, TRIAGE_COMMAND_USAGE, "info");
|
|
2684
|
+
return;
|
|
2685
|
+
}
|
|
2686
|
+
|
|
2687
|
+
if (parsed.error) {
|
|
2688
|
+
notifyCommand(ctx, parsed.error, "error");
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
2691
|
+
|
|
2692
|
+
if (!ctx.hasUI) {
|
|
2693
|
+
notifyCommand(
|
|
2694
|
+
ctx,
|
|
2695
|
+
`${TRIAGE_COMMAND_USAGE}\n\nThis intake flow requires the /triage-comments interactive UI for the editor, PR comment display, and all/subset confirmation.`,
|
|
2696
|
+
"warning",
|
|
2697
|
+
);
|
|
2698
|
+
return;
|
|
2699
|
+
}
|
|
2700
|
+
|
|
2701
|
+
let mode = parsed.mode;
|
|
2702
|
+
if (!mode) {
|
|
2703
|
+
const selection = await ctx.ui.select("/triage-comments: choose feedback source", [
|
|
2704
|
+
"Paste feedback",
|
|
2705
|
+
"Fetch comments from a PR",
|
|
2706
|
+
"Cancel",
|
|
2707
|
+
]);
|
|
2708
|
+
if (!selection || selection === "Cancel") return;
|
|
2709
|
+
mode = selection === "Paste feedback" ? "paste" : "pr";
|
|
2710
|
+
}
|
|
2711
|
+
|
|
2712
|
+
if (mode === "paste") {
|
|
2713
|
+
await runPasteMode(pi, ctx, parsed.pastePrefill);
|
|
2714
|
+
return;
|
|
2715
|
+
}
|
|
2716
|
+
|
|
2717
|
+
await runPrMode(pi, ctx, parsed.target);
|
|
2718
|
+
},
|
|
2719
|
+
});
|
|
2720
|
+
|
|
2721
|
+
pi.registerTool({
|
|
2722
|
+
name: "triage_comments",
|
|
2723
|
+
label: "Triage comments",
|
|
2724
|
+
description:
|
|
2725
|
+
"Isolated subagent with read-only built-in tools and configured MCP tools that triages selected PR review comments against the local checkout and git context. It classifies each comment with evidence, suggests review responses, and proposes handling options without implementing changes.",
|
|
2726
|
+
promptSnippet:
|
|
2727
|
+
"Triage selected PR review comments in an isolated subagent using read-only built-in tools plus any configured MCP tools; returns verdicts, evidence, response text, and handling options without editing files.",
|
|
2728
|
+
promptGuidelines: [
|
|
2729
|
+
"Use triage_comments when selected PR review comments need evidence-based classification against the local checkout.",
|
|
2730
|
+
"Do not use triage_comments to implement changes; ask the user which valid handling option to take after triage.",
|
|
2731
|
+
],
|
|
2732
|
+
parameters: TriageCommentsParams,
|
|
2733
|
+
prepareArguments,
|
|
2734
|
+
|
|
2735
|
+
async execute(_toolCallId, params, signal, onUpdate, ctx) {
|
|
2736
|
+
if (!ctx.model)
|
|
2737
|
+
throw new Error("triage_comments needs an active model, but ctx.model is unavailable.");
|
|
2738
|
+
const input = normalizeInput(params);
|
|
2739
|
+
const cwd = path.resolve(ctx.cwd);
|
|
2740
|
+
const details: TriageDetails = {
|
|
2741
|
+
status: "running",
|
|
2742
|
+
cwd,
|
|
2743
|
+
commentCount: input.comments.length,
|
|
2744
|
+
turns: 0,
|
|
2745
|
+
toolCalls: [],
|
|
2746
|
+
startedAt: Date.now(),
|
|
2747
|
+
};
|
|
2748
|
+
|
|
2749
|
+
let lastContent = "(triaging comments...)";
|
|
2750
|
+
let session: AgentSession | undefined;
|
|
2751
|
+
let unsubscribe: (() => void) | undefined;
|
|
2752
|
+
let runTimeout: NodeJS.Timeout | undefined;
|
|
2753
|
+
let abortListenerAdded = false;
|
|
2754
|
+
let callerAborted = Boolean(signal?.aborted);
|
|
2755
|
+
const usage = createEmptyUsage();
|
|
2756
|
+
|
|
2757
|
+
const emit = () => {
|
|
2758
|
+
onUpdate?.({ content: [{ type: "text", text: lastContent }], details });
|
|
2759
|
+
};
|
|
2760
|
+
|
|
2761
|
+
const abortForCleanup = () => {
|
|
2762
|
+
void session?.abort();
|
|
2763
|
+
};
|
|
2764
|
+
|
|
2765
|
+
const abortFromCaller = () => {
|
|
2766
|
+
callerAborted = true;
|
|
2767
|
+
details.status = "aborted";
|
|
2768
|
+
details.endedAt = Date.now();
|
|
2769
|
+
lastContent = "Aborted";
|
|
2770
|
+
emit();
|
|
2771
|
+
abortForCleanup();
|
|
2772
|
+
};
|
|
2773
|
+
|
|
2774
|
+
if (signal?.aborted) abortFromCaller();
|
|
2775
|
+
if (signal && !signal.aborted) {
|
|
2776
|
+
signal.addEventListener("abort", abortFromCaller);
|
|
2777
|
+
abortListenerAdded = true;
|
|
2778
|
+
}
|
|
2779
|
+
|
|
2780
|
+
try {
|
|
2781
|
+
emit();
|
|
2782
|
+
|
|
2783
|
+
const systemPrompt = buildSystemPrompt({
|
|
2784
|
+
cwd,
|
|
2785
|
+
maxTurns: MAX_TURNS,
|
|
2786
|
+
maxRunSeconds: Math.round(MAX_RUN_MS / 1000),
|
|
2787
|
+
});
|
|
2788
|
+
|
|
2789
|
+
// Keep the guarded subagent from inheriting user/project shell prefix or shell path settings.
|
|
2790
|
+
const projectTrusted = resolveSubagentProjectTrusted(ctx, cwd);
|
|
2791
|
+
const isolatedSettingsManager = SettingsManager.inMemory({}, { projectTrusted });
|
|
2792
|
+
const resourceLoader = new DefaultResourceLoader({
|
|
2793
|
+
cwd,
|
|
2794
|
+
agentDir: getAgentDir(),
|
|
2795
|
+
settingsManager: isolatedSettingsManager,
|
|
2796
|
+
noExtensions: true,
|
|
2797
|
+
noSkills: true,
|
|
2798
|
+
noPromptTemplates: true,
|
|
2799
|
+
noThemes: true,
|
|
2800
|
+
noContextFiles: true,
|
|
2801
|
+
extensionFactories: [
|
|
2802
|
+
createTriageRuntimeGuardExtension({ cwd, maxTurns: MAX_TURNS }),
|
|
2803
|
+
...createMcpSubagentFactories(),
|
|
2804
|
+
],
|
|
2805
|
+
systemPromptOverride: () => systemPrompt,
|
|
2806
|
+
appendSystemPromptOverride: () => [],
|
|
2807
|
+
skillsOverride: () => ({ skills: [], diagnostics: [] }),
|
|
2808
|
+
promptsOverride: () => ({ prompts: [], diagnostics: [] }),
|
|
2809
|
+
themesOverride: () => ({ themes: [], diagnostics: [] }),
|
|
2810
|
+
agentsFilesOverride: () => ({ agentsFiles: [] }),
|
|
2811
|
+
});
|
|
2812
|
+
|
|
2813
|
+
await resourceLoader.reload();
|
|
2814
|
+
|
|
2815
|
+
const created = await createAgentSession({
|
|
2816
|
+
cwd,
|
|
2817
|
+
...getModelRuntimeOption(ctx),
|
|
2818
|
+
resourceLoader,
|
|
2819
|
+
settingsManager: isolatedSettingsManager,
|
|
2820
|
+
sessionManager: SessionManager.inMemory(cwd),
|
|
2821
|
+
model: ctx.model,
|
|
2822
|
+
thinkingLevel: pi.getThinkingLevel(),
|
|
2823
|
+
tools: ["read", "grep", "find", "ls", "bash", ...SUBAGENT_MCP_TOOLS],
|
|
2824
|
+
});
|
|
2825
|
+
|
|
2826
|
+
// Assign session BEFORE bindExtensions so the finally block can dispose it
|
|
2827
|
+
// even if binding fails partway through (e.g. a server starts then an
|
|
2828
|
+
// extension's session_start throws).
|
|
2829
|
+
session = created.session;
|
|
2830
|
+
// Connect extensions (MCP servers) before prompting. Feature-detected: Pi >=0.99.0.
|
|
2831
|
+
await (created.session as any).bindExtensions?.({ mode: "json" });
|
|
2832
|
+
unsubscribe = session.subscribe((event) => {
|
|
2833
|
+
addSessionEventUsage(usage, event);
|
|
2834
|
+
switch (event.type) {
|
|
2835
|
+
case "turn_end":
|
|
2836
|
+
details.turns += 1;
|
|
2837
|
+
emit();
|
|
2838
|
+
break;
|
|
2839
|
+
case "tool_execution_start":
|
|
2840
|
+
details.toolCalls.push({
|
|
2841
|
+
id: event.toolCallId,
|
|
2842
|
+
name: event.toolName,
|
|
2843
|
+
args: event.args,
|
|
2844
|
+
startedAt: Date.now(),
|
|
2845
|
+
});
|
|
2846
|
+
if (details.toolCalls.length > MAX_TOOL_CALLS_TO_KEEP) {
|
|
2847
|
+
details.toolCalls.splice(0, details.toolCalls.length - MAX_TOOL_CALLS_TO_KEEP);
|
|
2848
|
+
}
|
|
2849
|
+
emit();
|
|
2850
|
+
break;
|
|
2851
|
+
case "tool_execution_end": {
|
|
2852
|
+
const call = details.toolCalls.find((item) => item.id === event.toolCallId);
|
|
2853
|
+
if (call) {
|
|
2854
|
+
call.endedAt = Date.now();
|
|
2855
|
+
call.isError = event.isError;
|
|
2856
|
+
}
|
|
2857
|
+
emit();
|
|
2858
|
+
break;
|
|
2859
|
+
}
|
|
2860
|
+
}
|
|
2861
|
+
});
|
|
2862
|
+
|
|
2863
|
+
if (!callerAborted) {
|
|
2864
|
+
const promptPromise = session.prompt(buildUserPrompt(input, cwd), {
|
|
2865
|
+
expandPromptTemplates: false,
|
|
2866
|
+
});
|
|
2867
|
+
const timeoutPromise = new Promise<never>((_resolve, reject) => {
|
|
2868
|
+
runTimeout = setTimeout(() => {
|
|
2869
|
+
abortForCleanup();
|
|
2870
|
+
reject(
|
|
2871
|
+
new Error(
|
|
2872
|
+
`triage_comments timed out after ${Math.round(MAX_RUN_MS / 1000)} seconds.`,
|
|
2873
|
+
),
|
|
2874
|
+
);
|
|
2875
|
+
}, MAX_RUN_MS);
|
|
2876
|
+
});
|
|
2877
|
+
await Promise.race([promptPromise, timeoutPromise]);
|
|
2878
|
+
}
|
|
2879
|
+
|
|
2880
|
+
const outcome = session
|
|
2881
|
+
? inspectFinalAssistant(session.state.messages)
|
|
2882
|
+
: {
|
|
2883
|
+
ok: false as const,
|
|
2884
|
+
reason: "the isolated triage_comments session produced no assistant message",
|
|
2885
|
+
};
|
|
2886
|
+
if (outcome.ok) {
|
|
2887
|
+
lastContent = ensureImplementationNote(outcome.answer);
|
|
2888
|
+
details.status = "done";
|
|
2889
|
+
} else if (callerAborted) {
|
|
2890
|
+
lastContent = "Aborted";
|
|
2891
|
+
details.status = "aborted";
|
|
2892
|
+
} else {
|
|
2893
|
+
throw new Error(outcome.reason);
|
|
2894
|
+
}
|
|
2895
|
+
details.endedAt = Date.now();
|
|
2896
|
+
emit();
|
|
2897
|
+
|
|
2898
|
+
return {
|
|
2899
|
+
content: [{ type: "text", text: lastContent }],
|
|
2900
|
+
details,
|
|
2901
|
+
usage,
|
|
2902
|
+
};
|
|
2903
|
+
} catch (error) {
|
|
2904
|
+
const failure = classifyRunFailure(error, callerAborted);
|
|
2905
|
+
const { message } = failure;
|
|
2906
|
+
details.status = failure.status;
|
|
2907
|
+
details.error = failure.error;
|
|
2908
|
+
details.endedAt = Date.now();
|
|
2909
|
+
lastContent = message;
|
|
2910
|
+
emit();
|
|
2911
|
+
|
|
2912
|
+
return {
|
|
2913
|
+
content: [{ type: "text", text: message }],
|
|
2914
|
+
details,
|
|
2915
|
+
usage,
|
|
2916
|
+
};
|
|
2917
|
+
} finally {
|
|
2918
|
+
if (runTimeout) clearTimeout(runTimeout);
|
|
2919
|
+
if (signal && abortListenerAdded) signal.removeEventListener("abort", abortFromCaller);
|
|
2920
|
+
unsubscribe?.();
|
|
2921
|
+
await disposeSubagentSession(session);
|
|
2922
|
+
}
|
|
2923
|
+
},
|
|
2924
|
+
|
|
2925
|
+
renderCall(args, theme) {
|
|
2926
|
+
const comments = Array.isArray((args as { comments?: unknown })?.comments)
|
|
2927
|
+
? (args as { comments: unknown[] }).comments
|
|
2928
|
+
: [];
|
|
2929
|
+
const first = comments[0];
|
|
2930
|
+
const body =
|
|
2931
|
+
typeof first === "string" ? first : (asTrimmedString(asRecord(first)?.body) ?? "");
|
|
2932
|
+
const pr = asRecord((args as { pr?: unknown })?.pr);
|
|
2933
|
+
const prLabel = pr?.number ? `PR #${pr.number}` : pr?.url ? "PR context" : "no PR context";
|
|
2934
|
+
return new Text(
|
|
2935
|
+
`${theme.fg("muted", `${comments.length} comments • ${prLabel}`)} · ${theme.fg("toolOutput", shorten(body, 90))}`,
|
|
2936
|
+
0,
|
|
2937
|
+
0,
|
|
2938
|
+
);
|
|
2939
|
+
},
|
|
2940
|
+
|
|
2941
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
2942
|
+
const details = result.details as TriageDetails | undefined;
|
|
2943
|
+
if (!details) {
|
|
2944
|
+
const first = result.content[0];
|
|
2945
|
+
return new Text(first?.type === "text" ? first.text : "(no output)", 0, 0);
|
|
2946
|
+
}
|
|
2947
|
+
|
|
2948
|
+
const status = isPartial ? "running" : details.status;
|
|
2949
|
+
const icon =
|
|
2950
|
+
status === "done"
|
|
2951
|
+
? theme.fg("success", "✓")
|
|
2952
|
+
: status === "error"
|
|
2953
|
+
? theme.fg("error", "✗")
|
|
2954
|
+
: status === "aborted"
|
|
2955
|
+
? theme.fg("warning", "◼")
|
|
2956
|
+
: theme.fg("warning", "⏳");
|
|
2957
|
+
const header = `${icon} ${theme.fg("toolTitle", theme.bold("triage_comments "))}${theme.fg(
|
|
2958
|
+
"dim",
|
|
2959
|
+
`${details.commentCount} comments • ${details.turns} turns • ${details.toolCalls.length} tools`,
|
|
2960
|
+
)}`;
|
|
2961
|
+
const cwdLine = `${theme.fg("muted", "checkout: ")}${theme.fg("toolOutput", details.cwd)}`;
|
|
2962
|
+
const answer =
|
|
2963
|
+
(result.content[0]?.type === "text"
|
|
2964
|
+
? result.content[0].text
|
|
2965
|
+
: renderAnswer(details)
|
|
2966
|
+
).trim() || "(no output)";
|
|
2967
|
+
|
|
2968
|
+
const toolLines = details.toolCalls.slice(expanded ? 0 : -6).map((call) => {
|
|
2969
|
+
const callIcon = call.isError ? theme.fg("error", "✗") : theme.fg("dim", "→");
|
|
2970
|
+
return `${callIcon} ${theme.fg("toolOutput", formatToolCall(call))}`;
|
|
2971
|
+
});
|
|
2972
|
+
if (!expanded && details.toolCalls.length > 6) toolLines.unshift(theme.fg("muted", "…"));
|
|
2973
|
+
|
|
2974
|
+
if (status === "running") {
|
|
2975
|
+
const parts = [header, cwdLine];
|
|
2976
|
+
if (toolLines.length) parts.push("", theme.fg("muted", "Read-only checks:"), ...toolLines);
|
|
2977
|
+
parts.push("", theme.fg("muted", "Triaging comments…"));
|
|
2978
|
+
return new Text(parts.join("\n"), 0, 0);
|
|
2979
|
+
}
|
|
2980
|
+
|
|
2981
|
+
if (!expanded) {
|
|
2982
|
+
const lines = answer.split("\n");
|
|
2983
|
+
const previewLines = lines.slice(0, COLLAPSED_PREVIEW_LINES);
|
|
2984
|
+
const parts = [header, cwdLine, "", theme.fg("toolOutput", previewLines.join("\n"))];
|
|
2985
|
+
if (lines.length > previewLines.length) parts.push(theme.fg("muted", "(Ctrl+O to expand)"));
|
|
2986
|
+
if (toolLines.length) parts.push("", theme.fg("muted", "Read-only checks:"), ...toolLines);
|
|
2987
|
+
return new Text(parts.join("\n"), 0, 0);
|
|
2988
|
+
}
|
|
2989
|
+
|
|
2990
|
+
const container = new Container();
|
|
2991
|
+
container.addChild(new Text(header, 0, 0));
|
|
2992
|
+
container.addChild(new Text(cwdLine, 0, 0));
|
|
2993
|
+
if (toolLines.length) {
|
|
2994
|
+
container.addChild(new Spacer(1));
|
|
2995
|
+
container.addChild(
|
|
2996
|
+
new Text([theme.fg("muted", "Read-only checks:"), ...toolLines].join("\n"), 0, 0),
|
|
2997
|
+
);
|
|
2998
|
+
}
|
|
2999
|
+
container.addChild(new Spacer(1));
|
|
3000
|
+
container.addChild(new Markdown(answer, 0, 0, getMarkdownTheme()));
|
|
3001
|
+
return container;
|
|
3002
|
+
},
|
|
3003
|
+
});
|
|
2272
3004
|
}
|