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