@henryqw/pi-pr 3.1.2 → 3.1.4
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 +18 -7
- package/extensions/pr-github.ts +120 -0
- package/extensions/pr.ts +33 -7
- package/package.json +1 -1
- package/skills/pi-pr-comment-sweep/SKILL.md +6 -2
package/README.md
CHANGED
|
@@ -21,10 +21,13 @@ keeps the complete JSON output. Read one item with
|
|
|
21
21
|
`pr-feedback.mjs show --snapshot FILE --id ID`.
|
|
22
22
|
|
|
23
23
|
`show` prints one JSON record with its exact stored body and fields. A thread
|
|
24
|
-
record includes child IDs without child bodies.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
24
|
+
record includes child IDs without child bodies. Treat that record as a container.
|
|
25
|
+
Inspect the `thread_comment` IDs directly. Do not call `show` on the parent only
|
|
26
|
+
to find children. Show the parent only when it has no child, or when you need
|
|
27
|
+
parent-level metadata. Issue independent `show` lookups in one tool-call round.
|
|
28
|
+
A nested comment includes only its parent thread's ID, state, and location.
|
|
29
|
+
Missing, unknown, duplicate, or ambiguous IDs fail. `show` does not run Git, call
|
|
30
|
+
GitHub, use the network, or write files.
|
|
28
31
|
|
|
29
32
|
## Works with
|
|
30
33
|
|
|
@@ -73,9 +76,9 @@ Multiple candidate remotes, multiple matching PRs, OID mismatches, and unsafe Gi
|
|
|
73
76
|
|
|
74
77
|
The creation workflow repeats destination, remote OID, PR, and configuration checks immediately before pushing. It pushes to the saved validated URL, not a mutable remote name. Every push uses the saved remote OID as an exact lease. Existing refs must also be ancestors of the captured local OID. A missing ref uses an empty lease as a create-only compare-and-swap.
|
|
75
78
|
|
|
76
|
-
After a `/pr` create workflow settles, the extension waits for a refresh that finds
|
|
79
|
+
After a `/pr` create workflow settles, the extension waits for a refresh that finds a configured current PR. It then prefixes the Herdr workspace label with `#<number> • `.
|
|
77
80
|
|
|
78
|
-
Failed or empty discovery leaves one rename pending for a later refresh.
|
|
81
|
+
Failed or empty discovery leaves one rename pending for a later refresh. A restored configured PR completes the rename even when GitHub reports it as merged or closed.
|
|
79
82
|
|
|
80
83
|
It removes repeated leading `#<number> • ` prefixes and legacy trailing ` · PR #<number>` suffixes before adding one current prefix. The remaining workspace name must be non-empty. This requires `HERDR_ENV=1` and a non-empty, trimmed `HERDR_WORKSPACE_ID`.
|
|
81
84
|
|
|
@@ -106,12 +109,20 @@ The comment sweep resolves its bundled helper and references from the installed
|
|
|
106
109
|
|
|
107
110
|
The footer and widget load at session start. A directory outside a Git worktree stays silent and does not start polling. The UI shows `PR · status unavailable` for other discovery failures and reports only a generic error.
|
|
108
111
|
|
|
109
|
-
They refresh after local commits, PR creation, pushes, and each dispatched workflow settles. They also refresh after any successful delegated task settles. Active Git worktrees poll every 30 seconds. Polling updates presentation only and may be stale.
|
|
112
|
+
They refresh after local commits, PR creation, pushes, and each dispatched workflow settles. During creation, intermediate refreshes wait until the workflow settles. They also refresh after any successful delegated task settles. Active Git worktrees poll every 30 seconds. Polling updates presentation only and may be stale.
|
|
110
113
|
|
|
111
114
|
The create widget stays hidden until the local branch has a commit beyond its creation point. Any displayed widget clears as soon as `/pr` starts. A dispatched workflow keeps it hidden until the agent settles. A direct merge, no-action route, or failed command refreshes the widget when the handler finishes.
|
|
112
115
|
|
|
113
116
|
Presentation uses route priority, so draft appears before running CI. `/pr` reads fresh state before routing or merging. The command is authoritative for actions.
|
|
114
117
|
|
|
118
|
+
### Session identity
|
|
119
|
+
|
|
120
|
+
The extension records one configured PR identity in the Pi session. It stores only the PR URL, number, host, head identity, and configured target identity. It does not store lifecycle, CI, review, readiness, or base state. Repeated polling does not add duplicate entries, and no repository cache file is created.
|
|
121
|
+
|
|
122
|
+
Normal discovery always runs first. If the configured remote ref was deleted, the footer and `/pr` may reload the exact observed PR URL. The current host, repository, branch, remote, ref, and local HEAD must still match the observation. Repository names use case-insensitive GitHub matching.
|
|
123
|
+
|
|
124
|
+
The GitHub response must match the observed URL, host, repository, head ref, head OID, and PR number. GitHub supplies fresh mutable state. Invalid session data is ignored. A failed GitHub lookup stops routing and cannot start PR creation.
|
|
125
|
+
|
|
115
126
|
## Limits and recovery
|
|
116
127
|
|
|
117
128
|
- `/pr` takes no arguments and does not open a browser.
|
package/extensions/pr-github.ts
CHANGED
|
@@ -95,6 +95,12 @@ export type CurrentPullRequest = PullRequest & {
|
|
|
95
95
|
|
|
96
96
|
export type CurrentPullRequestDiscovery = PullRequestDiscovery<CurrentPullRequest>;
|
|
97
97
|
|
|
98
|
+
export type PullRequestObservation = {
|
|
99
|
+
pullRequest: { url: string; number: number; host: string };
|
|
100
|
+
head: PullRequestRef;
|
|
101
|
+
target: { repository: string; branch: string; remote: string; ref: string };
|
|
102
|
+
};
|
|
103
|
+
|
|
98
104
|
export type PullRequestLoadContext = Pick<ExtensionContext, "cwd" | "signal">;
|
|
99
105
|
|
|
100
106
|
type CommandOutput = {
|
|
@@ -423,6 +429,67 @@ function parsePullRequestUrl(value: unknown, number: number): { url: URL; reposi
|
|
|
423
429
|
};
|
|
424
430
|
}
|
|
425
431
|
|
|
432
|
+
export function parsePullRequestObservation(value: unknown): PullRequestObservation | null {
|
|
433
|
+
try {
|
|
434
|
+
if (!isRecord(value) || !isRecord(value.pullRequest) || !isRecord(value.head) || !isRecord(value.target)) {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
const number = value.pullRequest.number;
|
|
438
|
+
if (typeof number !== "number" || !Number.isSafeInteger(number) || number <= 0) return null;
|
|
439
|
+
const parsedUrl = parsePullRequestUrl(value.pullRequest.url, number).url;
|
|
440
|
+
const host = text(value.pullRequest.host, "Read pull request observation", "host").toLowerCase();
|
|
441
|
+
if (parsedUrl.hostname.toLowerCase() !== host) return null;
|
|
442
|
+
return {
|
|
443
|
+
pullRequest: { url: parsedUrl.href, number, host },
|
|
444
|
+
head: {
|
|
445
|
+
repository: repositoryName(value.head.repository, "Read pull request observation", "head repository"),
|
|
446
|
+
ref: text(value.head.ref, "Read pull request observation", "head ref"),
|
|
447
|
+
oid: oid(value.head.oid, "Read pull request observation", "head OID"),
|
|
448
|
+
},
|
|
449
|
+
target: {
|
|
450
|
+
repository: repositoryName(value.target.repository, "Read pull request observation", "target repository"),
|
|
451
|
+
branch: text(value.target.branch, "Read pull request observation", "target branch"),
|
|
452
|
+
remote: text(value.target.remote, "Read pull request observation", "target remote"),
|
|
453
|
+
ref: text(value.target.ref, "Read pull request observation", "target ref"),
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
} catch (error) {
|
|
457
|
+
if (error instanceof PullRequestLoadError) return null;
|
|
458
|
+
throw error;
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export function pullRequestObservation(pullRequest: CurrentPullRequest): PullRequestObservation | null {
|
|
463
|
+
if (pullRequest.target.provenance !== "configured") return null;
|
|
464
|
+
return {
|
|
465
|
+
pullRequest: {
|
|
466
|
+
url: pullRequest.url.href,
|
|
467
|
+
number: pullRequest.number,
|
|
468
|
+
host: pullRequest.host,
|
|
469
|
+
},
|
|
470
|
+
head: { ...pullRequest.head },
|
|
471
|
+
target: {
|
|
472
|
+
repository: pullRequest.target.repository,
|
|
473
|
+
branch: pullRequest.target.branch,
|
|
474
|
+
remote: pullRequest.target.remote,
|
|
475
|
+
ref: pullRequest.target.ref,
|
|
476
|
+
},
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function samePullRequestObservation(
|
|
481
|
+
left: PullRequestObservation | undefined,
|
|
482
|
+
right: PullRequestObservation,
|
|
483
|
+
): boolean {
|
|
484
|
+
return left !== undefined && left.pullRequest.url === right.pullRequest.url &&
|
|
485
|
+
left.pullRequest.number === right.pullRequest.number && left.pullRequest.host === right.pullRequest.host &&
|
|
486
|
+
normalizeRepository(left.head.repository) === normalizeRepository(right.head.repository) &&
|
|
487
|
+
left.head.ref === right.head.ref && left.head.oid === right.head.oid &&
|
|
488
|
+
normalizeRepository(left.target.repository) === normalizeRepository(right.target.repository) &&
|
|
489
|
+
left.target.branch === right.target.branch && left.target.remote === right.target.remote &&
|
|
490
|
+
left.target.ref === right.target.ref;
|
|
491
|
+
}
|
|
492
|
+
|
|
426
493
|
function lifecycle(value: unknown): PullRequestLifecycle {
|
|
427
494
|
if (value === "OPEN") return "open";
|
|
428
495
|
if (value === "MERGED") return "merged";
|
|
@@ -1332,6 +1399,45 @@ async function loadPullRequestDetails(
|
|
|
1332
1399
|
};
|
|
1333
1400
|
}
|
|
1334
1401
|
|
|
1402
|
+
async function loadObservedPullRequest(
|
|
1403
|
+
pi: Pick<ExtensionAPI, "exec">,
|
|
1404
|
+
context: PullRequestLoadContext,
|
|
1405
|
+
pushTarget: PushTarget,
|
|
1406
|
+
observation: PullRequestObservation | null,
|
|
1407
|
+
inspectedLocal?: LocalMergeSafety,
|
|
1408
|
+
): Promise<CurrentPullRequest | null> {
|
|
1409
|
+
if (
|
|
1410
|
+
observation === null || observation.pullRequest.host !== pushTarget.repository.host ||
|
|
1411
|
+
normalizeRepository(observation.target.repository) !== pushTarget.repository.normalizedName ||
|
|
1412
|
+
observation.target.branch !== pushTarget.branch || observation.target.remote !== pushTarget.remote ||
|
|
1413
|
+
observation.target.ref !== pushTarget.ref
|
|
1414
|
+
) return null;
|
|
1415
|
+
|
|
1416
|
+
const localHead = oid(singleLine((await execute(pi, context, "Read local HEAD", "git", [
|
|
1417
|
+
"rev-parse", "--verify", "HEAD^{commit}",
|
|
1418
|
+
])).stdout, "Read local HEAD", "OID"), "Read local HEAD", "OID");
|
|
1419
|
+
if (localHead !== observation.head.oid) return null;
|
|
1420
|
+
|
|
1421
|
+
const loaded = await execute(pi, context, "Load observed pull request", "gh", [
|
|
1422
|
+
"pr",
|
|
1423
|
+
"view",
|
|
1424
|
+
observation.pullRequest.url,
|
|
1425
|
+
"--json",
|
|
1426
|
+
PR_FIELDS,
|
|
1427
|
+
]);
|
|
1428
|
+
const candidate = parseLoadedPullRequest(loaded.stdout, new URL(observation.pullRequest.url));
|
|
1429
|
+
if (candidate === null) fail("Load observed pull request", "pull request head repository is unavailable");
|
|
1430
|
+
if (
|
|
1431
|
+
candidate.number !== observation.pullRequest.number ||
|
|
1432
|
+
candidate.url.hostname.toLowerCase() !== observation.pullRequest.host ||
|
|
1433
|
+
normalizeRepository(candidate.head.repository) !== normalizeRepository(observation.head.repository) ||
|
|
1434
|
+
normalizeRepository(candidate.head.repository) !== pushTarget.repository.normalizedName ||
|
|
1435
|
+
candidate.head.ref !== observation.head.ref || candidate.head.ref !== pushTarget.ref ||
|
|
1436
|
+
candidate.head.oid !== observation.head.oid
|
|
1437
|
+
) return null;
|
|
1438
|
+
return loadPullRequestDetails(pi, context, candidate, pushTarget, inspectedLocal);
|
|
1439
|
+
}
|
|
1440
|
+
|
|
1335
1441
|
async function searchPullRequests(
|
|
1336
1442
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1337
1443
|
context: PullRequestLoadContext,
|
|
@@ -1399,6 +1505,7 @@ export async function loadCurrentPullRequest(
|
|
|
1399
1505
|
pi: Pick<ExtensionAPI, "exec">,
|
|
1400
1506
|
context: PullRequestLoadContext,
|
|
1401
1507
|
inspectedLocal?: LocalMergeSafety,
|
|
1508
|
+
observed?: unknown,
|
|
1402
1509
|
): Promise<CurrentPullRequestDiscovery> {
|
|
1403
1510
|
const read = await readPushTarget(pi, context);
|
|
1404
1511
|
if (read.kind === "inactive") return { kind: "inactive" };
|
|
@@ -1430,6 +1537,19 @@ export async function loadCurrentPullRequest(
|
|
|
1430
1537
|
}
|
|
1431
1538
|
|
|
1432
1539
|
const search = await searchPullRequests(pi, context, pushTarget);
|
|
1540
|
+
if (
|
|
1541
|
+
pushTarget.provenance === "configured" && pushTarget.remoteHeadOid === null &&
|
|
1542
|
+
(search.kind === "none" || search.kind === "target-invalid")
|
|
1543
|
+
) {
|
|
1544
|
+
const restored = await loadObservedPullRequest(
|
|
1545
|
+
pi,
|
|
1546
|
+
context,
|
|
1547
|
+
pushTarget,
|
|
1548
|
+
parsePullRequestObservation(observed),
|
|
1549
|
+
inspectedLocal,
|
|
1550
|
+
);
|
|
1551
|
+
if (restored !== null) return { kind: "current", pullRequest: restored };
|
|
1552
|
+
}
|
|
1433
1553
|
if (search.kind === "ambiguous") {
|
|
1434
1554
|
return {
|
|
1435
1555
|
kind: "blocked",
|
package/extensions/pr.ts
CHANGED
|
@@ -8,6 +8,10 @@ import { createPrCommandHandler } from "./pr-command.ts";
|
|
|
8
8
|
import {
|
|
9
9
|
hasLocalCommit,
|
|
10
10
|
loadCurrentPullRequest,
|
|
11
|
+
parsePullRequestObservation,
|
|
12
|
+
pullRequestObservation,
|
|
13
|
+
samePullRequestObservation,
|
|
14
|
+
type PullRequestObservation,
|
|
11
15
|
} from "./pr-github.ts";
|
|
12
16
|
import {
|
|
13
17
|
discoveryIssueKey,
|
|
@@ -22,6 +26,7 @@ import {
|
|
|
22
26
|
const POLL_INTERVAL_MS = 30_000;
|
|
23
27
|
const HERDR_TIMEOUT_MS = 10_000;
|
|
24
28
|
const UI_KEY = "pi-pr";
|
|
29
|
+
const OBSERVATION_ENTRY = "pi-pr-observation";
|
|
25
30
|
const GH_PR_CREATE = /(?:^|[;&|]\s*|\n\s*)gh\s+pr\s+create(?=\s|$|[;&|])/;
|
|
26
31
|
const GIT_COMMIT = /(?:^|[;&|]\s*|\n\s*)git\s+commit(?=\s|$|[;&|])/;
|
|
27
32
|
const GIT_PUSH = /(?:^|[;&|]\s*|\n\s*)git\s+push(?=\s|$|[;&|])/;
|
|
@@ -51,6 +56,14 @@ function parseWorkspaceLabel(response: Record<string, unknown>, workspaceId: str
|
|
|
51
56
|
return label;
|
|
52
57
|
}
|
|
53
58
|
|
|
59
|
+
function latestObservation(ctx: ExtensionContext): PullRequestObservation | undefined {
|
|
60
|
+
for (const entry of [...ctx.sessionManager.getBranch()].reverse()) {
|
|
61
|
+
if (entry.type !== "custom" || entry.customType !== OBSERVATION_ENTRY) continue;
|
|
62
|
+
const observation = parsePullRequestObservation(entry.data);
|
|
63
|
+
if (observation !== null) return observation;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
54
67
|
export default function pullRequestExtension(
|
|
55
68
|
pi: ExtensionAPI,
|
|
56
69
|
dependencies: PullRequestExtensionDependencies = {},
|
|
@@ -86,10 +99,24 @@ export default function pullRequestExtension(
|
|
|
86
99
|
signal.throwIfAborted();
|
|
87
100
|
};
|
|
88
101
|
|
|
89
|
-
const
|
|
102
|
+
const discover = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
|
|
90
103
|
const detectLocalCommit = dependencies.hasLocalCommit ?? hasLocalCommit;
|
|
91
104
|
const createCommandHandler = dependencies.createPrCommandHandler ?? createPrCommandHandler;
|
|
92
105
|
let context: ExtensionContext | undefined;
|
|
106
|
+
let observation: PullRequestObservation | undefined;
|
|
107
|
+
const load: typeof loadCurrentPullRequest = async (api, loadContext, inspectedLocal) => {
|
|
108
|
+
const generation = sessionGeneration;
|
|
109
|
+
const discovery = await discover(api, loadContext, inspectedLocal, observation);
|
|
110
|
+
if (generation !== sessionGeneration) return discovery;
|
|
111
|
+
if (discovery.kind === "current") {
|
|
112
|
+
const current = pullRequestObservation(discovery.pullRequest);
|
|
113
|
+
if (current !== null && !samePullRequestObservation(observation, current)) {
|
|
114
|
+
pi.appendEntry(OBSERVATION_ENTRY, current);
|
|
115
|
+
observation = current;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return discovery;
|
|
119
|
+
};
|
|
93
120
|
let sessionGeneration = 0;
|
|
94
121
|
let timer: ReturnType<typeof setInterval> | undefined;
|
|
95
122
|
let active: AbortController | undefined;
|
|
@@ -164,6 +191,7 @@ export default function pullRequestExtension(
|
|
|
164
191
|
const stop = (): void => {
|
|
165
192
|
sessionGeneration += 1;
|
|
166
193
|
context = undefined;
|
|
194
|
+
observation = undefined;
|
|
167
195
|
queued = false;
|
|
168
196
|
refreshFailureReported = false;
|
|
169
197
|
displayEstablished = false;
|
|
@@ -203,7 +231,7 @@ export default function pullRequestExtension(
|
|
|
203
231
|
|
|
204
232
|
const refresh = async (): Promise<void> => {
|
|
205
233
|
const ctx = context;
|
|
206
|
-
if (!ctx) return;
|
|
234
|
+
if (!ctx || [...activeInvocations.values()].includes("create-workflow")) return;
|
|
207
235
|
const generation = sessionGeneration;
|
|
208
236
|
if (active) {
|
|
209
237
|
queued = true;
|
|
@@ -240,10 +268,7 @@ export default function pullRequestExtension(
|
|
|
240
268
|
refreshFailureReported = false;
|
|
241
269
|
|
|
242
270
|
const pullRequest = discovery.kind === "current" ? discovery.pullRequest : undefined;
|
|
243
|
-
if (
|
|
244
|
-
pendingWorkspaceRename && pullRequest?.lifecycle === "open" &&
|
|
245
|
-
pullRequest.target.provenance === "configured"
|
|
246
|
-
) {
|
|
271
|
+
if (pendingWorkspaceRename && pullRequest?.target.provenance === "configured") {
|
|
247
272
|
pendingWorkspaceRename = false;
|
|
248
273
|
const workspaceId = process.env.HERDR_WORKSPACE_ID?.trim();
|
|
249
274
|
if (process.env.HERDR_ENV === "1" && workspaceId) {
|
|
@@ -279,6 +304,7 @@ export default function pullRequestExtension(
|
|
|
279
304
|
pi.on("session_start", async (_event, ctx) => {
|
|
280
305
|
stop();
|
|
281
306
|
const generation = sessionGeneration;
|
|
307
|
+
observation = latestObservation(ctx);
|
|
282
308
|
if (!ctx.hasUI) return;
|
|
283
309
|
context = ctx;
|
|
284
310
|
await refresh();
|
|
@@ -318,7 +344,7 @@ export default function pullRequestExtension(
|
|
|
318
344
|
}
|
|
319
345
|
});
|
|
320
346
|
|
|
321
|
-
const commandHandler = createCommandHandler(pi);
|
|
347
|
+
const commandHandler = createCommandHandler(pi, { loadCurrentPullRequest: load });
|
|
322
348
|
pi.registerCommand("pr", {
|
|
323
349
|
description: "[instructions] — Run the current branch pull request next step",
|
|
324
350
|
handler: async (args, ctx) => {
|
package/package.json
CHANGED
|
@@ -33,13 +33,17 @@ Run standalone; never invoke, defer to, or modify Shipyard.
|
|
|
33
33
|
|
|
34
34
|
2. Initial fetch prints a compact index of every feedback ID. Later fetches into
|
|
35
35
|
a copied snapshot print compact indexes of additions, edits, and state changes.
|
|
36
|
-
The snapshot remains complete.
|
|
36
|
+
The snapshot remains complete. A thread record with child IDs is a container:
|
|
37
|
+
inspect `thread_comment` IDs directly. Do not call `show` on the parent solely
|
|
38
|
+
to discover its children. Show the parent only if it has no child or parent-level
|
|
39
|
+
metadata is actually needed. For each non-obvious item, inspect only that item:
|
|
37
40
|
|
|
38
41
|
```bash
|
|
39
42
|
node "<skill>/scripts/pr-feedback.mjs" show --snapshot "$SNAPSHOT" --id "$ID"
|
|
40
43
|
```
|
|
41
44
|
|
|
42
|
-
|
|
45
|
+
Issue independent `show` lookups in one tool-call round. Always inspect every
|
|
46
|
+
unresolved feedback item before triage. Never
|
|
43
47
|
read or print the whole raw snapshot when `show` provides the bounded lookup.
|
|
44
48
|
Follow [Thread triage](<skill>/references/thread-triage.md>) and ledger every item as
|
|
45
49
|
`actionable`, `non-actionable`, or `blocked`, with terse evidence, smallest
|