@henryqw/pi-pr 3.0.1 → 3.1.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 CHANGED
@@ -54,7 +54,9 @@ Each footer entry is one linked `PR #number` plus one plain-language status: `N
54
54
 
55
55
  | Current condition | `/pr` route |
56
56
  | --- | --- |
57
- | No current-branch pull request, including no upstream push target | Start pull-request creation. |
57
+ | No current-branch pull request, no published matching ref, and safe Git push configuration | Start pull-request creation. |
58
+ | One open pull request inferred from a published matching ref | Confirm the exact `remote/ref`, then link the local branch. |
59
+ | Ambiguous or unsafe discovery | Show the blocked reason and do not mutate Git or GitHub. |
58
60
  | Base update required or merge conflict | Update from the base branch's current target when the tree is clean and local HEAD equals the PR head. |
59
61
  | CI failed | Run the CI fix workflow when the same local prerequisite holds. |
60
62
  | Changes requested or unresolved review threads | Run the package comment sweep when the same local prerequisite holds. |
@@ -63,11 +65,17 @@ Each footer entry is one linked `PR #number` plus one plain-language status: `N
63
65
 
64
66
  `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.
65
67
 
66
- After a `/pr` create workflow settles, the extension waits for a refresh that finds an open current PR. It then ends the Herdr workspace label with one ` · PR #<number>` suffix.
68
+ 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.
69
+
70
+ Multiple candidate remotes, multiple matching PRs, OID mismatches, and unsafe Git push configuration block routing. A published ref with no PR also blocks creation. If no candidate ref exists, creation uses only a validated `origin` destination.
71
+
72
+ 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.
73
+
74
+ 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> • `.
67
75
 
68
76
  Failed or empty discovery leaves one rename pending for a later refresh. Closed or merged historical matches do not trigger it.
69
77
 
70
- It removes every stale trailing PR suffix before adding the current one. This requires `HERDR_ENV=1` and a non-empty, trimmed `HERDR_WORKSPACE_ID`.
78
+ 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`.
71
79
 
72
80
  It renames only the workspace. Outside Herdr, it does nothing.
73
81
 
@@ -94,7 +102,9 @@ The comment sweep resolves its bundled helper and references from the installed
94
102
 
95
103
  ### Refresh
96
104
 
97
- The footer and widget load at session start. They refresh after local commits, PR creation, pushes, and each dispatched workflow settles. They also poll every 30 seconds. Polling updates presentation only and may be stale.
105
+ 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.
106
+
107
+ 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.
98
108
 
99
109
  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.
100
110
 
@@ -106,7 +116,7 @@ Presentation uses route priority, so draft appears before running CI. `/pr` read
106
116
  - It does not run `/done` or `/sweep`.
107
117
  - Polling does not auto-triage comments or start a workflow. The package comment sweep runs only when an explicit `/pr` selects it.
108
118
  - It does not enable auto-merge or add a merge queue.
109
- - It does not rebase the local branch, force-push, delete branches, or clean up worktrees.
119
+ - It does not rebase the local branch, overwrite concurrent remote updates, delete branches, or clean up worktrees. Creation uses exact leases plus ancestry checks; an empty lease is only an atomic absence check.
110
120
  - Creation, discovery, and comment-sweep pushes require one unambiguous push URL for the configured destination.
111
121
  - Presentation fetches use that exact push URL and exact advertised OID. They do not use shared fetch state.
112
122
  - Strict status checks in legacy branch protection or applicable repository rulesets require a base update.
@@ -115,6 +125,8 @@ Presentation uses route priority, so draft appears before running CI. `/pr` read
115
125
  - A merge, rebase, cherry-pick, revert, or sequencer state blocks direct merge, even when `git status` is empty.
116
126
  - A branch update resolves the base repository ref directly. It stops if that ref moves before merge or push.
117
127
  - Before a comment-sweep push, it revalidates the configured destination, full PR identity, and local HEAD. It pushes the captured OID.
128
+ - CI repair captures the failed-step log tail and runs one narrow local reproducer before editing.
118
129
  - An already-published local HEAD needs no second push.
119
130
  - Direct merge requires final confirmation and a fresh readiness check.
131
+ - After a successful merge, the create widget stays hidden until a new local commit.
120
132
  - Only authenticated GitHub.com and GitHub Enterprise repositories are supported.
