@henryqw/pi-pr 0.3.6 → 1.0.0
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 +60 -23
- package/docs/pr-routing.svg +127 -0
- package/extensions/pr-command.ts +154 -0
- package/extensions/pr-github.ts +994 -0
- package/extensions/pr-merge.ts +288 -0
- package/extensions/pr-routing.ts +59 -0
- package/extensions/pr-ui.ts +93 -0
- package/extensions/pr.ts +70 -259
- package/package.json +3 -2
- package/skills/pi-pr-comment-sweep/SKILL.md +79 -0
- package/skills/pi-pr-comment-sweep/references/recovery.md +49 -0
- package/skills/pi-pr-comment-sweep/references/thread-triage.md +8 -0
- package/skills/pi-pr-comment-sweep/scripts/pr-feedback.mjs +1672 -0
- package/skills/pi-pr-create/SKILL.md +25 -1
- package/skills/pi-pr-fix-ci/SKILL.md +59 -0
- package/skills/pi-pr-update-branch/SKILL.md +116 -0
package/extensions/pr.ts
CHANGED
|
@@ -3,266 +3,112 @@ import {
|
|
|
3
3
|
type ExtensionAPI,
|
|
4
4
|
type ExtensionContext,
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
|
-
import {
|
|
6
|
+
import { createPrCommandHandler } from "./pr-command.ts";
|
|
7
|
+
import { loadCurrentPullRequest } from "./pr-github.ts";
|
|
8
|
+
import {
|
|
9
|
+
formatPrFooter,
|
|
10
|
+
formatPrWidget,
|
|
11
|
+
projectPrDisplay,
|
|
12
|
+
} from "./pr-ui.ts";
|
|
7
13
|
|
|
8
14
|
const POLL_INTERVAL_MS = 30_000;
|
|
9
|
-
const
|
|
10
|
-
const PR_FIELDS = "id,number,url,headRefOid,updatedAt,state,isDraft,mergeable,reviewDecision,statusCheckRollup";
|
|
11
|
-
const REVIEW_THREADS_QUERY = "query($id:ID!,$endCursor:String){node(id:$id){...on PullRequest{reviewThreads(first:100,after:$endCursor){nodes{isResolved}pageInfo{hasNextPage endCursor}}}}}";
|
|
15
|
+
const UI_KEY = "pi-pr";
|
|
12
16
|
const GH_PR_CREATE = /(?:^|[;&|]\s*|\n\s*)gh\s+pr\s+create(?=\s|$|[;&|])/;
|
|
13
17
|
const GIT_PUSH = /(?:^|[;&|]\s*|\n\s*)git\s+push(?=\s|$|[;&|])/;
|
|
14
|
-
const CREATE_PR_SKILL_COMMAND = "skill:pi-pr-create";
|
|
15
|
-
const FAILED_CHECK_STATES = new Set(["ACTION_REQUIRED", "CANCELLED", "ERROR", "FAILURE", "STALE", "STARTUP_FAILURE", "TIMED_OUT"]);
|
|
16
|
-
const SUCCESSFUL_CHECK_STATES = new Set(["NEUTRAL", "SKIPPED", "SUCCESS"]);
|
|
17
|
-
|
|
18
|
-
type CiStatus = "success" | "running" | "failure" | "none";
|
|
19
|
-
type PullRequest = {
|
|
20
|
-
id: string;
|
|
21
|
-
number: number;
|
|
22
|
-
url: URL;
|
|
23
|
-
headRefOid: string;
|
|
24
|
-
updatedAt: string;
|
|
25
|
-
state: "OPEN" | "MERGED" | "CLOSED";
|
|
26
|
-
isDraft: boolean;
|
|
27
|
-
mergeable: string;
|
|
28
|
-
reviewDecision: string | null;
|
|
29
|
-
statusCheckRollup: unknown[];
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
33
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function pullRequestUrl(value: unknown): URL | undefined {
|
|
37
|
-
if (typeof value !== "string" || !URL.canParse(value)) return undefined;
|
|
38
|
-
const url = new URL(value);
|
|
39
|
-
return url.protocol === "http:" || url.protocol === "https:" ? url : undefined;
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export function parsePullRequest(value: unknown): PullRequest | undefined {
|
|
43
|
-
if (!isRecord(value)) return undefined;
|
|
44
|
-
|
|
45
|
-
const id = value.id;
|
|
46
|
-
const number = value.number;
|
|
47
|
-
const url = pullRequestUrl(value.url);
|
|
48
|
-
const headRefOid = value.headRefOid;
|
|
49
|
-
const updatedAt = value.updatedAt;
|
|
50
|
-
const state = value.state;
|
|
51
|
-
const isDraft = value.isDraft;
|
|
52
|
-
const mergeable = value.mergeable;
|
|
53
|
-
const reviewDecision = value.reviewDecision;
|
|
54
|
-
const statusCheckRollup = value.statusCheckRollup;
|
|
55
|
-
if (
|
|
56
|
-
typeof id !== "string" || !id ||
|
|
57
|
-
typeof number !== "number" || !Number.isSafeInteger(number) || number <= 0 || !url ||
|
|
58
|
-
typeof headRefOid !== "string" || !headRefOid ||
|
|
59
|
-
typeof updatedAt !== "string" || Number.isNaN(Date.parse(updatedAt)) ||
|
|
60
|
-
(state !== "MERGED" && state !== "CLOSED" && state !== "OPEN") ||
|
|
61
|
-
typeof isDraft !== "boolean" || typeof mergeable !== "string" ||
|
|
62
|
-
(reviewDecision !== null && typeof reviewDecision !== "string") ||
|
|
63
|
-
(statusCheckRollup !== null && !Array.isArray(statusCheckRollup))
|
|
64
|
-
) return undefined;
|
|
65
|
-
|
|
66
|
-
return { id, number, url, headRefOid, updatedAt, state, isDraft, mergeable, reviewDecision, statusCheckRollup: statusCheckRollup ?? [] };
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
function parseUnresolvedReviewCount(value: string): number {
|
|
70
|
-
if (!value.trim()) throw new Error("Read review comments failed: invalid GitHub CLI output");
|
|
71
|
-
const pages = value.trim().split(/\s+/).map(Number);
|
|
72
|
-
const count = pages.reduce((total, page) => total + page, 0);
|
|
73
|
-
if (pages.some((page) => !Number.isSafeInteger(page) || page < 0) || !Number.isSafeInteger(count)) {
|
|
74
|
-
throw new Error("Read review comments failed: invalid GitHub CLI output");
|
|
75
|
-
}
|
|
76
|
-
return count;
|
|
77
|
-
}
|
|
78
18
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
value = JSON.parse(json);
|
|
83
|
-
} catch {
|
|
84
|
-
throw new Error("Read push repository failed: invalid GitHub CLI output");
|
|
85
|
-
}
|
|
86
|
-
const nameWithOwner = isRecord(value) ? value.nameWithOwner : undefined;
|
|
87
|
-
if (typeof nameWithOwner !== "string" || !nameWithOwner) {
|
|
88
|
-
throw new Error("Read push repository failed: invalid GitHub CLI output");
|
|
89
|
-
}
|
|
90
|
-
return nameWithOwner.toLowerCase();
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function parseOpenPullRequestNumbers(json: string, headRepository: string): number[] {
|
|
94
|
-
let values: unknown;
|
|
95
|
-
try {
|
|
96
|
-
values = JSON.parse(json);
|
|
97
|
-
} catch {
|
|
98
|
-
throw new Error("Find pull requests failed: invalid GitHub CLI output");
|
|
99
|
-
}
|
|
100
|
-
if (!Array.isArray(values)) throw new Error("Find pull requests failed: invalid GitHub CLI output");
|
|
101
|
-
return values.flatMap((value) => {
|
|
102
|
-
const number = isRecord(value) ? value.number : undefined;
|
|
103
|
-
const repository = isRecord(value) ? value.headRepository : undefined;
|
|
104
|
-
if (repository === null) return [];
|
|
105
|
-
const nameWithOwner = isRecord(repository) ? repository.nameWithOwner : undefined;
|
|
106
|
-
if (
|
|
107
|
-
typeof number !== "number" || !Number.isSafeInteger(number) || number <= 0 ||
|
|
108
|
-
typeof nameWithOwner !== "string" || !nameWithOwner
|
|
109
|
-
) throw new Error("Find pull requests failed: invalid GitHub CLI output");
|
|
110
|
-
return nameWithOwner.toLowerCase() === headRepository ? [number] : [];
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
function ciStatus(rollup: unknown[]): CiStatus {
|
|
115
|
-
if (!rollup.length) return "none";
|
|
116
|
-
let running = false;
|
|
117
|
-
for (const check of rollup) {
|
|
118
|
-
const record = isRecord(check) ? check : undefined;
|
|
119
|
-
const value = record?.conclusion ?? record?.state ?? record?.status;
|
|
120
|
-
const state = typeof value === "string" ? value.toUpperCase() : undefined;
|
|
121
|
-
if (state && FAILED_CHECK_STATES.has(state)) return "failure";
|
|
122
|
-
if (!state || !SUCCESSFUL_CHECK_STATES.has(state)) running = true;
|
|
123
|
-
}
|
|
124
|
-
return running ? "running" : "success";
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
type Status = {
|
|
128
|
-
text: string;
|
|
129
|
-
color: "accent" | "warning" | "success" | "error" | "dim";
|
|
19
|
+
type PullRequestExtensionDependencies = {
|
|
20
|
+
loadCurrentPullRequest?: typeof loadCurrentPullRequest;
|
|
21
|
+
createPrCommandHandler?: typeof createPrCommandHandler;
|
|
130
22
|
};
|
|
131
23
|
|
|
132
|
-
function
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (ci === "running") return { text: "CI running", color: "warning" };
|
|
139
|
-
if (pullRequest.isDraft) return { text: "draft", color: "warning" };
|
|
140
|
-
if (pullRequest.reviewDecision === "APPROVED") return { text: "approved", color: "success" };
|
|
141
|
-
return { text: "open", color: "accent" };
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
export function formatPullRequest(pullRequest: PullRequest, theme: ExtensionContext["ui"]["theme"], unresolved = 0): string {
|
|
145
|
-
const prText = theme.fg("text", `PR #${pullRequest.number}`);
|
|
146
|
-
const link = getCapabilities().hyperlinks ? hyperlink(prText, pullRequest.url.href) : prText;
|
|
147
|
-
const status = unresolved > 0
|
|
148
|
-
? { text: `${unresolved} unresolved`, color: "warning" as const }
|
|
149
|
-
: statusFor(pullRequest, ciStatus(pullRequest.statusCheckRollup));
|
|
150
|
-
return `${link} · ${theme.fg(status.color, status.text)}`;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
export default function pullRequestExtension(pi: ExtensionAPI): void {
|
|
24
|
+
export default function pullRequestExtension(
|
|
25
|
+
pi: ExtensionAPI,
|
|
26
|
+
dependencies: PullRequestExtensionDependencies = {},
|
|
27
|
+
): void {
|
|
28
|
+
const load = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
|
|
29
|
+
const createCommandHandler = dependencies.createPrCommandHandler ?? createPrCommandHandler;
|
|
154
30
|
let context: ExtensionContext | undefined;
|
|
155
31
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
156
32
|
let active: AbortController | undefined;
|
|
157
33
|
let queued = false;
|
|
158
|
-
let
|
|
159
|
-
|
|
34
|
+
let refreshFailureReported = false;
|
|
35
|
+
|
|
36
|
+
const render = (ctx: ExtensionContext, pullRequest: Awaited<ReturnType<typeof loadCurrentPullRequest>>): void => {
|
|
37
|
+
const display = projectPrDisplay(pullRequest);
|
|
38
|
+
const footer = pullRequest === null ? undefined : formatPrFooter(display, ctx.ui.theme);
|
|
39
|
+
if (pullRequest !== null && footer === undefined) {
|
|
40
|
+
throw new Error("Current pull request display is missing a footer");
|
|
41
|
+
}
|
|
42
|
+
const widget = formatPrWidget(display);
|
|
43
|
+
ctx.ui.setStatus(UI_KEY, footer);
|
|
44
|
+
ctx.ui.setWidget(UI_KEY, widget === undefined ? undefined : [widget]);
|
|
45
|
+
};
|
|
160
46
|
|
|
161
|
-
const stop = () => {
|
|
47
|
+
const stop = (): void => {
|
|
162
48
|
context = undefined;
|
|
163
49
|
queued = false;
|
|
164
|
-
|
|
165
|
-
reviewWindow = undefined;
|
|
50
|
+
refreshFailureReported = false;
|
|
166
51
|
if (timer !== undefined) clearInterval(timer);
|
|
167
52
|
timer = undefined;
|
|
168
53
|
active?.abort();
|
|
169
54
|
active = undefined;
|
|
170
55
|
};
|
|
171
56
|
|
|
172
|
-
const
|
|
57
|
+
const reportRefreshFailure = (error: unknown): void => {
|
|
58
|
+
const ctx = context;
|
|
59
|
+
if (!ctx || refreshFailureReported) return;
|
|
60
|
+
refreshFailureReported = true;
|
|
61
|
+
try {
|
|
62
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
63
|
+
ctx.ui.notify(`PR status refresh failed: ${message.slice(0, 500)}`, "error");
|
|
64
|
+
} catch (reportError) {
|
|
65
|
+
console.error("PR status refresh failed and could not be reported", error, reportError);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const refresh = async (): Promise<void> => {
|
|
173
70
|
const ctx = context;
|
|
174
71
|
if (!ctx) return;
|
|
175
72
|
if (active) {
|
|
176
|
-
queued
|
|
73
|
+
queued = true;
|
|
177
74
|
return;
|
|
178
75
|
}
|
|
179
76
|
|
|
180
77
|
const controller = new AbortController();
|
|
78
|
+
const loadContext = { cwd: ctx.cwd, signal: controller.signal };
|
|
181
79
|
active = controller;
|
|
182
80
|
try {
|
|
183
|
-
|
|
184
|
-
"gh",
|
|
185
|
-
["pr", "view", "--json", PR_FIELDS],
|
|
186
|
-
{ cwd: ctx.cwd, signal: controller.signal, timeout: 10_000 },
|
|
187
|
-
);
|
|
188
|
-
if (controller.signal.aborted || context !== ctx) return;
|
|
189
|
-
if (result.code !== 0) {
|
|
190
|
-
ctx.ui.setStatus("pi-pr", undefined);
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
const pullRequest = parsePullRequest(JSON.parse(result.stdout));
|
|
195
|
-
if (!pullRequest) {
|
|
196
|
-
reviewState = undefined;
|
|
197
|
-
reviewWindow = undefined;
|
|
198
|
-
ctx.ui.setStatus("pi-pr", undefined);
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
if (pullRequest.state === "MERGED" || pullRequest.state === "CLOSED") {
|
|
202
|
-
reviewState = undefined;
|
|
203
|
-
reviewWindow = undefined;
|
|
204
|
-
ctx.ui.setStatus("pi-pr", formatPullRequest(pullRequest, ctx.ui.theme));
|
|
205
|
-
return;
|
|
206
|
-
}
|
|
207
|
-
const changedPullRequest = reviewWindow?.id !== pullRequest.id;
|
|
208
|
-
if (
|
|
209
|
-
changedPullRequest ||
|
|
210
|
-
reviewWindow?.headRefOid !== pullRequest.headRefOid ||
|
|
211
|
-
reviewWindow?.updatedAt !== pullRequest.updatedAt
|
|
212
|
-
) {
|
|
213
|
-
if (changedPullRequest) reviewState = undefined;
|
|
214
|
-
reviewWindow = {
|
|
215
|
-
id: pullRequest.id,
|
|
216
|
-
headRefOid: pullRequest.headRefOid,
|
|
217
|
-
updatedAt: pullRequest.updatedAt,
|
|
218
|
-
until: Date.now() + REVIEW_POLL_WINDOW_MS,
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
ctx.ui.setStatus("pi-pr", formatPullRequest(
|
|
222
|
-
pullRequest,
|
|
223
|
-
ctx.ui.theme,
|
|
224
|
-
reviewState?.id === pullRequest.id ? reviewState.unresolved : 0,
|
|
225
|
-
));
|
|
226
|
-
if (Date.now() >= reviewWindow.until) return;
|
|
227
|
-
|
|
81
|
+
let pullRequest: Awaited<ReturnType<typeof loadCurrentPullRequest>>;
|
|
228
82
|
try {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
"--jq", "[.data.node.reviewThreads.nodes[] | select(.isResolved == false)] | length",
|
|
235
|
-
],
|
|
236
|
-
{ cwd: ctx.cwd, signal: controller.signal, timeout: 10_000 },
|
|
237
|
-
);
|
|
238
|
-
if (controller.signal.aborted || context !== ctx || reviews.code !== 0) return;
|
|
239
|
-
const unresolved = parseUnresolvedReviewCount(reviews.stdout);
|
|
240
|
-
if (unresolved > 0 && (reviewState?.id !== pullRequest.id || unresolved > reviewState.unresolved)) {
|
|
241
|
-
ctx.ui.notify(`PR #${pullRequest.number} has ${unresolved} unresolved review thread${unresolved === 1 ? "" : "s"}`, "warning");
|
|
242
|
-
}
|
|
243
|
-
reviewState = { id: pullRequest.id, unresolved };
|
|
244
|
-
ctx.ui.setStatus("pi-pr", formatPullRequest(pullRequest, ctx.ui.theme, unresolved));
|
|
245
|
-
} catch {
|
|
246
|
-
// Keep known PR status when review lookup fails.
|
|
83
|
+
pullRequest = await load(pi, loadContext);
|
|
84
|
+
} catch (error) {
|
|
85
|
+
// Keep the last known display when lookup is unavailable.
|
|
86
|
+
if (!controller.signal.aborted && context === ctx) reportRefreshFailure(error);
|
|
87
|
+
return;
|
|
247
88
|
}
|
|
248
|
-
|
|
249
|
-
|
|
89
|
+
if (controller.signal.aborted || context !== ctx) return;
|
|
90
|
+
render(ctx, pullRequest);
|
|
91
|
+
refreshFailureReported = false;
|
|
250
92
|
} finally {
|
|
251
93
|
if (active !== controller) return;
|
|
252
94
|
active = undefined;
|
|
253
95
|
if (queued) {
|
|
254
96
|
queued = false;
|
|
255
|
-
|
|
97
|
+
refreshInBackground();
|
|
256
98
|
}
|
|
257
99
|
}
|
|
258
100
|
};
|
|
259
101
|
|
|
102
|
+
const refreshInBackground = (): void => {
|
|
103
|
+
void refresh().catch(reportRefreshFailure);
|
|
104
|
+
};
|
|
105
|
+
|
|
260
106
|
pi.on("session_start", async (_event, ctx) => {
|
|
261
|
-
if (!ctx.hasUI) return;
|
|
262
107
|
stop();
|
|
108
|
+
if (!ctx.hasUI) return;
|
|
263
109
|
context = ctx;
|
|
264
110
|
await refresh();
|
|
265
|
-
if (context === ctx) timer = setInterval(
|
|
111
|
+
if (context === ctx) timer = setInterval(refreshInBackground, POLL_INTERVAL_MS);
|
|
266
112
|
});
|
|
267
113
|
|
|
268
114
|
pi.on("session_shutdown", stop);
|
|
@@ -270,55 +116,20 @@ export default function pullRequestExtension(pi: ExtensionAPI): void {
|
|
|
270
116
|
pi.on("tool_result", async (event, ctx) => {
|
|
271
117
|
if (!ctx.hasUI || event.isError || !isBashToolResult(event)) return;
|
|
272
118
|
const command = event.input.command;
|
|
273
|
-
if (typeof command === "string" && (GH_PR_CREATE.test(command) || GIT_PUSH.test(command)))
|
|
119
|
+
if (typeof command === "string" && (GH_PR_CREATE.test(command) || GIT_PUSH.test(command))) {
|
|
120
|
+
await refresh().catch(reportRefreshFailure);
|
|
121
|
+
}
|
|
274
122
|
});
|
|
275
123
|
|
|
124
|
+
const commandHandler = createCommandHandler(pi);
|
|
276
125
|
pi.registerCommand("pr", {
|
|
277
|
-
description: "
|
|
278
|
-
handler: async (
|
|
126
|
+
description: "Run the current branch pull request next step",
|
|
127
|
+
handler: async (args, ctx) => {
|
|
279
128
|
if (!ctx.hasUI) return;
|
|
280
|
-
const execute = async (action: string, command: string, args: string[]) => {
|
|
281
|
-
let result;
|
|
282
|
-
try {
|
|
283
|
-
result = await pi.exec(command, args, { cwd: ctx.cwd, signal: ctx.signal, timeout: 10_000 });
|
|
284
|
-
} catch (error) {
|
|
285
|
-
throw new Error(`${action} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
286
|
-
}
|
|
287
|
-
if (result.code !== 0) {
|
|
288
|
-
const detail = result.stderr.trim() || result.stdout.trim() || (result.killed ? "command was cancelled" : `exit code ${result.code}`);
|
|
289
|
-
throw new Error(`${action} failed: ${detail}`);
|
|
290
|
-
}
|
|
291
|
-
return result;
|
|
292
|
-
};
|
|
293
|
-
|
|
294
129
|
try {
|
|
295
|
-
|
|
296
|
-
if (!branch) throw new Error("Create pull request failed: current checkout has no branch");
|
|
297
|
-
const remote = (await execute("Read push remote", "git", ["remote", "get-url", "--push", "origin"])).stdout.trim();
|
|
298
|
-
if (!remote) throw new Error("Read push remote failed: origin has no push URL");
|
|
299
|
-
const repository = await execute("Read push repository", "gh", ["repo", "view", remote, "--json", "nameWithOwner"]);
|
|
300
|
-
const listed = await execute("Find pull requests", "gh", [
|
|
301
|
-
"pr", "list", "--head", branch, "--state", "open", "--limit", "100", "--json", "number,headRepository",
|
|
302
|
-
]);
|
|
303
|
-
const numbers = parseOpenPullRequestNumbers(listed.stdout, parseRepositoryName(repository.stdout));
|
|
304
|
-
if (numbers.length > 1) throw new Error("Open pull request failed: multiple open pull requests found for current branch");
|
|
305
|
-
|
|
306
|
-
if (numbers.length === 1) {
|
|
307
|
-
await execute("Open pull request", "gh", ["pr", "view", String(numbers[0]), "--web"]);
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
const workflow = pi.getCommands().find((command) =>
|
|
312
|
-
command.name === CREATE_PR_SKILL_COMMAND && command.source === "skill" && command.sourceInfo.origin === "package",
|
|
313
|
-
);
|
|
314
|
-
if (!workflow) throw new Error("Create pull request failed: bundled workflow is unavailable");
|
|
315
|
-
if (ctx.isIdle()) {
|
|
316
|
-
pi.sendUserMessage(`/${CREATE_PR_SKILL_COMMAND}`, { expandPromptTemplates: true });
|
|
317
|
-
} else {
|
|
318
|
-
pi.sendUserMessage(`/${CREATE_PR_SKILL_COMMAND}`, { deliverAs: "followUp", expandPromptTemplates: true });
|
|
319
|
-
}
|
|
130
|
+
await commandHandler(args, ctx);
|
|
320
131
|
} finally {
|
|
321
|
-
|
|
132
|
+
refreshInBackground();
|
|
322
133
|
}
|
|
323
134
|
},
|
|
324
135
|
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@henryqw/pi-pr",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Run /pr to automatically choose the current pull request's next safe lifecycle step: create, update, address feedback, fix CI, or merge when ready.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
7
7
|
"pi",
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"files": [
|
|
18
18
|
"LICENSE",
|
|
19
|
+
"docs/pr-routing.svg",
|
|
19
20
|
"extensions",
|
|
20
21
|
"skills",
|
|
21
22
|
"README.md"
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: pi-pr-comment-sweep
|
|
3
|
+
description: Fetch GitHub pull request comments with gh, assess actionable feedback, then apply, validate, commit, push, and resolve scoped fixes without approval pauses. Use when asked to sweep, address, or fix PR comments end to end.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# PR Comment Sweep
|
|
7
|
+
|
|
8
|
+
Address actionable feedback on current-branch PR or supplied PR number/URL.
|
|
9
|
+
Run standalone; never invoke, defer to, or modify Shipyard.
|
|
10
|
+
|
|
11
|
+
1. Resolve `<skill>` as the absolute directory containing the effective
|
|
12
|
+
`SKILL.md` loaded by Pi. It is a placeholder to substitute, never a literal
|
|
13
|
+
path; use it for bundled scripts and references. Set optional `$PR` and
|
|
14
|
+
require a clean tracked tree. On resume, dirt, or head mismatch, read
|
|
15
|
+
[Sweep recovery](<skill>/references/recovery.md>) and stop unless its
|
|
16
|
+
checkpoint or adoption rules pass. For a fresh sweep:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
SNAPSHOT=$(mktemp)
|
|
20
|
+
if [ -n "${PR:-}" ]; then
|
|
21
|
+
node "<skill>/scripts/pr-feedback.mjs" target --pr "$PR"
|
|
22
|
+
node "<skill>/scripts/pr-feedback.mjs" fetch --pr "$PR" --out "$SNAPSHOT"
|
|
23
|
+
else
|
|
24
|
+
node "<skill>/scripts/pr-feedback.mjs" target
|
|
25
|
+
node "<skill>/scripts/pr-feedback.mjs" fetch --out "$SNAPSHOT"
|
|
26
|
+
fi
|
|
27
|
+
PR=$(node "<skill>/scripts/pr-feedback.mjs" snapshot-url --snapshot "$SNAPSHOT")
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
Target verification requires authenticated `gh`, an open PR, exact local/PR
|
|
31
|
+
head, and one matching configured push target. Inspect base, full diff, and raw
|
|
32
|
+
snapshot. Maintain recovery reference's one-line checkpoint after each
|
|
33
|
+
phase.
|
|
34
|
+
2. Initial fetch prints all conversation comments, review bodies, unresolved
|
|
35
|
+
current/outdated threads, replies, and status. Later fetches into a copied
|
|
36
|
+
snapshot print additions, edits, and state changes only; raw JSON remains complete.
|
|
37
|
+
Follow [Thread triage](<skill>/references/thread-triage.md>) and ledger every item as
|
|
38
|
+
`actionable`, `non-actionable`, or `blocked`, with evidence, smallest fix,
|
|
39
|
+
and regression. Never reconstruct GraphQL pagination.
|
|
40
|
+
3. Verify feedback against code, callers, tests, and PR intent. For SDK/framework
|
|
41
|
+
claims, inspect installed types and runtime caller; missing public capability
|
|
42
|
+
is `blocked`, never a private-API or cast workaround. Invocation authorizes
|
|
43
|
+
scoped edits, commits, push, and addressed-thread resolution. Stop only for
|
|
44
|
+
unclear behavior, conflicting feedback, material product choice, or scope
|
|
45
|
+
expansion.
|
|
46
|
+
4. Inspect resulting diff and run smallest relevant non-destructive validation.
|
|
47
|
+
Stage only inspected paths; stop on failed or unavailable required validation
|
|
48
|
+
unless user accepts risk. Never change secrets or unrelated work. Never
|
|
49
|
+
hand-edit generated files: change source, run canonical generator for required
|
|
50
|
+
tracked artifacts, then inspect both. Never wait or poll checks.
|
|
51
|
+
5. Commit accepted fixes with scoped Conventional Commit message(s). With no
|
|
52
|
+
fixes, do not commit or push. After clean-tree validation, run
|
|
53
|
+
`node "<skill>/scripts/pr-feedback.mjs" push --snapshot "$SNAPSHOT"`. The
|
|
54
|
+
helper revalidates the configured destination, PR identity, and local HEAD.
|
|
55
|
+
It pushes the captured OID once with no fallback. Then capture the swept head:
|
|
56
|
+
|
|
57
|
+
```bash
|
|
58
|
+
EXPECTED_HEAD=$(git rev-parse --verify 'HEAD^{commit}')
|
|
59
|
+
```
|
|
60
|
+
6. Copy baseline before final fetch:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
FINAL_SNAPSHOT=$(mktemp)
|
|
64
|
+
cp "$SNAPSHOT" "$FINAL_SNAPSHOT"
|
|
65
|
+
node "<skill>/scripts/pr-feedback.mjs" fetch --pr "$PR" --out "$FINAL_SNAPSHOT"
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Assess delta, then resolve all addressed IDs in one command, repeating flag:
|
|
69
|
+
`node "<skill>/scripts/pr-feedback.mjs" resolve --pr "$PR" --expected-head
|
|
70
|
+
"$EXPECTED_HEAD" --thread "$ID1" --thread "$ID2"`. Before each resolution,
|
|
71
|
+
the helper re-resolves the checkout's configured push target and exact open PR.
|
|
72
|
+
Never resolve non-actionable or blocked threads. Re-fetch once into `FINAL_SNAPSHOT`, assess
|
|
73
|
+
late delta, batch any newly addressed IDs, then run
|
|
74
|
+
`node "<skill>/scripts/pr-feedback.mjs" checks --pr "$PR" --expected-head
|
|
75
|
+
"$EXPECTED_HEAD"` once. Read-only
|
|
76
|
+
transient retries are allowed; mutation retries and polling are not. Do not
|
|
77
|
+
reply unless explicitly requested.
|
|
78
|
+
7. Report `PR | actioned | resolved IDs | skipped IDs | pending IDs | checks |
|
|
79
|
+
commits | push | blockers`.
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# Sweep Recovery
|
|
2
|
+
|
|
3
|
+
Read only for resume or PR-head mismatch.
|
|
4
|
+
|
|
5
|
+
## Checkpoint
|
|
6
|
+
|
|
7
|
+
Only ignored `.context/progress.md` may change under `.context/`. Keep one JSON
|
|
8
|
+
line under `## PR comment sweep`; never stage it:
|
|
9
|
+
|
|
10
|
+
```json
|
|
11
|
+
{"workflow":"pi-pr-comment-sweep","pr":"URL","snapshot":"/tmp/file","final_snapshot":null,"head":"SHA","phase":"triage","owned":[],"ledger":{},"checks":[],"commit":null,"pushed":false,"resolved":[]}
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Use phases `triage`, `editing`, `validated`, `committed`, `pushed`, or `resolved`.
|
|
15
|
+
Update after each phase; keep repository-relative owned paths, thread verdicts in
|
|
16
|
+
`ledger`, exact validation results, commit SHA, push state, and resolved IDs.
|
|
17
|
+
|
|
18
|
+
Resume only when workflow and PR match, snapshot exists and names that PR, local
|
|
19
|
+
`HEAD` equals `head`, and tracked dirty paths equal `owned`. Skip completed
|
|
20
|
+
phases and continue with next one. Stop on missing, malformed, or conflicting
|
|
21
|
+
state; never absorb unknown changes. Commands requiring a clean tree remain
|
|
22
|
+
blocked until tracked work is committed or otherwise restored by user.
|
|
23
|
+
|
|
24
|
+
## Adopt PR head
|
|
25
|
+
|
|
26
|
+
Only exact user words **“Adopt PR head”** authorize this path. Inspect
|
|
27
|
+
`gh pr view "$PR" --json url,headRepository,headRefName,headRefOid`. Validate the
|
|
28
|
+
PR URL, full head OID, and ref. Choose exactly one push URL whose normalized
|
|
29
|
+
GitHub host/owner/repository matches `headRepository`. Run:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
git fetch --no-write-fetch-head --no-tags "$PUSH_URL" "$PR_HEAD_SHA"
|
|
33
|
+
git cat-file -e "$PR_HEAD_SHA^{commit}"
|
|
34
|
+
git log --oneline HEAD.."$PR_HEAD_SHA"
|
|
35
|
+
git diff --stat HEAD.."$PR_HEAD_SHA"
|
|
36
|
+
git merge-base --is-ancestor HEAD "$PR_HEAD_SHA"
|
|
37
|
+
STATUS=$(git status --porcelain=v1 --untracked-files=all) || exit 1
|
|
38
|
+
test -z "$STATUS" || exit 1
|
|
39
|
+
for STATE in MERGE_HEAD rebase-merge rebase-apply CHERRY_PICK_HEAD REVERT_HEAD sequencer; do
|
|
40
|
+
STATE_PATH=$(git rev-parse --git-path "$STATE") || exit 1
|
|
41
|
+
test -n "$STATE_PATH" && test ! -e "$STATE_PATH" || exit 1
|
|
42
|
+
done
|
|
43
|
+
git merge --ff-only "$PR_HEAD_SHA"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Run the clean-tree and Git-operation checks immediately before the fast-forward.
|
|
47
|
+
Stop on an absent object, divergence, dirty tracked or untracked state, an active
|
|
48
|
+
Git operation, or an ambiguous push URL. Never stash, reset, or clean. Re-run
|
|
49
|
+
target verification and initial fetch after fast-forward.
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Thread Triage
|
|
2
|
+
|
|
3
|
+
- Resolved: ignore.
|
|
4
|
+
- Outdated: inspect current diff and source lines; re-anchor before deciding relevance.
|
|
5
|
+
- Open/current: record `actionable`, `non-actionable`, or `blocked` with evidence,
|
|
6
|
+
smallest fix, and regression check.
|
|
7
|
+
- Resolve only an addressed actionable thread after verified fix and head check.
|
|
8
|
+
Never resolve non-actionable or blocked threads; report their IDs as pending.
|