@henryqw/pi-pr 2.0.3 → 2.1.0

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
@@ -23,7 +23,9 @@ Run `/pr` without arguments in a GitHub checkout. It reads the current branch pu
23
23
  | Surface | Type | Purpose |
24
24
  | --- | --- | --- |
25
25
  | Footer | ui | Show a linked `PR #number` and one plain-language status. |
26
- | Widget hint | ui | Show at most one hint for the next `/pr` step. |
26
+ | Widget | ui | Show one actionable icon-prefixed `Run /pr to …` hint. |
27
+
28
+ The footer already shows the pull request and status. Actionable widgets omit duplicate identity and status. Each uses one semantic status icon, a space, and a plain `Run /pr to …` route. `✗` marks errors, `!` warnings, `✓` success, and `●` accent or neutral routes. In TUI, only the icon uses a theme color. RPC and non-TUI output use the same plain text without ANSI.
27
29
 
28
30
  ## Flow
29
31
 
@@ -65,7 +67,7 @@ Ordinary conversation comments do not trigger a route or block a merge. Changes
65
67
 
66
68
  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.
67
69
 
68
- The create hint stays hidden until the local branch has a commit beyond its creation point. Any displayed hint 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 hint when the handler finishes.
70
+ 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.
69
71
 
70
72
  Presentation uses route priority, so draft appears before running CI. `/pr` reads fresh state before routing or merging. The command is authoritative for actions.
71
73
 
@@ -1,4 +1,4 @@
1
- import { getCapabilities, hyperlink } from "@earendil-works/pi-tui";
1
+ import { getCapabilities, hyperlink, truncateToWidth } from "@earendil-works/pi-tui";
2
2
  import {
3
3
  deriveNextStep,
4
4
  type NextStep,
@@ -93,6 +93,10 @@ export function formatPrFooter(display: PrDisplay, theme: PrTheme): string | und
93
93
  return `${link} · ${theme.fg(display.footer.color, display.footer.text)}`;
94
94
  }
95
95
 
96
- export function formatPrWidget(display: PrDisplay): string | undefined {
97
- return display.widget;
96
+ export function formatPrWidget(display: PrDisplay, theme?: PrTheme, width?: number): string[] | undefined {
97
+ if (display.widget === undefined) return undefined;
98
+ const color = display.footer?.color ?? "accent";
99
+ const icon = color === "error" ? "✗" : color === "warning" ? "!" : color === "success" ? "✓" : "●";
100
+ const line = `${theme ? theme.fg(color, icon) : icon} ${display.widget}`;
101
+ return [width === undefined ? line : truncateToWidth(line, Math.max(1, width))];
98
102
  }
package/extensions/pr.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  formatPrFooter,
13
13
  formatPrWidget,
14
14
  projectPrDisplay,
15
+ type PrDisplay,
15
16
  } from "./pr-ui.ts";
16
17
 
17
18
  const POLL_INTERVAL_MS = 30_000;