@@ -45,7 +45,7 @@
45
45
  <line x1="704" y1="168" x2="704" y2="200" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
46
46
  <text x="148" y="192" fill="#4c5665" font-size="12" font-family="'Geist Mono', monospace" text-anchor="middle">1</text>
47
47
  <text x="200" y="176" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">CONDITION</text>
48
- <text x="200" y="196" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">No current PR</text>
48
+ <text x="200" y="196" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Safe PR absence</text>
49
49
  <text x="728" y="176" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">ROUTE</text>
50
50
  <text x="728" y="196" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Create pull request</text>
51
51
  <text x="1136" y="196" fill="#4c5665" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">pi-pr-create</text>
@@ -7,7 +7,9 @@ import {
7
7
  selectMergeMethod,
8
8
  } from "./pr-merge.ts";
9
9
  import {
10
+ linkInferredPullRequest,
10
11
  loadCurrentPullRequest,
12
+ samePullRequestSnapshot,
11
13
  type CurrentPullRequest,
12
14
  } from "./pr-github.ts";
13
15
  import {
@@ -15,7 +17,7 @@ import {
15
17
  type NextStep,
16
18
  } from "./pr-routing.ts";
17
19
 
18
- type WorkflowNextStep = Exclude<NextStep, "none" | "merge">;
20
+ type WorkflowNextStep = Extract<NextStep, "create" | "update-branch" | "sweep" | "fix-ci">;
19
21
 
20
22
  const WORKFLOWS: Record<WorkflowNextStep, string> = {
21
23
  create: "skill:pi-pr-create",
@@ -27,11 +29,16 @@ const WORKFLOWS: Record<WorkflowNextStep, string> = {
27
29
  type PrCommandPi = Pick<ExtensionAPI, "exec" | "getCommands" | "sendUserMessage">;
28
30
  export type PrCommandHandler = (args: string, ctx: ExtensionCommandContext) => Promise<NextStep>;
29
31
 
32
+ type PrCommandDependencies = {
33
+ loadCurrentPullRequest?: typeof loadCurrentPullRequest;
34
+ linkInferredPullRequest?: typeof linkInferredPullRequest;
35
+ };
36
+
30
37
  function dispatchWorkflow(pi: PrCommandPi, ctx: ExtensionCommandContext, commandName: string): void {
31
38
  const command = pi.getCommands().find((candidate) =>
32
39
  candidate.name === commandName &&
33
40
  candidate.source === "skill" &&
34
- candidate.sourceInfo.origin === "package",
41
+ candidate.sourceInfo.origin === "package"
35
42
  );
36
43
  if (!command) throw new Error(`${commandName} failed: bundled workflow is unavailable`);
37
44
 
@@ -88,14 +95,15 @@ async function mergePullRequest(
88
95
  pi: PrCommandPi,
89
96
  ctx: ExtensionCommandContext,
90
97
  current: CurrentPullRequest,
91
- ): Promise<void> {
98
+ load: typeof loadCurrentPullRequest,
99
+ ): Promise<boolean> {
92
100
  if (!current.merge) throw new Error(`PR #${current.number} merge failed: merge capabilities are unavailable`);
93
101
  const method = selectMergeMethod(current.merge);
94
102
  const confirmed = await ctx.ui.confirm(
95
103
  `Merge PR #${current.number} with ${method}?`,
96
104
  `Merge PR #${current.number} using ${method}.`,
97
105
  );
98
- if (!confirmed) return;
106
+ if (!confirmed) return false;
99
107
 
100
108
  await executeGitHubMerge({
101
109
  exec: (command, args, options) => pi.exec(command, args, {
@@ -112,12 +120,15 @@ async function mergePullRequest(
112
120
  allowedMergeMethods: current.merge.allowedMergeMethods,
113
121
  viewerDefaultMergeMethod: current.merge.viewerDefaultMergeMethod,
114
122
  revalidateReadiness: async (local) => {
115
- const fresh = await loadCurrentPullRequest(pi, ctx, local);
116
- if (!fresh) throw new Error(`PR #${current.number} merge cancelled: pull request is no longer current`);
123
+ const discovery = await load(pi, ctx, local);
124
+ if (discovery.kind !== "current") {
125
+ throw new Error(`PR #${current.number} merge cancelled: pull request is no longer current`);
126
+ }
127
+ const fresh = discovery.pullRequest;
117
128
  if (!isSameConfirmedMerge(current, fresh)) {
118
129
  throw new Error(`PR #${current.number} merge cancelled: confirmed pull request context changed`);
119
130
  }
120
- if (deriveNextStep(fresh) !== "merge") {
131
+ if (deriveNextStep(discovery) !== "merge") {
121
132
  throw new Error(`PR #${fresh.number} merge cancelled: pull request is no longer merge-ready`);
122
133
  }
123
134
  if (!fresh.merge) throw new Error(`PR #${fresh.number} merge failed: merge capabilities are unavailable`);
@@ -127,28 +138,66 @@ async function mergePullRequest(
127
138
  }
128
139
  },
129
140
  });
141
+ return true;
130
142
  }
131
143
 
132
- export function createPrCommandHandler(pi: PrCommandPi): PrCommandHandler {
144
+ async function linkPullRequest(
145
+ pi: PrCommandPi,
146
+ ctx: ExtensionCommandContext,
147
+ current: CurrentPullRequest,
148
+ load: typeof loadCurrentPullRequest,
149
+ link: typeof linkInferredPullRequest,
150
+ ): Promise<void> {
151
+ const targetName = `${current.target.remote}/${current.target.ref}`;
152
+ const confirmed = await ctx.ui.confirm(
153
+ `Link pull request branch to ${targetName}?`,
154
+ `Set ${targetName} as the push target for this branch.`,
155
+ );
156
+ if (!confirmed) return;
157
+
158
+ const discovery = await load(pi, ctx);
159
+ if (
160
+ discovery.kind !== "current" ||
161
+ discovery.pullRequest.target.provenance !== "inferred" ||
162
+ !samePullRequestSnapshot(current, discovery.pullRequest)
163
+ ) throw new Error("Link branch cancelled: inferred pull request context changed");
164
+ await link(pi, ctx, discovery.pullRequest);
165
+ }
166
+
167
+ export function createPrCommandHandler(
168
+ pi: PrCommandPi,
169
+ dependencies: PrCommandDependencies = {},
170
+ ): PrCommandHandler {
171
+ const load = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
172
+ const link = dependencies.linkInferredPullRequest ?? linkInferredPullRequest;
133
173
  return async (args, ctx) => {
134
174
  if (args.trim()) throw new Error("/pr does not accept arguments");
135
175
 
136
- const current = await loadCurrentPullRequest(pi, ctx);
137
- const nextStep = deriveNextStep(current);
176
+ const discovery = await load(pi, ctx);
177
+ const nextStep = deriveNextStep(discovery);
178
+ if (discovery.kind === "inactive") return nextStep;
179
+ if (discovery.kind === "blocked") {
180
+ return nextStep;
181
+ }
138
182
  if (nextStep === "none") {
139
- if (current) {
140
- const notification = noActionNotification(current);
183
+ if (discovery.kind === "current") {
184
+ const notification = noActionNotification(discovery.pullRequest);
141
185
  ctx.ui.notify(notification.message, notification.type);
142
186
  }
143
187
  return nextStep;
144
188
  }
145
- if (nextStep === "merge") {
146
- if (!current) throw new Error("/pr merge failed: pull request is unavailable");
147
- await mergePullRequest(pi, ctx, current);
189
+ if (nextStep === "link-branch") {
190
+ if (discovery.kind !== "current") throw new Error("/pr link failed: pull request is unavailable");
191
+ await linkPullRequest(pi, ctx, discovery.pullRequest, load, link);
148
192
  return nextStep;
149
193
  }
194
+ if (nextStep === "merge") {
195
+ if (discovery.kind !== "current") throw new Error("/pr merge failed: pull request is unavailable");
196
+ return await mergePullRequest(pi, ctx, discovery.pullRequest, load) ? "merge" : "none";
197
+ }
150
198
 
151
- dispatchWorkflow(pi, ctx, WORKFLOWS[nextStep]);
199
+ if (!(nextStep in WORKFLOWS)) throw new Error(`/pr cannot dispatch route ${nextStep}`);
200
+ dispatchWorkflow(pi, ctx, WORKFLOWS[nextStep as WorkflowNextStep]);
152
201
  return nextStep;
153
202
  };
154
203
  }