@diegopetrucci/pi-extensions 0.1.26 → 0.1.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md
CHANGED
|
@@ -15,7 +15,7 @@ A collection of [pi](https://github.com/earendil-works/pi-mono) agent extensions
|
|
|
15
15
|
- [`permission-gate`](./extensions/permission-gate): Prompts for confirmation before dangerous bash commands like `rm -rf`, `sudo`, and `chmod 777`.
|
|
16
16
|
- [`quiet-tools`](./extensions/quiet-tools): Renders collapsed built-in tool rows as a one-line invocation plus an expand hint without changing model-visible tool results; toggle temporarily with `/quiet-tools`.
|
|
17
17
|
- [`todo`](./extensions/todo): Adds a branch-aware `todo` tool for the agent and a `/todos` viewer for users.
|
|
18
|
-
- [`triage-comments`](./extensions/triage-comments): Adds `/triage-comments` and a read-only `triage_comments` subagent tool that
|
|
18
|
+
- [`triage-comments`](./extensions/triage-comments): Adds `/triage-comments` and a read-only `triage_comments` subagent tool that can auto-detect the current branch's PR, filter resolved/outdated inline comments, classify selected review comments with evidence, and suggest handling options without implementing changes.
|
|
19
19
|
|
|
20
20
|
(For the full list of pi extensions I use, [check out my dotfiles](https://github.com/diegopetrucci/dot/blob/main/.pi/agent/settings.json).)
|
|
21
21
|
|
|
@@ -37,6 +37,7 @@ The extension registers `/triage-comments` as an interactive intake flow.
|
|
|
37
37
|
```text
|
|
38
38
|
/triage-comments
|
|
39
39
|
/triage-comments paste
|
|
40
|
+
/triage-comments pr
|
|
40
41
|
/triage-comments pr 123
|
|
41
42
|
/triage-comments pr https://github.com/owner/repo/pull/123
|
|
42
43
|
/triage-comments 123
|
|
@@ -44,8 +45,10 @@ The extension registers `/triage-comments` as an interactive intake flow.
|
|
|
44
45
|
|
|
45
46
|
- With no arguments, The Last Harness asks whether to paste feedback or fetch PR comments.
|
|
46
47
|
- `paste` opens an editor for multiline reviewer feedback, then sends one selected feedback item to the main agent.
|
|
47
|
-
- `pr
|
|
48
|
-
-
|
|
48
|
+
- `pr` with no explicit target first tries to detect an existing PR for the current named non-`main` git branch using read-only `git` and `gh pr view` calls. If the branch is `main`, detached, outside a git repository, `gh` is unavailable or unauthenticated, or no PR is found, it falls back to the PR URL/number prompt.
|
|
49
|
+
- `pr <PR URL or number>` and a bare PR URL/number fetch that explicit PR directly, display PR review comments, PR issue comments, and review bodies with `gh` as numbered items with stable IDs, and ask whether to investigate all displayed comments or an explicit subset such as `1,3-5`.
|
|
50
|
+
- Before displaying fetched PR comments, PR mode asks whether to show all comments or hide resolved inline review comments, outdated inline review comments, or both. This filter applies only to inline review comments because GitHub exposes resolved/outdated state at the review-thread level; PR issue comments and review bodies always remain visible, and inline comments without thread metadata remain visible.
|
|
51
|
+
- If more than 50 comments are displayed after filtering, you must choose a subset of at most 50 comments.
|
|
49
52
|
- The command sends a normal user message instructing the main agent to call `triage_comments` with the selected payload. It does not directly edit files or post GitHub replies.
|
|
50
53
|
|
|
51
54
|
The slash command requires interactive UI mode for the editor, PR comment display, and all/subset confirmation. In non-UI modes it prints usage instead of running the intake flow.
|
|
@@ -57,9 +60,9 @@ PR mode requires:
|
|
|
57
60
|
- running inside a git checkout;
|
|
58
61
|
- GitHub CLI `gh` installed and on `PATH`;
|
|
59
62
|
- `gh auth login` completed for the target host/repository, including private repositories;
|
|
60
|
-
- a PR number that `gh pr view` can resolve from the current checkout,
|
|
63
|
+
- a PR number that `gh pr view` can resolve from the current checkout, a full GitHub PR URL, or a current non-`main` branch with an existing PR that `gh pr view` can resolve.
|
|
61
64
|
|
|
62
|
-
The command uses read-only `gh` calls to fetch PR metadata, review comments, issue comments, and review
|
|
65
|
+
The command uses read-only `git`/`gh` calls to detect the current branch PR when no target is supplied, then read-only `gh` calls to fetch PR metadata, review comments, PR issue comments, review bodies, and best-effort review-thread resolved/outdated metadata. It does not post comments, submit reviews, checkout branches, or mutate GitHub.
|
|
63
66
|
|
|
64
67
|
## `triage_comments` tool behavior
|
|
65
68
|
|
|
@@ -131,5 +134,6 @@ Do not implement changes from this triage automatically; ask the parent/user whi
|
|
|
131
134
|
- At most 50 comments can be triaged in one tool call.
|
|
132
135
|
- The subagent has an 8-turn and 8-minute budget.
|
|
133
136
|
- PR mode depends on the GitHub API data available to `gh`; authentication, permissions, host configuration, and API availability can affect what is fetched.
|
|
137
|
+
- Resolved/outdated filtering is best effort and only applies to inline review comments. If GitHub does not return review-thread metadata for an inline comment, `/triage-comments` keeps it visible and labels the thread state as unavailable.
|
|
134
138
|
- The tool validates against the current local checkout. If the checkout does not match the PR head/base or supplied diff context, the result may be `needs clarification` or call out stale/missing evidence.
|
|
135
139
|
- Paste mode treats the editor contents as one feedback item; use PR mode or direct tool calls for multiple separately numbered comments.
|
|
@@ -20,9 +20,34 @@ const MAX_RUN_MS = 8 * 60 * 1000;
|
|
|
20
20
|
const DEFAULT_BASH_TIMEOUT_SECONDS = 30;
|
|
21
21
|
const COLLAPSED_PREVIEW_LINES = 18;
|
|
22
22
|
const GH_COMMAND_TIMEOUT_MS = 30_000;
|
|
23
|
+
const GH_PR_METADATA_FIELDS = 'number,title,url,headRefName,headRefOid,baseRefName,baseRefOid';
|
|
24
|
+
const GH_REVIEW_THREADS_GRAPHQL_QUERY = `
|
|
25
|
+
query TriageCommentsReviewThreads($owner: String!, $name: String!, $number: Int!, $after: String) {
|
|
26
|
+
repository(owner: $owner, name: $name) {
|
|
27
|
+
pullRequest(number: $number) {
|
|
28
|
+
reviewThreads(first: 100, after: $after) {
|
|
29
|
+
pageInfo {
|
|
30
|
+
hasNextPage
|
|
31
|
+
endCursor
|
|
32
|
+
}
|
|
33
|
+
nodes {
|
|
34
|
+
id
|
|
35
|
+
isResolved
|
|
36
|
+
isOutdated
|
|
37
|
+
comments(first: 100) {
|
|
38
|
+
nodes {
|
|
39
|
+
id
|
|
40
|
+
databaseId
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}`;
|
|
23
48
|
const COMMENT_DISPLAY_BODY_LIMIT = 1200;
|
|
24
49
|
const TRIAGE_COMMAND_USAGE =
|
|
25
|
-
"Usage: /triage-comments [paste | pr <PR URL or number>]\nInteractive UI mode lets you paste feedback or fetch PR comments, then confirm all comments or choose a subset such as 1,3-5.";
|
|
50
|
+
"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.";
|
|
26
51
|
const IMPLEMENTATION_NOTE =
|
|
27
52
|
"Do not implement changes from this triage automatically; ask the parent/user which option to take before implementation.";
|
|
28
53
|
|
|
@@ -185,6 +210,10 @@ function asFiniteNumber(value: unknown): number | undefined {
|
|
|
185
210
|
return undefined;
|
|
186
211
|
}
|
|
187
212
|
|
|
213
|
+
function asBoolean(value: unknown): boolean | undefined {
|
|
214
|
+
return typeof value === "boolean" ? value : undefined;
|
|
215
|
+
}
|
|
216
|
+
|
|
188
217
|
function normalizeComment(raw: unknown, index: number): NormalizedComment | undefined {
|
|
189
218
|
if (typeof raw === "string") {
|
|
190
219
|
const body = raw.trim();
|
|
@@ -855,6 +884,34 @@ type PullRequestContext = {
|
|
|
855
884
|
baseSha?: string;
|
|
856
885
|
};
|
|
857
886
|
|
|
887
|
+
type ReviewThreadState = {
|
|
888
|
+
threadId?: string;
|
|
889
|
+
isResolved?: boolean;
|
|
890
|
+
isOutdated?: boolean;
|
|
891
|
+
metadataAvailable: boolean;
|
|
892
|
+
};
|
|
893
|
+
|
|
894
|
+
type ReviewThreadStateLookup = {
|
|
895
|
+
byDatabaseId: Map<number, ReviewThreadState>;
|
|
896
|
+
byNodeId: Map<string, ReviewThreadState>;
|
|
897
|
+
};
|
|
898
|
+
|
|
899
|
+
type InlineCommentFilter = {
|
|
900
|
+
hideResolved: boolean;
|
|
901
|
+
hideOutdated: boolean;
|
|
902
|
+
label: string;
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
type AppliedInlineCommentFilter = {
|
|
906
|
+
filter: InlineCommentFilter;
|
|
907
|
+
originalCount: number;
|
|
908
|
+
displayedCount: number;
|
|
909
|
+
hiddenInlineCount: number;
|
|
910
|
+
hiddenResolvedInlineCount: number;
|
|
911
|
+
hiddenOutdatedInlineCount: number;
|
|
912
|
+
keptInlineWithoutThreadMetadataCount: number;
|
|
913
|
+
};
|
|
914
|
+
|
|
858
915
|
type CommandComment = {
|
|
859
916
|
id: string;
|
|
860
917
|
body: string;
|
|
@@ -866,6 +923,7 @@ type CommandComment = {
|
|
|
866
923
|
author?: string;
|
|
867
924
|
url?: string;
|
|
868
925
|
createdAt?: string;
|
|
926
|
+
reviewThread?: ReviewThreadState;
|
|
869
927
|
metadata: Record<string, unknown>;
|
|
870
928
|
sourceLabel: string;
|
|
871
929
|
displayNumber?: number;
|
|
@@ -1097,7 +1155,107 @@ function reviewHtmlUrl(record: Record<string, unknown>): string | undefined {
|
|
|
1097
1155
|
return asTrimmedString(record.html_url ?? record.htmlUrl ?? html?.href);
|
|
1098
1156
|
}
|
|
1099
1157
|
|
|
1100
|
-
function
|
|
1158
|
+
function createReviewThreadStateLookup(): ReviewThreadStateLookup {
|
|
1159
|
+
return { byDatabaseId: new Map(), byNodeId: new Map() };
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function reviewThreadStateForComment(raw: unknown, lookup: ReviewThreadStateLookup): ReviewThreadState {
|
|
1163
|
+
const record = asRecord(raw);
|
|
1164
|
+
const databaseId = asFiniteNumber(record?.id);
|
|
1165
|
+
const nodeId = asTrimmedString(record?.node_id ?? record?.nodeId);
|
|
1166
|
+
return (
|
|
1167
|
+
(databaseId !== undefined ? lookup.byDatabaseId.get(databaseId) : undefined) ??
|
|
1168
|
+
(nodeId ? lookup.byNodeId.get(nodeId) : undefined) ??
|
|
1169
|
+
{ metadataAvailable: false }
|
|
1170
|
+
);
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
async function fetchReviewThreadStateLookup(
|
|
1174
|
+
pi: ExtensionAPI,
|
|
1175
|
+
ctx: ExtensionCommandContext,
|
|
1176
|
+
pr: PullRequestContext,
|
|
1177
|
+
): Promise<ReviewThreadStateLookup> {
|
|
1178
|
+
const [owner, repo] = pr.repository.split('/');
|
|
1179
|
+
if (!owner || !repo) throw new Error(`Could not build GitHub GraphQL variables from repository ${pr.repository}.`);
|
|
1180
|
+
|
|
1181
|
+
const lookup = createReviewThreadStateLookup();
|
|
1182
|
+
let after: string | undefined;
|
|
1183
|
+
|
|
1184
|
+
while (true) {
|
|
1185
|
+
const args = ['api'];
|
|
1186
|
+
if (pr.host) args.push('--hostname', pr.host);
|
|
1187
|
+
args.push(
|
|
1188
|
+
'graphql',
|
|
1189
|
+
'-f',
|
|
1190
|
+
`owner=${owner}`,
|
|
1191
|
+
'-f',
|
|
1192
|
+
`name=${repo}`,
|
|
1193
|
+
'-F',
|
|
1194
|
+
`number=${pr.number}`,
|
|
1195
|
+
'-f',
|
|
1196
|
+
`query=${GH_REVIEW_THREADS_GRAPHQL_QUERY}`,
|
|
1197
|
+
);
|
|
1198
|
+
if (after) args.push('-f', `after=${after}`);
|
|
1199
|
+
|
|
1200
|
+
const label = 'Fetching review thread resolved/outdated metadata with gh';
|
|
1201
|
+
const stdout = await execChecked(pi, ctx, 'gh', args, label);
|
|
1202
|
+
const raw = parseJsonOutput(stdout, label);
|
|
1203
|
+
const data = asRecord(raw)?.data;
|
|
1204
|
+
const repository = asRecord(asRecord(data)?.repository);
|
|
1205
|
+
const pullRequest = asRecord(repository?.pullRequest);
|
|
1206
|
+
const reviewThreads = asRecord(pullRequest?.reviewThreads);
|
|
1207
|
+
if (!reviewThreads) throw new Error('GitHub GraphQL response did not include reviewThreads.');
|
|
1208
|
+
|
|
1209
|
+
const nodes = Array.isArray(reviewThreads.nodes) ? reviewThreads.nodes : [];
|
|
1210
|
+
for (const rawThread of nodes) {
|
|
1211
|
+
const thread = asRecord(rawThread);
|
|
1212
|
+
if (!thread) continue;
|
|
1213
|
+
const state: ReviewThreadState = {
|
|
1214
|
+
threadId: asTrimmedString(thread.id),
|
|
1215
|
+
isResolved: asBoolean(thread.isResolved),
|
|
1216
|
+
isOutdated: asBoolean(thread.isOutdated),
|
|
1217
|
+
metadataAvailable: true,
|
|
1218
|
+
};
|
|
1219
|
+
const comments = asRecord(thread.comments);
|
|
1220
|
+
const commentNodes = Array.isArray(comments?.nodes) ? comments.nodes : [];
|
|
1221
|
+
for (const rawComment of commentNodes) {
|
|
1222
|
+
const comment = asRecord(rawComment);
|
|
1223
|
+
if (!comment) continue;
|
|
1224
|
+
const databaseId = asFiniteNumber(comment.databaseId);
|
|
1225
|
+
const nodeId = asTrimmedString(comment.id);
|
|
1226
|
+
if (databaseId !== undefined) lookup.byDatabaseId.set(databaseId, state);
|
|
1227
|
+
if (nodeId) lookup.byNodeId.set(nodeId, state);
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const pageInfo = asRecord(reviewThreads.pageInfo);
|
|
1232
|
+
if (!asBoolean(pageInfo?.hasNextPage)) break;
|
|
1233
|
+
const endCursor = asTrimmedString(pageInfo?.endCursor);
|
|
1234
|
+
if (!endCursor) break;
|
|
1235
|
+
after = endCursor;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
return lookup;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
async function fetchReviewThreadStateLookupBestEffort(
|
|
1242
|
+
pi: ExtensionAPI,
|
|
1243
|
+
ctx: ExtensionCommandContext,
|
|
1244
|
+
pr: PullRequestContext,
|
|
1245
|
+
): Promise<ReviewThreadStateLookup> {
|
|
1246
|
+
try {
|
|
1247
|
+
return await fetchReviewThreadStateLookup(pi, ctx, pr);
|
|
1248
|
+
} catch (error) {
|
|
1249
|
+
notifyCommand(
|
|
1250
|
+
ctx,
|
|
1251
|
+
`Could not fetch inline review-thread resolved/outdated metadata; inline review comments without thread metadata will stay visible. ${formatErrorMessage(error)}`,
|
|
1252
|
+
'warning',
|
|
1253
|
+
);
|
|
1254
|
+
return createReviewThreadStateLookup();
|
|
1255
|
+
}
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
function normalizeReviewComment(raw: unknown, pr: PullRequestContext, sortIndex: number, reviewThread: ReviewThreadState): CommandComment | undefined {
|
|
1101
1259
|
const record = asRecord(raw);
|
|
1102
1260
|
if (!record) return undefined;
|
|
1103
1261
|
const body = asTrimmedString(record.body);
|
|
@@ -1123,6 +1281,7 @@ function normalizeReviewComment(raw: unknown, pr: PullRequestContext, sortIndex:
|
|
|
1123
1281
|
author: githubLogin(record.user),
|
|
1124
1282
|
url: asTrimmedString(record.html_url ?? record.htmlUrl),
|
|
1125
1283
|
createdAt,
|
|
1284
|
+
reviewThread,
|
|
1126
1285
|
metadata: compactRecord({
|
|
1127
1286
|
source: 'pull_request_review_comment',
|
|
1128
1287
|
repository: pr.repository,
|
|
@@ -1130,6 +1289,12 @@ function normalizeReviewComment(raw: unknown, pr: PullRequestContext, sortIndex:
|
|
|
1130
1289
|
host: pr.host,
|
|
1131
1290
|
databaseId,
|
|
1132
1291
|
nodeId,
|
|
1292
|
+
reviewThread: compactRecord({
|
|
1293
|
+
threadId: reviewThread.threadId,
|
|
1294
|
+
isResolved: reviewThread.isResolved,
|
|
1295
|
+
isOutdated: reviewThread.isOutdated,
|
|
1296
|
+
metadataAvailable: reviewThread.metadataAvailable,
|
|
1297
|
+
}),
|
|
1133
1298
|
pullRequestReviewId: asFiniteNumber(record.pull_request_review_id ?? record.pullRequestReviewId),
|
|
1134
1299
|
commitId: asTrimmedString(record.commit_id ?? record.commitId),
|
|
1135
1300
|
originalCommitId: asTrimmedString(record.original_commit_id ?? record.originalCommitId),
|
|
@@ -1234,35 +1399,64 @@ function assignDisplayNumbers(comments: CommandComment[]): CommandComment[] {
|
|
|
1234
1399
|
}));
|
|
1235
1400
|
}
|
|
1236
1401
|
|
|
1237
|
-
|
|
1402
|
+
function prContextFallbackTarget(raw: unknown, explicitTarget?: string): string {
|
|
1403
|
+
const record = asRecord(raw);
|
|
1404
|
+
const number = asFiniteNumber(record?.number);
|
|
1405
|
+
return explicitTarget ?? asTrimmedString(record?.url) ?? (number ? String(number) : '');
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
async function fetchPullRequestMetadata(
|
|
1238
1409
|
pi: ExtensionAPI,
|
|
1239
1410
|
ctx: ExtensionCommandContext,
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1411
|
+
label: string,
|
|
1412
|
+
target?: string,
|
|
1413
|
+
): Promise<PullRequestContext> {
|
|
1414
|
+
const args = ['pr', 'view'];
|
|
1415
|
+
if (target) args.push(target);
|
|
1416
|
+
args.push('--json', GH_PR_METADATA_FIELDS);
|
|
1246
1417
|
|
|
1247
|
-
await
|
|
1248
|
-
|
|
1418
|
+
const stdout = await execChecked(pi, ctx, 'gh', args, label);
|
|
1419
|
+
const raw = parseJsonOutput(stdout, label);
|
|
1420
|
+
return normalizePullRequestContext(raw, prContextFallbackTarget(raw, target));
|
|
1421
|
+
}
|
|
1249
1422
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1423
|
+
async function detectCurrentBranchPullRequest(
|
|
1424
|
+
pi: ExtensionAPI,
|
|
1425
|
+
ctx: ExtensionCommandContext,
|
|
1426
|
+
): Promise<PullRequestContext | undefined> {
|
|
1427
|
+
try {
|
|
1428
|
+
const branch = await pi.exec('git', ['branch', '--show-current'], {
|
|
1429
|
+
cwd: ctx.cwd,
|
|
1430
|
+
signal: ctx.signal,
|
|
1431
|
+
timeout: GH_COMMAND_TIMEOUT_MS,
|
|
1432
|
+
});
|
|
1433
|
+
if (branch.killed || branch.code !== 0) return undefined;
|
|
1434
|
+
|
|
1435
|
+
const branchName = branch.stdout.trim();
|
|
1436
|
+
if (!branchName || branchName === 'main') return undefined;
|
|
1437
|
+
|
|
1438
|
+
return await fetchPullRequestMetadata(pi, ctx, 'Detecting current branch PR with gh');
|
|
1439
|
+
} catch {
|
|
1440
|
+
return undefined;
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
async function fetchCommentsForPullRequest(
|
|
1445
|
+
pi: ExtensionAPI,
|
|
1446
|
+
ctx: ExtensionCommandContext,
|
|
1447
|
+
pr: PullRequestContext,
|
|
1448
|
+
): Promise<CommandComment[]> {
|
|
1258
1449
|
const reviewComments = await ghApiArray(pi, ctx, pr, `pulls/${pr.number}/comments?per_page=100`, 'review comments');
|
|
1450
|
+
const reviewThreadLookup = reviewComments.length > 0
|
|
1451
|
+
? await fetchReviewThreadStateLookupBestEffort(pi, ctx, pr)
|
|
1452
|
+
: createReviewThreadStateLookup();
|
|
1259
1453
|
const issueComments = await ghApiArray(pi, ctx, pr, `issues/${pr.number}/comments?per_page=100`, 'issue comments');
|
|
1260
1454
|
const reviews = await ghApiArray(pi, ctx, pr, `pulls/${pr.number}/reviews?per_page=100`, 'review bodies');
|
|
1261
1455
|
|
|
1262
1456
|
let sortIndex = 0;
|
|
1263
1457
|
const comments: CommandComment[] = [];
|
|
1264
1458
|
for (const raw of reviewComments) {
|
|
1265
|
-
const comment = normalizeReviewComment(raw, pr, ++sortIndex);
|
|
1459
|
+
const comment = normalizeReviewComment(raw, pr, ++sortIndex, reviewThreadStateForComment(raw, reviewThreadLookup));
|
|
1266
1460
|
if (comment) comments.push(comment);
|
|
1267
1461
|
}
|
|
1268
1462
|
for (const raw of issueComments) {
|
|
@@ -1274,7 +1468,25 @@ async function fetchPrCommentsForTriage(
|
|
|
1274
1468
|
if (comment) comments.push(comment);
|
|
1275
1469
|
}
|
|
1276
1470
|
|
|
1277
|
-
return
|
|
1471
|
+
return assignDisplayNumbers(comments.sort(compareCommandComments));
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
async function fetchPrCommentsForTriage(
|
|
1475
|
+
pi: ExtensionAPI,
|
|
1476
|
+
ctx: ExtensionCommandContext,
|
|
1477
|
+
target: string,
|
|
1478
|
+
): Promise<{ pr: PullRequestContext; comments: CommandComment[] }> {
|
|
1479
|
+
const normalizedTarget = normalizePrTarget(target);
|
|
1480
|
+
if (!normalizedTarget) {
|
|
1481
|
+
throw new Error('PR input must be a PR number (for example, 123 or #123) or a GitHub pull request URL.');
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
await assertGitRepo(pi, ctx);
|
|
1485
|
+
await assertGhReady(pi, ctx);
|
|
1486
|
+
|
|
1487
|
+
const pr = await fetchPullRequestMetadata(pi, ctx, 'Fetching PR metadata with gh', normalizedTarget);
|
|
1488
|
+
const comments = await fetchCommentsForPullRequest(pi, ctx, pr);
|
|
1489
|
+
return { pr, comments };
|
|
1278
1490
|
}
|
|
1279
1491
|
|
|
1280
1492
|
function truncateForDisplay(text: string, max: number): string {
|
|
@@ -1294,21 +1506,139 @@ function formatCommentLocation(comment: CommandComment): string {
|
|
|
1294
1506
|
return lineRange ? `${comment.path}:${lineRange}` : comment.path;
|
|
1295
1507
|
}
|
|
1296
1508
|
|
|
1297
|
-
function
|
|
1509
|
+
function isInlineReviewComment(comment: CommandComment): boolean {
|
|
1510
|
+
return asTrimmedString(comment.metadata.source) === 'pull_request_review_comment';
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
function formatReviewThreadState(comment: CommandComment): string | undefined {
|
|
1514
|
+
if (!isInlineReviewComment(comment)) return undefined;
|
|
1515
|
+
const reviewThread = comment.reviewThread;
|
|
1516
|
+
if (!reviewThread?.metadataAvailable) return 'thread: resolved/outdated state unavailable (kept visible)';
|
|
1517
|
+
const resolved = reviewThread.isResolved === true
|
|
1518
|
+
? 'resolved'
|
|
1519
|
+
: reviewThread.isResolved === false
|
|
1520
|
+
? 'unresolved'
|
|
1521
|
+
: 'resolved state unavailable';
|
|
1522
|
+
const outdated = reviewThread.isOutdated === true
|
|
1523
|
+
? 'outdated'
|
|
1524
|
+
: reviewThread.isOutdated === false
|
|
1525
|
+
? 'current'
|
|
1526
|
+
: 'outdated state unavailable';
|
|
1527
|
+
return `thread: ${resolved}, ${outdated}`;
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
function formatInlineFilterNotice(summary: AppliedInlineCommentFilter): string {
|
|
1531
|
+
const scope = 'Only inline review comments can be filtered; PR issue comments, review bodies, and inline comments without thread metadata remain visible.';
|
|
1532
|
+
if (!summary.filter.hideResolved && !summary.filter.hideOutdated) {
|
|
1533
|
+
return `Filter: showing all fetched comments. ${scope}`;
|
|
1534
|
+
}
|
|
1535
|
+
const hiddenParts = [
|
|
1536
|
+
summary.hiddenResolvedInlineCount > 0 ? `${summary.hiddenResolvedInlineCount} resolved` : undefined,
|
|
1537
|
+
summary.hiddenOutdatedInlineCount > 0 ? `${summary.hiddenOutdatedInlineCount} outdated` : undefined,
|
|
1538
|
+
]
|
|
1539
|
+
.filter(Boolean)
|
|
1540
|
+
.join(', ');
|
|
1541
|
+
const hiddenDetail = hiddenParts ? ` (${hiddenParts})` : '';
|
|
1542
|
+
return `Filter: ${summary.filter.label}. Hidden ${summary.hiddenInlineCount} inline review comment(s)${hiddenDetail}; displaying ${summary.displayedCount} of ${summary.originalCount} fetched comment(s). ${scope}`;
|
|
1543
|
+
}
|
|
1544
|
+
|
|
1545
|
+
function formatInlineFilterContext(summary: AppliedInlineCommentFilter): string {
|
|
1546
|
+
return `${formatInlineFilterNotice(summary)} Inline comments kept without thread metadata: ${summary.keptInlineWithoutThreadMetadataCount}.`;
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
function formatFetchedCommentsForSelection(pr: PullRequestContext, comments: CommandComment[], filterSummary?: AppliedInlineCommentFilter): string {
|
|
1298
1550
|
const title = pr.title ? ` — ${pr.title}` : '';
|
|
1299
1551
|
const limitNotice = comments.length > MAX_COMMENTS
|
|
1300
1552
|
? `\n\ntriage_comments can investigate at most ${MAX_COMMENTS} comments per run, so choose a subset.`
|
|
1301
1553
|
: '\n\nChoose whether to investigate all displayed comments or select a subset.';
|
|
1302
|
-
const
|
|
1554
|
+
const fetchedCount = filterSummary?.originalCount ?? comments.length;
|
|
1555
|
+
const displayedCount = comments.length;
|
|
1556
|
+
const countSummary = filterSummary
|
|
1557
|
+
? `Fetched ${fetchedCount} comment(s) from ${pr.repository}#${pr.number}${title}; displaying ${displayedCount} numbered comment(s) after filtering.`
|
|
1558
|
+
: `Fetched ${displayedCount} numbered comment(s) from ${pr.repository}#${pr.number}${title}.`;
|
|
1559
|
+
const filterNotice = filterSummary ? `\n\n${formatInlineFilterNotice(filterSummary)}` : '';
|
|
1560
|
+
const header = `${countSummary}${filterNotice}${limitNotice}`;
|
|
1303
1561
|
const body = comments.map((comment) => {
|
|
1304
1562
|
const author = comment.author ? ` by @${comment.author}` : '';
|
|
1305
1563
|
const url = comment.url ? `\n url: ${comment.url}` : '';
|
|
1564
|
+
const threadState = formatReviewThreadState(comment);
|
|
1565
|
+
const threadLine = threadState ? `\n ${threadState}` : '';
|
|
1306
1566
|
const preview = indentLines(truncateForDisplay(comment.body, COMMENT_DISPLAY_BODY_LIMIT), ' ');
|
|
1307
|
-
return `${comment.displayNumber ?? '?'}. ${comment.sourceLabel}${author} — ${formatCommentLocation(comment)}\n id: ${comment.id}${url}\n${preview}`;
|
|
1567
|
+
return `${comment.displayNumber ?? '?'}. ${comment.sourceLabel}${author} — ${formatCommentLocation(comment)}\n id: ${comment.id}${url}${threadLine}\n${preview}`;
|
|
1308
1568
|
});
|
|
1309
1569
|
return [header, ...body].join('\n\n');
|
|
1310
1570
|
}
|
|
1311
1571
|
|
|
1572
|
+
async function promptForInlineCommentFilter(ctx: ExtensionCommandContext): Promise<InlineCommentFilter | undefined> {
|
|
1573
|
+
const prompt = [
|
|
1574
|
+
'/triage-comments: filter inline review comments before display',
|
|
1575
|
+
'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.',
|
|
1576
|
+
].join('\n\n');
|
|
1577
|
+
const showAll = 'Show all fetched comments';
|
|
1578
|
+
const hideResolved = 'Hide resolved inline review comments';
|
|
1579
|
+
const hideOutdated = 'Hide outdated inline review comments';
|
|
1580
|
+
const hideBoth = 'Hide resolved and outdated inline review comments';
|
|
1581
|
+
const cancel = 'Cancel';
|
|
1582
|
+
const decision = await ctx.ui.select(prompt, [showAll, hideResolved, hideOutdated, hideBoth, cancel]);
|
|
1583
|
+
if (!decision || decision === cancel) return undefined;
|
|
1584
|
+
if (decision === hideResolved) return { hideResolved: true, hideOutdated: false, label: 'hiding resolved inline review comments' };
|
|
1585
|
+
if (decision === hideOutdated) return { hideResolved: false, hideOutdated: true, label: 'hiding outdated inline review comments' };
|
|
1586
|
+
if (decision === hideBoth) return { hideResolved: true, hideOutdated: true, label: 'hiding resolved or outdated inline review comments' };
|
|
1587
|
+
return { hideResolved: false, hideOutdated: false, label: 'showing all fetched comments' };
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
function applyInlineCommentFilter(
|
|
1591
|
+
comments: CommandComment[],
|
|
1592
|
+
filter: InlineCommentFilter,
|
|
1593
|
+
): { comments: CommandComment[]; summary: AppliedInlineCommentFilter } {
|
|
1594
|
+
const kept: CommandComment[] = [];
|
|
1595
|
+
let hiddenInlineCount = 0;
|
|
1596
|
+
let hiddenResolvedInlineCount = 0;
|
|
1597
|
+
let hiddenOutdatedInlineCount = 0;
|
|
1598
|
+
let keptInlineWithoutThreadMetadataCount = 0;
|
|
1599
|
+
|
|
1600
|
+
for (const comment of comments) {
|
|
1601
|
+
if (!isInlineReviewComment(comment)) {
|
|
1602
|
+
kept.push(comment);
|
|
1603
|
+
continue;
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
const reviewThread = comment.reviewThread;
|
|
1607
|
+
const hasThreadMetadata = Boolean(reviewThread?.metadataAvailable);
|
|
1608
|
+
const hideForResolved = filter.hideResolved && reviewThread?.isResolved === true;
|
|
1609
|
+
const hideForOutdated = filter.hideOutdated && reviewThread?.isOutdated === true;
|
|
1610
|
+
if (hideForResolved || hideForOutdated) {
|
|
1611
|
+
hiddenInlineCount += 1;
|
|
1612
|
+
if (hideForResolved) hiddenResolvedInlineCount += 1;
|
|
1613
|
+
if (hideForOutdated) hiddenOutdatedInlineCount += 1;
|
|
1614
|
+
continue;
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
if (!hasThreadMetadata) keptInlineWithoutThreadMetadataCount += 1;
|
|
1618
|
+
kept.push(comment);
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
const commentsWithFilterMetadata = kept.map((comment) => ({
|
|
1622
|
+
...comment,
|
|
1623
|
+
metadata: compactRecord({
|
|
1624
|
+
...comment.metadata,
|
|
1625
|
+
preFilterDisplayNumber: comment.displayNumber,
|
|
1626
|
+
}),
|
|
1627
|
+
}));
|
|
1628
|
+
|
|
1629
|
+
const summary: AppliedInlineCommentFilter = {
|
|
1630
|
+
filter,
|
|
1631
|
+
originalCount: comments.length,
|
|
1632
|
+
displayedCount: kept.length,
|
|
1633
|
+
hiddenInlineCount,
|
|
1634
|
+
hiddenResolvedInlineCount,
|
|
1635
|
+
hiddenOutdatedInlineCount,
|
|
1636
|
+
keptInlineWithoutThreadMetadataCount,
|
|
1637
|
+
};
|
|
1638
|
+
|
|
1639
|
+
return { comments: assignDisplayNumbers(commentsWithFilterMetadata), summary };
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1312
1642
|
function parseSelectionList(input: string, max: number): SelectionParseResult {
|
|
1313
1643
|
const trimmed = input.trim();
|
|
1314
1644
|
if (!trimmed) return { ok: false, error: 'Enter comment numbers such as 1,3-5.' };
|
|
@@ -1384,11 +1714,12 @@ async function choosePrComments(
|
|
|
1384
1714
|
ctx: ExtensionCommandContext,
|
|
1385
1715
|
pr: PullRequestContext,
|
|
1386
1716
|
comments: CommandComment[],
|
|
1717
|
+
filterSummary?: AppliedInlineCommentFilter,
|
|
1387
1718
|
): Promise<CommandComment[] | undefined> {
|
|
1388
1719
|
const options = comments.length <= MAX_COMMENTS
|
|
1389
1720
|
? ['Investigate all displayed comments', 'Choose a subset', 'Cancel']
|
|
1390
1721
|
: ['Choose a subset', 'Cancel'];
|
|
1391
|
-
const selectionPrompt = formatFetchedCommentsForSelection(pr, comments);
|
|
1722
|
+
const selectionPrompt = formatFetchedCommentsForSelection(pr, comments, filterSummary);
|
|
1392
1723
|
const decision = await ctx.ui.select(selectionPrompt, options);
|
|
1393
1724
|
if (!decision || decision === 'Cancel') return undefined;
|
|
1394
1725
|
if (decision === 'Investigate all displayed comments') return comments;
|
|
@@ -1428,13 +1759,16 @@ function toPayloadComment(comment: CommandComment): Record<string, unknown> {
|
|
|
1428
1759
|
|
|
1429
1760
|
function buildCommandPayload(
|
|
1430
1761
|
comments: CommandComment[],
|
|
1431
|
-
options: { pr?: PullRequestContext; totalDisplayed: number; source: 'paste' | 'pr' },
|
|
1762
|
+
options: { pr?: PullRequestContext; totalDisplayed: number; source: 'paste' | 'pr'; filterSummary?: AppliedInlineCommentFilter },
|
|
1432
1763
|
): TriageCommandPayload {
|
|
1764
|
+
const prSelectionContext = options.filterSummary
|
|
1765
|
+
? `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)}`
|
|
1766
|
+
: `Selected by /triage-comments after displaying ${options.totalDisplayed} fetched PR comment(s) and receiving explicit user selection.`;
|
|
1433
1767
|
const payload: TriageCommandPayload = {
|
|
1434
1768
|
comments: comments.map(toPayloadComment),
|
|
1435
1769
|
context:
|
|
1436
1770
|
options.source === 'pr'
|
|
1437
|
-
?
|
|
1771
|
+
? `${prSelectionContext} Do not implement changes until the user chooses a handling option.`
|
|
1438
1772
|
: 'Pasted feedback captured by /triage-comments. Do not implement changes until the user chooses a handling option.',
|
|
1439
1773
|
};
|
|
1440
1774
|
|
|
@@ -1490,6 +1824,18 @@ async function runPasteMode(pi: ExtensionAPI, ctx: ExtensionCommandContext, pref
|
|
|
1490
1824
|
|
|
1491
1825
|
async function runPrMode(pi: ExtensionAPI, ctx: ExtensionCommandContext, target?: string): Promise<void> {
|
|
1492
1826
|
let prTarget = target?.trim();
|
|
1827
|
+
let detectedPr: PullRequestContext | undefined;
|
|
1828
|
+
|
|
1829
|
+
if (!prTarget) {
|
|
1830
|
+
ctx.ui.setStatus('triage-comments', 'triage-comments: checking current branch PR…');
|
|
1831
|
+
detectedPr = await detectCurrentBranchPullRequest(pi, ctx);
|
|
1832
|
+
ctx.ui.setStatus('triage-comments', undefined);
|
|
1833
|
+
if (detectedPr) {
|
|
1834
|
+
prTarget = detectedPr.url ?? String(detectedPr.number);
|
|
1835
|
+
notifyCommand(ctx, `Detected PR ${detectedPr.repository}#${detectedPr.number} from the current branch.`, 'info');
|
|
1836
|
+
}
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1493
1839
|
if (!prTarget) {
|
|
1494
1840
|
const input = await ctx.ui.input('PR URL or number', 'For example: 123, #123, or https://github.com/owner/repo/pull/123');
|
|
1495
1841
|
if (input === undefined) return;
|
|
@@ -1504,16 +1850,26 @@ async function runPrMode(pi: ExtensionAPI, ctx: ExtensionCommandContext, target?
|
|
|
1504
1850
|
ctx.ui.setStatus('triage-comments', 'triage-comments: fetching PR comments…');
|
|
1505
1851
|
try {
|
|
1506
1852
|
notifyCommand(ctx, 'Fetching PR comments with gh (read-only)…', 'info');
|
|
1507
|
-
const { pr, comments } =
|
|
1853
|
+
const { pr, comments } = detectedPr
|
|
1854
|
+
? { pr: detectedPr, comments: await fetchCommentsForPullRequest(pi, ctx, detectedPr) }
|
|
1855
|
+
: await fetchPrCommentsForTriage(pi, ctx, prTarget);
|
|
1508
1856
|
if (comments.length === 0) {
|
|
1509
1857
|
notifyCommand(ctx, `No review comments, issue comments, or review bodies were found for ${pr.repository}#${pr.number}.`, 'info');
|
|
1510
1858
|
return;
|
|
1511
1859
|
}
|
|
1512
1860
|
|
|
1513
|
-
const
|
|
1861
|
+
const filter = await promptForInlineCommentFilter(ctx);
|
|
1862
|
+
if (!filter) return;
|
|
1863
|
+
const filtered = applyInlineCommentFilter(comments, filter);
|
|
1864
|
+
if (filtered.comments.length === 0) {
|
|
1865
|
+
notifyCommand(ctx, `${formatInlineFilterNotice(filtered.summary)} No comments remain to send to triage_comments.`, 'info');
|
|
1866
|
+
return;
|
|
1867
|
+
}
|
|
1868
|
+
|
|
1869
|
+
const selected = await choosePrComments(ctx, pr, filtered.comments, filtered.summary);
|
|
1514
1870
|
if (!selected || selected.length === 0) return;
|
|
1515
|
-
const payload = buildCommandPayload(selected, { pr, totalDisplayed: comments.length, source: 'pr' });
|
|
1516
|
-
sendTriageUserMessage(pi, ctx, payload, selected.length, comments.length);
|
|
1871
|
+
const payload = buildCommandPayload(selected, { pr, totalDisplayed: filtered.comments.length, source: 'pr', filterSummary: filtered.summary });
|
|
1872
|
+
sendTriageUserMessage(pi, ctx, payload, selected.length, filtered.comments.length);
|
|
1517
1873
|
} catch (error) {
|
|
1518
1874
|
notifyCommand(ctx, formatErrorMessage(error), 'error');
|
|
1519
1875
|
} finally {
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diegopetrucci/pi-triage-comments",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "A pi extension that adds /triage-comments and a read-only triage_comments subagent tool for review-comment triage.",
|
|
5
5
|
"keywords": ["pi-package", "pi", "triage", "comments", "review", "subagent"],
|
|
6
6
|
"license": "MIT",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@diegopetrucci/pi-extensions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.27",
|
|
4
4
|
"description": "A collection of pi extensions for context management, review-comment triage, notifications, safety guards, GitHub research, todos, tool rendering, and model/provider helpers.",
|
|
5
5
|
"keywords": ["pi-package", "pi", "terminal", "agent"],
|
|
6
6
|
"license": "MIT",
|