@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 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` honors an existing configured push target. Without one, it pushes a captured OID to the local branch ref on `origin` and sets upstream.
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 an open current PR. It then prefixes the Herdr workspace label with `#<number> • `.
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. Closed or merged historical matches do not trigger it.
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.
@@ -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 load = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "3.1.3",
3
+ "version": "3.1.5",
4
4
  "description": "Run /pr to safely discover or link the current pull request, then create, update, address feedback, fix CI, or merge when ready.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -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. Resolve `<base>` from explicit input, current PR base, or repository default
16
- branch. Stop if ambiguous. Resolve it to `<base-oid>` with `git rev-parse
17
- --verify --end-of-options "${base}^{commit}"`. Use only the validated OID in
18
- `git merge-base HEAD "$base_oid"`, then inspect `git diff "$merge_base" --`.
19
- Also inspect `git status --short` and staged and unstaged diffs. Never commit
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. Run smallest relevant non-destructive validation for current `HEAD`; state when none exists.
23
- 4. Derive Conventional Commit PR title plus Summary and Testing body from live diff and validation.
24
- 5. Resolve the push destination after validation. Require an attached branch.
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
- 6. Reply only with the already validated PR URL. The extension handles any
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.