@@ -39,10 +40,25 @@ export default function pullRequestExtension(
39
40
  let active: AbortController | undefined;
40
41
  let queued = false;
41
42
  let refreshFailureReported = false;
42
- let displayedWidget: string | undefined;
43
+ let displayedWidget: PrDisplay | undefined;
43
44
  let commandGeneration = 0;
44
45
  const activeInvocations = new Map<number, "routing" | "workflow">();
45
46
 
47
+ const setWidget = (ctx: ExtensionContext, display: PrDisplay | undefined): void => {
48
+ if (display?.widget === undefined) {
49
+ ctx.ui.setWidget(UI_KEY, undefined);
50
+ return;
51
+ }
52
+ if (ctx.mode === "tui") {
53
+ ctx.ui.setWidget(UI_KEY, (_tui, theme) => ({
54
+ invalidate() {},
55
+ render: (width) => formatPrWidget(display, theme, width)!,
56
+ }));
57
+ return;
58
+ }
59
+ ctx.ui.setWidget(UI_KEY, formatPrWidget(display));
60
+ };
61
+
46
62
  const render = (
47
63
  ctx: ExtensionContext,
48
64
  pullRequest: Awaited<ReturnType<typeof loadCurrentPullRequest>>,
@@ -53,10 +69,10 @@ export default function pullRequestExtension(
53
69
  if (pullRequest !== null && footer === undefined) {
54
70
  throw new Error("Current pull request display is missing a footer");
55
71
  }
56
- displayedWidget = formatPrWidget(display);
72
+ displayedWidget = display.widget === undefined ? undefined : display;
57
73
  const widget = activeInvocations.size > 0 ? undefined : displayedWidget;
58
74
  ctx.ui.setStatus(UI_KEY, footer);
59
- ctx.ui.setWidget(UI_KEY, widget === undefined ? undefined : [widget]);
75
+ setWidget(ctx, widget);
60
76
  };
61
77
 
62
78
  const stop = (): void => {
@@ -172,7 +188,7 @@ export default function pullRequestExtension(
172
188
  const generation = sessionGeneration;
173
189
  const invocation = ++commandGeneration;
174
190
  activeInvocations.set(invocation, "routing");
175
- if (displayedWidget !== undefined) ctx.ui.setWidget(UI_KEY, undefined);
191
+ if (displayedWidget !== undefined) setWidget(ctx, undefined);
176
192
  let nextStep: Awaited<ReturnType<typeof commandHandler>>;
177
193
  try {
178
194
  nextStep = await commandHandler(args, ctx);
@@ -181,7 +197,7 @@ export default function pullRequestExtension(
181
197
  cancelRefresh();
182
198
  activeInvocations.delete(invocation);
183
199
  if (!activeInvocations.size) {
184
- ctx.ui.setWidget(UI_KEY, displayedWidget === undefined ? undefined : [displayedWidget]);
200
+ setWidget(ctx, displayedWidget);
185
201
  }
186
202
  refreshInBackground();
187
203
  }
@@ -192,7 +208,7 @@ export default function pullRequestExtension(
192
208
  if (nextStep !== "none" && nextStep !== "merge") {
193
209
  activeInvocations.set(invocation, "workflow");
194
210
  if (nextStep === "create") ctx.ui.setStatus(UI_KEY, undefined);
195
- ctx.ui.setWidget(UI_KEY, undefined);
211
+ setWidget(ctx, undefined);
196
212
  } else {
197
213
  activeInvocations.delete(invocation);
198
214
  refreshInBackground();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@henryqw/pi-pr",
3
- "version": "2.0.3",
3
+ "version": "2.1.0",
4
4
  "description": "Run /pr to automatically choose the current pull request's next safe lifecycle step: create, update, address feedback, fix CI, or merge when ready.",
5
5
  "keywords": [
6
6
  "pi-package",
@@ -15,7 +15,7 @@ Before changing anything:
15
15
  2. Read `LOCAL_BRANCH`'s validated `%(push:short)` with `git for-each-ref` and enumerate configured remote names. Match an exact `<remote>/` prefix, choosing the unique longest match so remote names containing `/` work. Save that remote and the remaining ref as `PUSH_REMOTE` and `PUSH_REF`, then validate `PUSH_REF` with `git check-ref-format --branch`. Require one push URL and validate its GitHub host and owner/repository. Do not use `%(push:remoteref)` or fall back to `LOCAL_BRANCH`.
16
16
  3. Set `PR_FIELDS=number,url,state,baseRefName,headRepository,headRefName,headRefOid,mergeStateStatus,mergeable`. On the push target's host, search open pull requests by the exact push owner and `PUSH_REF`. Inspect every candidate by its URL with exactly `PR_FIELDS`; stop for incomplete, capped, duplicate, or ambiguous results. Never use branch-default `gh pr view` or retry with `LOCAL_BRANCH`.
17
17
  4. Require exactly one open PR. Require its HTTPS URL to be exactly `HOST/OWNER/REPOSITORY/pull/NUMBER`, with no credentials, port, query, or fragment, and require its number to match. The URL gives the base host and repository. Require `headRepository.nameWithOwner` and `headRefName` to match the recorded push repository and `PUSH_REF`. Require local `HEAD` to equal `headRefOid`. Record the PR and its base/head repositories and refs. Set `EXPECTED_HEAD_SHA` from the initial head OID; never replace it. A fork head and upstream base are normal.
18
- 5. Validate `refs/heads/<BASE_REF>` with `git check-ref-format`, then split the validated base repository into its exact owner and name. Query `repository.ref(qualifiedName: "refs/heads/<BASE_REF>") { name target { oid } }` with `gh api graphql --hostname "$PR_HOST"`. Require no GraphQL errors, the exact recorded repository and ref, and one full hexadecimal target OID. Record that target as `BASE_SHA`; never replace it. Do not read `baseRefOid`, REST `pull.base.sha`, a local remote-tracking ref, or `git ls-remote` as a substitute.
18
+ 5. Validate `refs/heads/<BASE_REF>` with `git check-ref-format`, then split the validated base repository into its exact owner and name. Query `repository { nameWithOwner ref(qualifiedName: "refs/heads/<BASE_REF>") { name target { oid } } }` with `gh api graphql --hostname "$PR_HOST"`. Require no GraphQL errors, `repository.nameWithOwner` to exactly equal the recorded `BASE_REPOSITORY`, the exact recorded ref, and one full hexadecimal target OID. Record that target as `BASE_SHA`; never replace it. Do not read `baseRefOid`, REST `pull.base.sha`, a local remote-tracking ref, or `git ls-remote` as a substitute.
19
19
 
20
20
  ## Fetch, pin, and merge
21
21
 
@@ -49,7 +49,7 @@ printf 'Fetched base %s %s at %s\n' "$BASE_REPOSITORY" "$BASE_REF" "$BASE_SHA"
49
49
 
50
50
  If protocol lookup, API lookup, URL validation, fetch, or object verification fails, stop. The HTTPS helper is command-local and receives credentials only through Git's credential protocol. Never print a token, run `gh auth setup-git`, or change persistent Git config. The recorded `BASE_SHA` is authoritative for this run. Do not fall back to another URL, ref, protocol, or credential source.
51
51
 
52
- Before merging, require the attached branch to remain `LOCAL_BRANCH` and the tree to remain clean. Re-resolve its configured push target exactly as above and require the saved remote, `PUSH_REF`, sole push URL, host, and repository. Re-read the PR by its recorded URL with exactly `PR_FIELDS`; require its number, URL, open state, base repository/ref, head repository, `PUSH_REF`, and head OID to remain unchanged. Re-run the exact live base-ref GraphQL query and require its repository, ref, and target OID to match the recorded values and `BASE_SHA`. Immediately before merging, require local `HEAD` to equal `EXPECTED_HEAD_SHA`:
52
+ Before merging, require the attached branch to remain `LOCAL_BRANCH` and the tree to remain clean. Re-resolve its configured push target exactly as above and require the saved remote, `PUSH_REF`, sole push URL, host, and repository. Re-read the PR by its recorded URL with exactly `PR_FIELDS`; require its number, URL, open state, base repository/ref, head repository, `PUSH_REF`, and head OID to remain unchanged. Re-run the exact live base-ref GraphQL query, including `repository.nameWithOwner`, and require `repository.nameWithOwner` to exactly equal the recorded `BASE_REPOSITORY`; require its ref and target OID to match the recorded values and `BASE_SHA`. Immediately before merging, require local `HEAD` to equal `EXPECTED_HEAD_SHA`:
53
53
 
54
54
  ```bash
55
55
  LOCAL_HEAD="$(git rev-parse --verify HEAD)"
@@ -107,7 +107,7 @@ After the merge completes:
107
107
  ```
108
108
 
109
109
  Stop on either failure. Record the validated full local `HEAD` as `MERGED_HEAD`. Do not substitute a newer ref or another SHA.
110
- 3. Immediately before the single push, repeat the complete branch, configured push target, sole push URL, recorded-URL PR guard, and live base-ref GraphQL query used before merging. Require all saved identities and PR fields to remain exact, the live base target to remain `BASE_SHA`, the tree to be clean, and local `HEAD` to equal `MERGED_HEAD`. Stop on any change.
110
+ 3. Immediately before the single push, repeat the complete branch, configured push target, sole push URL, recorded-URL PR guard, and the same live base-ref GraphQL query, including `repository.nameWithOwner`. Require all saved identities and PR fields to remain exact, `repository.nameWithOwner` to exactly equal the recorded `BASE_REPOSITORY`, the live base target to remain `BASE_SHA`, the tree to be clean, and local `HEAD` to equal `MERGED_HEAD`. Stop on any change.
111
111
  4. Push once to the saved configured push ref, without force or retry:
112
112
 
113
113
  ```bash