@henryqw/pi-pr 3.1.3 → 3.1.5
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 +15 -3
- package/extensions/pr-github.ts +120 -0
- package/extensions/pr.ts +32 -6
- package/package.json +1 -1
- package/skills/pi-pr-create/SKILL.md +25 -9
package/README.md
CHANGED
|
@@ -68,7 +68,11 @@ Each footer entry is one linked `PR #number` plus one plain-language status: `N
|
|
|
68
68
|
| No-action state | Report the state without taking action. |
|
|
69
69
|
| Merge-ready pull request | Ask for final confirmation, recheck fresh state, and merge directly if confirmed. |
|
|
70
70
|
|
|
71
|
-
`pi-pr-create`
|
|
71
|
+
`pi-pr-create` fetches branches from `origin` and finds the current branch's parent. The parent can be a feature branch. It uses an explicit base when provided. Otherwise, it accepts only a uniquely identifiable parent from reflog and commit history.
|
|
72
|
+
|
|
73
|
+
It merges the parent's captured commit before validation and push. It resolves clear conflicts and stops when the base or conflict intent is ambiguous.
|
|
74
|
+
|
|
75
|
+
It honors an existing configured push target. Without one, it pushes a captured OID to the local branch ref on `origin` and sets upstream.
|
|
72
76
|
|
|
73
77
|
Without a configured push target, discovery checks validated remotes for the same branch ref. One exact open PR becomes an inferred target. `/pr` names the exact `remote/ref` and asks before linking it. The extension revalidates the branch, PR, remote OID, and Git configuration before mutation. It rolls back its upstream and remote-tracking changes if final verification fails.
|
|
74
78
|
|
|
@@ -76,9 +80,9 @@ Multiple candidate remotes, multiple matching PRs, OID mismatches, and unsafe Gi
|
|
|
76
80
|
|
|
77
81
|
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.
|
|
78
82
|
|
|
79
|
-
After a `/pr` create workflow settles, the extension waits for a refresh that finds
|
|
83
|
+
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> • `.
|
|
80
84
|
|
|
81
|
-
Failed or empty discovery leaves one rename pending for a later refresh.
|
|
85
|
+
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.
|
|
82
86
|
|
|
83
87
|
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`.
|
|
84
88
|
|
|
@@ -115,6 +119,14 @@ The create widget stays hidden until the local branch has a commit beyond its cr
|
|
|
115
119
|
|
|
116
120
|
Presentation uses route priority, so draft appears before running CI. `/pr` reads fresh state before routing or merging. The command is authoritative for actions.
|
|
117
121
|
|
|
122
|
+
### Session identity
|
|
123
|
+
|
|
124
|
+
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.
|
|
125
|
+
|
|
126
|
+
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.
|
|
127
|
+
|
|
128
|
+
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.
|
|
129
|
+
|
|
118
130
|
## Limits and recovery
|
|
119
131
|
|
|
120
132
|
- `/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;
|
|
@@ -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
|
@@ -12,16 +12,32 @@ Never concatenate one into shell syntax. Pass dynamic values through quoted shel
|
|
|
12
12
|
variables. Use `--` before positional Git arguments when the command supports it.
|
|
13
13
|
Never pass a credential-bearing URL to another command or print it.
|
|
14
14
|
|
|
15
|
-
1.
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
15
|
+
1. Fetch current branch refs from `origin` without tags or submodules. Resolve
|
|
16
|
+
`<base>` from explicit input or the branch's actual parent. The parent may be
|
|
17
|
+
a feature branch; never assume the repository default branch. To infer it,
|
|
18
|
+
inspect branch-creation reflogs and the fetched commit graph, excluding the
|
|
19
|
+
current branch and symbolic refs. Accept history inference only when one
|
|
20
|
+
validated remote branch is uniquely nearest to `HEAD`. Stop and ask for the
|
|
21
|
+
base when evidence is absent or ambiguous. Validate `<base>` with
|
|
22
|
+
`git check-ref-format --branch "$base"`, resolve
|
|
23
|
+
`refs/remotes/origin/${base}` to a full `<base-oid>`, and use only that OID in
|
|
24
|
+
`git merge-base HEAD "$base_oid"` and `git diff "$merge_base" --`. Also
|
|
25
|
+
inspect `git status --short` and staged and unstaged diffs. Never commit
|
|
20
26
|
`.context/` or unrelated changes.
|
|
21
27
|
2. Commit each coherent pending change with a scoped Conventional Commit. Preserve existing coherent staging; stop when changes cannot be separated safely.
|
|
22
|
-
3.
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
3. Require an attached branch and a clean tree, then merge the captured base OID
|
|
29
|
+
with `git merge --no-edit "$base_oid"`. Never merge the mutable branch name.
|
|
30
|
+
If conflicts occur, list only unmerged paths, inspect bounded conflict hunks,
|
|
31
|
+
preserve compatible changes from both sides, and stage each resolved path.
|
|
32
|
+
Regenerate generated files after resolving their sources. If intended behavior
|
|
33
|
+
is unclear, leave the merge pending and ask the user instead of guessing.
|
|
34
|
+
When no unmerged paths remain, run `GIT_EDITOR=true git merge --continue`.
|
|
35
|
+
Never rebase, reset, auto-stash, choose an entire side blindly, or bypass hooks.
|
|
36
|
+
Require `git merge-base --is-ancestor "$base_oid" HEAD` and a clean tree before
|
|
37
|
+
continuing.
|
|
38
|
+
4. Run the smallest relevant non-destructive validation for current `HEAD`; state when none exists.
|
|
39
|
+
5. Derive a Conventional Commit PR title plus Summary and Testing body from the live diff and validation.
|
|
40
|
+
6. Resolve the push destination after validation. Require an attached branch.
|
|
25
41
|
Validate the branch with `git check-ref-format --branch "$branch"`. Capture
|
|
26
42
|
and validate the full `HEAD^{commit}` OID.
|
|
27
43
|
|
|
@@ -67,5 +83,5 @@ Never pass a credential-bearing URL to another command or print it.
|
|
|
67
83
|
Reuse one result only when its base matches. Refresh its title and body.
|
|
68
84
|
Stop on a different base or multiple results. Otherwise create with quoted,
|
|
69
85
|
explicit `--repo`, `--head`, `--base`, title, and body-file arguments.
|
|
70
|
-
|
|
86
|
+
7. Reply only with the already validated PR URL. The extension handles any
|
|
71
87
|
Herdr workspace label update after it discovers the open PR.
|