@henryqw/pi-pr 3.0.1 → 3.1.2

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
@@ -34,11 +34,13 @@ write files.
34
34
 
35
35
  ## Use
36
36
 
37
- Run `/pr` without arguments in a GitHub checkout. It reads the current branch pull request and local state, then runs one route. The PR hostname selects its GitHub API host, and the extension works outside Herdr.
37
+ Run `/pr` in a GitHub checkout. It reads the current branch pull request and local state, then runs one route. The PR hostname selects its GitHub API host, and the extension works outside Herdr.
38
+
39
+ Add optional instructions to guide a creation, branch-update, CI-fix, or feedback workflow. For example, run `/pr keep the title under 50 characters`. Direct routes, such as linking or merging, reject instructions instead of ignoring them.
38
40
 
39
41
  | Surface | Type | Purpose |
40
42
  | --- | --- | --- |
41
- | `/pr` | command | Run the current pull request's next safe route. |
43
+ | `/pr [instructions]` | command | Run the current pull request's next safe route. |
42
44
  | Footer | ui | Show a linked `PR #number` and one plain-language status. |
43
45
  | Widget | ui | Show one actionable icon-prefixed `Run /pr to …` hint. |
44
46
 
@@ -54,7 +56,9 @@ Each footer entry is one linked `PR #number` plus one plain-language status: `N
54
56
 
55
57
  | Current condition | `/pr` route |
56
58
  | --- | --- |
57
- | No current-branch pull request, including no upstream push target | Start pull-request creation. |
59
+ | No current-branch pull request, no published matching ref, and safe Git push configuration | Start pull-request creation. |
60
+ | One open pull request inferred from a published matching ref | Confirm the exact `remote/ref`, then link the local branch. |
61
+ | Ambiguous or unsafe discovery | Show the blocked reason and do not mutate Git or GitHub. |
58
62
  | 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
63
  | CI failed | Run the CI fix workflow when the same local prerequisite holds. |
60
64
  | Changes requested or unresolved review threads | Run the package comment sweep when the same local prerequisite holds. |
@@ -63,11 +67,17 @@ Each footer entry is one linked `PR #number` plus one plain-language status: `N
63
67
 
64
68
  `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
69
 
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.
70
+ 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.
71
+
72
+ 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.
73
+
74
+ 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
+
76
+ 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
77
 
68
78
  Failed or empty discovery leaves one rename pending for a later refresh. Closed or merged historical matches do not trigger it.
69
79
 
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`.
80
+ 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
81
 
72
82
  It renames only the workspace. Outside Herdr, it does nothing.
73
83
 
@@ -94,7 +104,9 @@ The comment sweep resolves its bundled helper and references from the installed
94
104
 
95
105
  ### Refresh
96
106
 
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.
107
+ 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
+
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.
98
110
 
99
111
  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
112
 
@@ -106,7 +118,7 @@ Presentation uses route priority, so draft appears before running CI. `/pr` read
106
118
  - It does not run `/done` or `/sweep`.
107
119
  - Polling does not auto-triage comments or start a workflow. The package comment sweep runs only when an explicit `/pr` selects it.
108
120
  - 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.
121
+ - 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
122
  - Creation, discovery, and comment-sweep pushes require one unambiguous push URL for the configured destination.
111
123
  - Presentation fetches use that exact push URL and exact advertised OID. They do not use shared fetch state.
112
124
  - Strict status checks in legacy branch protection or applicable repository rulesets require a base update.
@@ -115,6 +127,8 @@ Presentation uses route priority, so draft appears before running CI. `/pr` read
115
127
  - A merge, rebase, cherry-pick, revert, or sequencer state blocks direct merge, even when `git status` is empty.
116
128
  - A branch update resolves the base repository ref directly. It stops if that ref moves before merge or push.
117
129
  - Before a comment-sweep push, it revalidates the configured destination, full PR identity, and local HEAD. It pushes the captured OID.
