@henryqw/pi-pr 0.3.7 → 1.0.1
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 +56 -23
- package/docs/pr-routing.svg +127 -0
- package/extensions/pr-command.ts +154 -0
- package/extensions/pr-github.ts +953 -0
- package/extensions/pr-merge.ts +288 -0
- package/extensions/pr-routing.ts +59 -0
- package/extensions/pr-ui.ts +98 -0
- package/extensions/pr.ts +82 -259
- package/package.json +4 -3
- 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,124 @@ 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 {
|
|
8
|
+
hasLocalCommit,
|
|
9
|
+
loadCurrentPullRequest,
|
|
10
|
+
} from "./pr-github.ts";
|
|
11
|
+
import {
|
|
12
|
+
formatPrFooter,
|
|
13
|
+
formatPrWidget,
|
|
14
|
+
projectPrDisplay,
|
|
15
|
+
} from "./pr-ui.ts";
|
|
7
16
|
|
|
8
17
|
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}}}}}";
|
|
18
|
+
const UI_KEY = "pi-pr";
|
|
12
19
|
const GH_PR_CREATE = /(?:^|[;&|]\s*|\n\s*)gh\s+pr\s+create(?=\s|$|[;&|])/;
|
|
20
|
+
const GIT_COMMIT = /(?:^|[;&|]\s*|\n\s*)git\s+commit(?=\s|$|[;&|])/;
|
|
13
21
|
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
|
-
|
|
79
|
-
function parseRepositoryName(json: string): string {
|
|
80
|
-
let value: unknown;
|
|
81
|
-
try {
|
|
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
22
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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";
|
|
23
|
+
type PullRequestExtensionDependencies = {
|
|
24
|
+
loadCurrentPullRequest?: typeof loadCurrentPullRequest;
|
|
25
|
+
hasLocalCommit?: typeof hasLocalCommit;
|
|
26
|
+
createPrCommandHandler?: typeof createPrCommandHandler;
|
|
130
27
|
};
|
|
131
28
|
|
|
132
|
-
function
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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 {
|
|
29
|
+
export default function pullRequestExtension(
|
|
30
|
+
pi: ExtensionAPI,
|
|
31
|
+
dependencies: PullRequestExtensionDependencies = {},
|
|
32
|
+
): void {
|
|
33
|
+
const load = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
|
|
34
|
+
const detectLocalCommit = dependencies.hasLocalCommit ?? hasLocalCommit;
|
|
35
|
+
const createCommandHandler = dependencies.createPrCommandHandler ?? createPrCommandHandler;
|
|
154
36
|
let context: ExtensionContext | undefined;
|
|
155
37
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
156
38
|
let active: AbortController | undefined;
|
|
157
39
|
let queued = false;
|
|
158
|
-
let
|
|
159
|
-
|
|
40
|
+
let refreshFailureReported = false;
|
|
41
|
+
|
|
42
|
+
const render = (
|
|
43
|
+
ctx: ExtensionContext,
|
|
44
|
+
pullRequest: Awaited<ReturnType<typeof loadCurrentPullRequest>>,
|
|
45
|
+
localCommit: boolean,
|
|
46
|
+
): void => {
|
|
47
|
+
const display = projectPrDisplay(pullRequest, localCommit);
|
|
48
|
+
const footer = pullRequest === null ? undefined : formatPrFooter(display, ctx.ui.theme);
|
|
49
|
+
if (pullRequest !== null && footer === undefined) {
|
|
50
|
+
throw new Error("Current pull request display is missing a footer");
|
|
51
|
+
}
|
|
52
|
+
const widget = formatPrWidget(display);
|
|
53
|
+
ctx.ui.setStatus(UI_KEY, footer);
|
|
54
|
+
ctx.ui.setWidget(UI_KEY, widget === undefined ? undefined : [widget]);
|
|
55
|
+
};
|
|
160
56
|
|
|
161
|
-
const stop = () => {
|
|
57
|
+
const stop = (): void => {
|
|
162
58
|
context = undefined;
|
|
163
59
|
queued = false;
|
|
164
|
-
|
|
165
|
-
reviewWindow = undefined;
|
|
60
|
+
refreshFailureReported = false;
|
|
166
61
|
if (timer !== undefined) clearInterval(timer);
|
|
167
62
|
timer = undefined;
|
|
168
63
|
active?.abort();
|
|
169
64
|
active = undefined;
|
|
170
65
|
};
|
|
171
66
|
|
|
172
|
-
const
|
|
67
|
+
const reportRefreshFailure = (error: unknown): void => {
|
|
68
|
+
const ctx = context;
|
|
69
|
+
if (!ctx || refreshFailureReported) return;
|
|
70
|
+
refreshFailureReported = true;
|
|
71
|
+
try {
|
|
72
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
73
|
+
ctx.ui.notify(`PR status refresh failed: ${message.slice(0, 500)}`, "error");
|
|
74
|
+
} catch (reportError) {
|
|
75
|
+
console.error("PR status refresh failed and could not be reported", error, reportError);
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
const refresh = async (): Promise<void> => {
|
|
173
80
|
const ctx = context;
|
|
174
81
|
if (!ctx) return;
|
|
175
82
|
if (active) {
|
|
176
|
-
queued
|
|
83
|
+
queued = true;
|
|
177
84
|
return;
|
|
178
85
|
}
|
|
179
86
|
|
|
180
87
|
const controller = new AbortController();
|
|
88
|
+
const loadContext = { cwd: ctx.cwd, signal: controller.signal };
|
|
181
89
|
active = controller;
|
|
182
90
|
try {
|
|
183
|
-
|
|
184
|
-
|
|
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
|
-
|
|
91
|
+
let pullRequest: Awaited<ReturnType<typeof loadCurrentPullRequest>>;
|
|
92
|
+
let localCommit = false;
|
|
228
93
|
try {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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.
|
|
94
|
+
pullRequest = await load(pi, loadContext);
|
|
95
|
+
if (pullRequest === null) localCommit = await detectLocalCommit(pi, loadContext);
|
|
96
|
+
} catch (error) {
|
|
97
|
+
// Keep the last known display when lookup is unavailable.
|
|
98
|
+
if (!controller.signal.aborted && context === ctx) reportRefreshFailure(error);
|
|
99
|
+
return;
|
|
247
100
|
}
|
|
248
|
-
|
|
249
|
-
|
|
101
|
+
if (controller.signal.aborted || context !== ctx) return;
|
|
102
|
+
render(ctx, pullRequest, localCommit);
|
|
103
|
+
refreshFailureReported = false;
|
|
250
104
|
} finally {
|
|
251
105
|
if (active !== controller) return;
|
|
252
106
|
active = undefined;
|
|
253
107
|
if (queued) {
|
|
254
108
|
queued = false;
|
|
255
|
-
|
|
109
|
+
refreshInBackground();
|
|
256
110
|
}
|
|
257
111
|
}
|
|
258
112
|
};
|
|
259
113
|
|
|
114
|
+
const refreshInBackground = (): void => {
|
|
115
|
+
void refresh().catch(reportRefreshFailure);
|
|
116
|
+
};
|
|
117
|
+
|
|
260
118
|
pi.on("session_start", async (_event, ctx) => {
|
|
261
|
-
if (!ctx.hasUI) return;
|
|
262
119
|
stop();
|
|
120
|
+
if (!ctx.hasUI) return;
|
|
263
121
|
context = ctx;
|
|
264
122
|
await refresh();
|
|
265
|
-
if (context === ctx) timer = setInterval(
|
|
123
|
+
if (context === ctx) timer = setInterval(refreshInBackground, POLL_INTERVAL_MS);
|
|
266
124
|
});
|
|
267
125
|
|
|
268
126
|
pi.on("session_shutdown", stop);
|
|
@@ -270,55 +128,20 @@ export default function pullRequestExtension(pi: ExtensionAPI): void {
|
|
|
270
128
|
pi.on("tool_result", async (event, ctx) => {
|
|
271
129
|
if (!ctx.hasUI || event.isError || !isBashToolResult(event)) return;
|
|
272
130
|
const command = event.input.command;
|
|
273
|
-
if (typeof command === "string" && (GH_PR_CREATE.test(command) || GIT_PUSH.test(command)))
|
|
131
|
+
if (typeof command === "string" && (GH_PR_CREATE.test(command) || GIT_COMMIT.test(command) || GIT_PUSH.test(command))) {
|
|
132
|
+
await refresh().catch(reportRefreshFailure);
|
|
133
|
+
}
|
|
274
134
|
});
|
|
275
135
|
|
|
136
|
+
const commandHandler = createCommandHandler(pi);
|
|
276
137
|
pi.registerCommand("pr", {
|
|
277
|
-
description: "
|
|
278
|
-
handler: async (
|
|
138
|
+
description: "Run the current branch pull request next step",
|
|
139
|
+
handler: async (args, ctx) => {
|
|
279
140
|
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
141
|
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
|
-
}
|
|
142
|
+
await commandHandler(args, ctx);
|
|
320
143
|
} finally {
|
|
321
|
-
|
|
144
|
+
refreshInBackground();
|
|
322
145
|
}
|
|
323
146
|
},
|
|
324
147
|
});
|
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.1",
|
|
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,12 +16,13 @@
|
|
|
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"
|
|
22
23
|
],
|
|
23
24
|
"scripts": {
|
|
24
|
-
"test": "node --test test/*.test.ts",
|
|
25
|
+
"test": "node --test test/*.test.ts && node skills/pi-pr-comment-sweep/scripts/pr-feedback.mjs self-test",
|
|
25
26
|
"typecheck": "tsc --noEmit --allowImportingTsExtensions --target ES2022 --module NodeNext --moduleResolution NodeNext --skipLibCheck extensions/*.ts test/*.test.ts",
|
|
26
27
|
"pack:check": "npm pack --dry-run"
|
|
27
28
|
},
|
|
@@ -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.
|