130
+ - CI repair captures the failed-step log tail and runs one narrow local reproducer before editing.
118
131
  - An already-published local HEAD needs no second push.
119
132
  - Direct merge requires final confirmation and a fresh readiness check.
133
+ - After a successful merge, the create widget stays hidden until a new local commit.
120
134
  - 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,18 +29,28 @@ 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
 
30
- function dispatchWorkflow(pi: PrCommandPi, ctx: ExtensionCommandContext, commandName: string): void {
32
+ type PrCommandDependencies = {
33
+ loadCurrentPullRequest?: typeof loadCurrentPullRequest;
34
+ linkInferredPullRequest?: typeof linkInferredPullRequest;
35
+ };
36
+
37
+ function dispatchWorkflow(
38
+ pi: PrCommandPi,
39
+ ctx: ExtensionCommandContext,
40
+ commandName: string,
41
+ instructions: string,
42
+ ): void {
31
43
  const command = pi.getCommands().find((candidate) =>
32
44
  candidate.name === commandName &&
33
45
  candidate.source === "skill" &&
34
- candidate.sourceInfo.origin === "package",
46
+ candidate.sourceInfo.origin === "package"
35
47
  );
36
48
  if (!command) throw new Error(`${commandName} failed: bundled workflow is unavailable`);
37
49
 
38
50
  const options = ctx.isIdle()
39
51
  ? { expandPromptTemplates: true }
40
52
  : { deliverAs: "followUp" as const, expandPromptTemplates: true };
41
- pi.sendUserMessage(`/${command.name}`, options);
53
+ pi.sendUserMessage(`/${command.name}${instructions ? ` ${instructions}` : ""}`, options);
42
54
  }
43
55
 
44
56
  function noActionNotification(pullRequest: CurrentPullRequest): { message: string; type: "info" | "warning" } {
@@ -88,14 +100,15 @@ async function mergePullRequest(
88
100
  pi: PrCommandPi,
89
101
  ctx: ExtensionCommandContext,
90
102
  current: CurrentPullRequest,
91
- ): Promise<void> {
103
+ load: typeof loadCurrentPullRequest,
104
+ ): Promise<boolean> {
92
105
  if (!current.merge) throw new Error(`PR #${current.number} merge failed: merge capabilities are unavailable`);
93
106
  const method = selectMergeMethod(current.merge);
94
107
  const confirmed = await ctx.ui.confirm(
95
108
  `Merge PR #${current.number} with ${method}?`,
96
109
  `Merge PR #${current.number} using ${method}.`,
97
110
  );
98
- if (!confirmed) return;
111
+ if (!confirmed) return false;
99
112
 
100
113
  await executeGitHubMerge({
101
114
  exec: (command, args, options) => pi.exec(command, args, {
@@ -112,12 +125,15 @@ async function mergePullRequest(
112
125
  allowedMergeMethods: current.merge.allowedMergeMethods,
113
126
  viewerDefaultMergeMethod: current.merge.viewerDefaultMergeMethod,
114
127
  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`);
128
+ const discovery = await load(pi, ctx, local);
129
+ if (discovery.kind !== "current") {
130
+ throw new Error(`PR #${current.number} merge cancelled: pull request is no longer current`);
131
+ }
132
+ const fresh = discovery.pullRequest;
117
133
  if (!isSameConfirmedMerge(current, fresh)) {
118
134
  throw new Error(`PR #${current.number} merge cancelled: confirmed pull request context changed`);
119
135
  }
120
- if (deriveNextStep(fresh) !== "merge") {
136
+ if (deriveNextStep(discovery) !== "merge") {
121
137
  throw new Error(`PR #${fresh.number} merge cancelled: pull request is no longer merge-ready`);
122
138
  }
123
139
  if (!fresh.merge) throw new Error(`PR #${fresh.number} merge failed: merge capabilities are unavailable`);
@@ -127,28 +143,68 @@ async function mergePullRequest(
127
143
  }
128
144
  },
129
145
  });
146
+ return true;
130
147
  }
131
148
 
132
- export function createPrCommandHandler(pi: PrCommandPi): PrCommandHandler {
133
- return async (args, ctx) => {
134
- if (args.trim()) throw new Error("/pr does not accept arguments");
149
+ async function linkPullRequest(
150
+ pi: PrCommandPi,
151
+ ctx: ExtensionCommandContext,
152
+ current: CurrentPullRequest,
153
+ load: typeof loadCurrentPullRequest,
154
+ link: typeof linkInferredPullRequest,
155
+ ): Promise<void> {
156
+ const targetName = `${current.target.remote}/${current.target.ref}`;
157
+ const confirmed = await ctx.ui.confirm(
158
+ `Link pull request branch to ${targetName}?`,
159
+ `Set ${targetName} as the push target for this branch.`,
160
+ );
161
+ if (!confirmed) return;
162
+
163
+ const discovery = await load(pi, ctx);
164
+ if (
165
+ discovery.kind !== "current" ||
166
+ discovery.pullRequest.target.provenance !== "inferred" ||
167
+ !samePullRequestSnapshot(current, discovery.pullRequest)
168
+ ) throw new Error("Link branch cancelled: inferred pull request context changed");
169
+ await link(pi, ctx, discovery.pullRequest);
170
+ }
135
171
 
136
- const current = await loadCurrentPullRequest(pi, ctx);
137
- const nextStep = deriveNextStep(current);
172
+ export function createPrCommandHandler(
173
+ pi: PrCommandPi,
174
+ dependencies: PrCommandDependencies = {},
175
+ ): PrCommandHandler {
176
+ const load = dependencies.loadCurrentPullRequest ?? loadCurrentPullRequest;
177
+ const link = dependencies.linkInferredPullRequest ?? linkInferredPullRequest;
178
+ return async (args, ctx) => {
179
+ const instructions = args.trim();
180
+ const discovery = await load(pi, ctx);
181
+ const nextStep = deriveNextStep(discovery);
182
+ if (instructions && !(nextStep in WORKFLOWS)) {
183
+ throw new Error("The current /pr route does not accept instructions");
184
+ }
185
+ if (discovery.kind === "inactive") return nextStep;
186
+ if (discovery.kind === "blocked") {
187
+ return nextStep;
188
+ }
138
189
  if (nextStep === "none") {
139
- if (current) {
140
- const notification = noActionNotification(current);
190
+ if (discovery.kind === "current") {
191
+ const notification = noActionNotification(discovery.pullRequest);
141
192
  ctx.ui.notify(notification.message, notification.type);
142
193
  }
143
194
  return nextStep;
144
195
  }
145
- if (nextStep === "merge") {
146
- if (!current) throw new Error("/pr merge failed: pull request is unavailable");
147
- await mergePullRequest(pi, ctx, current);
196
+ if (nextStep === "link-branch") {
197
+ if (discovery.kind !== "current") throw new Error("/pr link failed: pull request is unavailable");
198
+ await linkPullRequest(pi, ctx, discovery.pullRequest, load, link);
148
199
  return nextStep;
149
200
  }
201
+ if (nextStep === "merge") {
202
+ if (discovery.kind !== "current") throw new Error("/pr merge failed: pull request is unavailable");
203
+ return await mergePullRequest(pi, ctx, discovery.pullRequest, load) ? "merge" : "none";
204
+ }
150
205
 
151
- dispatchWorkflow(pi, ctx, WORKFLOWS[nextStep]);
206
+ if (!(nextStep in WORKFLOWS)) throw new Error(`/pr cannot dispatch route ${nextStep}`);
207
+ dispatchWorkflow(pi, ctx, WORKFLOWS[nextStep as WorkflowNextStep], instructions);
152
208
  return nextStep;
153
209
  };
154
210
  }