@henryqw/pi-pr 0.3.7 → 1.0.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
@@ -1,11 +1,11 @@
1
1
  # `@henryqw/pi-pr`
2
2
 
3
- See the current branch's pull request state and open or create that pull request from Pi.
3
+ Show the current branch pull request in the Pi footer and use `/pr` to run its next safe step.
4
4
 
5
5
  ## Why
6
6
 
7
- - **Created for**: GitHub contributors who check one branch's pull request while working.
8
- - **Advantage**: See CI, review, merge, and lifecycle state without repeating `gh` commands.
7
+ - **Created for**: Check pull-request progress without repeated `gh` commands.
8
+ - **Advantage**: See the current pull request and its next step in one place.
9
9
 
10
10
  ## Install
11
11
 
@@ -13,11 +13,13 @@ See the current branch's pull request state and open or create that pull request
13
13
  pi install npm:@henryqw/pi-pr
14
14
  ```
15
15
 
16
- Requires authenticated GitHub CLI access (`gh auth login`) in a GitHub repository checkout.
16
+ Requires an authenticated GitHub CLI session (`gh auth login`) and a checkout on GitHub.com or GitHub Enterprise. The PR hostname selects its GitHub API host. It works in generic Pi sessions outside Herdr.
17
+
18
+ The comment sweep resolves its bundled helper and references from the installed package skill path. It does not require an external `jq` executable.
17
19
 
18
20
  ## With
19
21
 
20
- [`@henryqw/pi-footer`](https://pi.henry.wang/extensions/pi-footer) improves this package by showing current-branch pull-request status in the footer.
22
+ [`@henryqw/pi-footer`](https://pi.henry.wang/extensions/pi-footer) improves this package by showing its pull-request status in the footer.
21
23
 
22
24
  ## Use
23
25
 
@@ -27,32 +29,63 @@ If none exists, Pi starts the bundled creation workflow instead.
27
29
 
28
30
  | Surface | Type | Purpose |
29
31
  | --- | --- | --- |
30
- | Footer | ui | Show the current branch pull request. |
31
- | `/pr` | command | Open current branch PR, or start PR workflow when absent. |
32
+ | Footer | ui | Show a linked `PR #number` and one plain-language status. |
33
+ | Widget hint | ui | Show at most one hint for the next `/pr` step. |
34
+
35
+ Footer statuses include `N unresolved`, `draft`, `open`, `approved`, `CI running`, `CI failed`, `changes requested`, `base update required`, `merge conflict`, `merge-ready`, `merged`, and `closed`. Colors support the text; they do not carry meaning alone.
36
+
37
+ Use `/pr` without arguments. It reads the current branch pull request and local state, then runs one route.
38
+
39
+ ![Flowchart showing /pr reading fresh GitHub and local state, choosing the first matching condition, and stopping after one route](./docs/pr-routing.svg)
40
+
41
+ ## Routes
42
+
43
+ | Current condition | `/pr` route |
44
+ | --- | --- |
45
+ | No current-branch pull request, including no upstream push target | Start pull-request creation. |
46
+ | Base update required or merge conflict | Update from the exact base when the tree is clean and local HEAD equals the PR head. |
47
+ | Changes requested or unresolved review threads | Run the package comment sweep when the same local prerequisite holds. |
48
+ | CI failed | Run the CI fix workflow when the same local prerequisite holds. |
49
+ | No-action state | Report the state without taking action. |
50
+ | Merge-ready pull request | Ask for final confirmation, recheck fresh state, and merge directly if confirmed. |
32
51
 
33
- Each entry is one linked `PR #number` plus one plain-language state. Possible states are:
52
+ `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.
34
53
 
35
- - `<count> unresolved`, `draft`, `open`, `approved`, and `CI running`
36
- - `CI failed`, `changes requested`, `merge conflict`, `merged`, and `closed`
54
+ Current-branch discovery matches the exact push repository and ref. It finds a fork-head PR whose base is an upstream repository. A unique historical match uses the exact remote push-ref OID, not local HEAD.
37
55
 
38
- Colors support text. They do not carry meaning alone.
56
+ A no-action state includes a draft, merged or closed pull request, running CI, pending review, or blocked merge policy. It also includes a mutating workflow whose tree is dirty or whose local HEAD differs from the PR head.
39
57
 
40
- Known states have this priority:
58
+ ## Route priority
41
59
 
42
- 1. `<count> unresolved` for known unresolved review threads
43
- 2. `merge conflict`
44
- 3. `changes requested`
45
- 4. `CI failed`
46
- 5. `CI running` for CI progress
60
+ A missing pull request uses creation. For an existing pull request, the first matching condition wins:
47
61
 
48
- The status loads at session start and polls every 30 seconds. It refreshes after an agent successfully runs `gh pr create`, `git push`, or `/pr`.
62
+ 1. Merged, closed, or draft: no action.
63
+ 2. Base update required or merge conflict. Run only with a clean tree and equal local and PR heads.
64
+ 3. Changes requested or unresolved review threads. Apply the same local prerequisite.
65
+ 4. CI failure. Apply the same local prerequisite.
66
+ 5. Waiting or local safety block: no action.
67
+ 6. Merge-ready: allow clean local HEAD equal to or behind the PR head. Confirm, then merge directly.
49
68
 
50
- Unresolved review threads are checked every 30 seconds for 20 minutes after an open PR is first found. Each new push or remote PR update, including new comments, restarts that window.
69
+ Ordinary conversation comments do not trigger a route or block a merge. Changes requested and unresolved review threads can select the package comment sweep.
51
70
 
52
- The footer and warning notification show the unresolved count when first found or increased. The last known footer count remains after review checks stop. No pull request leaves the footer blank.
71
+ ## Refresh
53
72
 
54
- `/pr` finds an open PR for the current branch. When absent, it starts bundled `/skill:pi-pr-create` workflow.
73
+ The footer and widget load at session start and poll every 30 seconds. Polling updates presentation only and may be stale. Presentation uses route priority, so draft appears before running CI. `/pr` reads fresh state before routing or merging. The command is authoritative for actions.
55
74
 
56
- The agent resolves the base and inspects and commits scoped changes. It runs relevant validation, pushes the branch, and creates or updates the PR with a live title and body.
75
+ ## Safety limits
57
76
 
58
- This workflow handles dirty worktrees. It never silently commits unrelated changes.
77
+ - `/pr` takes no arguments and does not open a browser.
78
+ - It does not run `/done` or `/sweep`.
79
+ - Polling does not auto-triage comments or start a workflow. The package comment sweep runs only when an explicit `/pr` selects it.
80
+ - It does not enable auto-merge or add a merge queue.
81
+ - It does not rebase the local branch, force-push, delete branches, or clean up worktrees.
82
+ - Creation, discovery, and comment-sweep pushes require one unambiguous push URL for the configured destination.
83
+ - Presentation fetches use that exact push URL and exact advertised OID. They do not use shared fetch state.
84
+ - Strict status checks in legacy branch protection or applicable repository rulesets require a base update.
85
+ - Applicable ruleset restrictions intersect repository-wide merge methods. An empty intersection stops the workflow.
86
+ - Before merge, `/pr` fetches the exact head OID from the validated push URL without shared fetch state.
87
+ - A merge, rebase, cherry-pick, revert, or sequencer state blocks direct merge, even when `git status` is empty.
88
+ - Before a comment-sweep push, it revalidates the configured destination, full PR identity, and local HEAD. It pushes the captured OID.
89
+ - An already-published local HEAD needs no second push.
90
+ - Direct merge requires final confirmation and a fresh readiness check.
91
+ - Only authenticated GitHub.com and GitHub Enterprise repositories are supported.
@@ -0,0 +1,127 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1280 720" role="img" aria-labelledby="pr-routing-title pr-routing-desc">
3
+ <title id="pr-routing-title">How /pr picks one route</title>
4
+ <desc id="pr-routing-desc">The /pr command creates a pull request when needed, gates branch update, feedback sweep, and CI repair on a clean worktree with local HEAD equal to the PR head, and permits direct merge when clean local HEAD is equal to or behind the PR head.</desc>
5
+ <defs>
6
+ <style>@import url("https://fonts.googleapis.com/css2?family=Instrument+Serif:ital@0;1&amp;family=Geist:wght@400;500;600&amp;family=Geist+Mono:wght@400;500;600&amp;display=swap");</style>
7
+ <marker id="pr-routing-arrow" markerWidth="8" markerHeight="8" refX="8" refY="4" orient="auto">
8
+ <polygon points="0 0, 8 4, 0 8" fill="#4c5665"/>
9
+ </marker>
10
+ <marker id="pr-routing-arrow-accent" markerWidth="8" markerHeight="8" refX="8" refY="4" orient="auto">
11
+ <polygon points="0 0, 8 4, 0 8" fill="#1d4ed8"/>
12
+ </marker>
13
+ <marker id="pr-routing-arrow-link" markerWidth="8" markerHeight="8" refX="8" refY="4" orient="auto">
14
+ <polygon points="0 0, 8 4, 0 8" fill="#1d4ed8"/>
15
+ </marker>
16
+ </defs>
17
+
18
+ <rect width="1280" height="720" fill="#f0eee9"/>
19
+
20
+ <!-- Header -->
21
+ <text x="80" y="68" fill="#101828" font-size="28" font-weight="400" font-family="'Instrument Serif', serif">How /pr picks one route</text>
22
+ <text x="80" y="88" fill="#4c5665" font-size="8" font-family="'Geist', sans-serif">Fresh state enters a strict, top-down priority ladder.</text>
23
+ <text x="1200" y="48" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" text-anchor="end" letter-spacing="0.12em">FIRST MATCHING CONDITION WINS</text>
24
+ <text x="1200" y="68" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" text-anchor="end" letter-spacing="0.12em">AT MOST ONE ROUTE</text>
25
+
26
+ <!-- Continuation arrows, drawn before nodes -->
27
+ <g fill="none" stroke="#4c5665" stroke-width="1.2" marker-end="url(#pr-routing-arrow)">
28
+ <line x1="640" y1="144" x2="640" y2="160"/>
29
+ <line x1="640" y1="208" x2="640" y2="224"/>
30
+ <line x1="640" y1="272" x2="640" y2="288"/>
31
+ <line x1="640" y1="336" x2="640" y2="352"/>
32
+ <line x1="640" y1="400" x2="640" y2="416"/>
33
+ <line x1="640" y1="464" x2="640" y2="480"/>
34
+ <line x1="640" y1="528" x2="640" y2="544"/>
35
+ </g>
36
+
37
+ <!-- Input node -->
38
+ <rect x="240" y="96" width="800" height="48" rx="8" fill="rgba(29,78,216,0.08)" stroke="#1d4ed8" stroke-width="1.2"/>
39
+ <text x="640" y="120" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif" text-anchor="middle">/pr reads fresh state</text>
40
+ <text x="640" y="140" fill="#1d4ed8" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" text-anchor="middle" letter-spacing="0.12em">GITHUB + LOCAL STATE</text>
41
+
42
+ <!-- 1 -->
43
+ <rect x="120" y="160" width="1040" height="48" rx="6" fill="#ffffff" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
44
+ <line x1="176" y1="168" x2="176" y2="200" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
45
+ <line x1="704" y1="168" x2="704" y2="200" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
46
+ <text x="148" y="192" fill="#4c5665" font-size="12" font-family="'Geist Mono', monospace" text-anchor="middle">1</text>
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>
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
+ <text x="728" y="196" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Create pull request</text>
51
+ <text x="1136" y="196" fill="#4c5665" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">pi-pr-create</text>
52
+
53
+ <!-- 2 -->
54
+ <rect x="120" y="224" width="1040" height="48" rx="6" fill="#ffffff" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
55
+ <line x1="176" y1="232" x2="176" y2="264" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
56
+ <line x1="704" y1="232" x2="704" y2="264" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
57
+ <text x="148" y="256" fill="#4c5665" font-size="12" font-family="'Geist Mono', monospace" text-anchor="middle">2</text>
58
+ <text x="200" y="240" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">CONDITION</text>
59
+ <text x="200" y="260" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Merged, closed, or draft</text>
60
+ <text x="728" y="240" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">ROUTE</text>
61
+ <text x="728" y="260" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">No action</text>
62
+ <text x="1136" y="260" fill="#4c5665" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">report current state</text>
63
+
64
+ <!-- 3 -->
65
+ <rect x="120" y="288" width="1040" height="48" rx="6" fill="#ffffff" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
66
+ <line x1="176" y1="296" x2="176" y2="328" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
67
+ <line x1="704" y1="296" x2="704" y2="328" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
68
+ <text x="148" y="320" fill="#4c5665" font-size="12" font-family="'Geist Mono', monospace" text-anchor="middle">3</text>
69
+ <text x="200" y="304" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">CONDITION</text>
70
+ <text x="200" y="324" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Base behind or conflicted</text>
71
+ <text x="728" y="304" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">ROUTE</text>
72
+ <text x="728" y="324" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Update if clean + equal</text>
73
+ <text x="1136" y="324" fill="#4c5665" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">otherwise no action</text>
74
+
75
+ <!-- 4 -->
76
+ <rect x="120" y="352" width="1040" height="48" rx="6" fill="#ffffff" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
77
+ <line x1="176" y1="360" x2="176" y2="392" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
78
+ <line x1="704" y1="360" x2="704" y2="392" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
79
+ <text x="148" y="384" fill="#4c5665" font-size="12" font-family="'Geist Mono', monospace" text-anchor="middle">4</text>
80
+ <text x="200" y="368" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">CONDITION</text>
81
+ <text x="200" y="388" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Changes requested or unresolved threads</text>
82
+ <text x="728" y="368" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">ROUTE</text>
83
+ <text x="728" y="388" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Sweep if clean + equal</text>
84
+ <text x="1136" y="388" fill="#4c5665" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">otherwise no action</text>
85
+
86
+ <!-- 5 -->
87
+ <rect x="120" y="416" width="1040" height="48" rx="6" fill="#ffffff" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
88
+ <line x1="176" y1="424" x2="176" y2="456" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
89
+ <line x1="704" y1="424" x2="704" y2="456" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
90
+ <text x="148" y="448" fill="#4c5665" font-size="12" font-family="'Geist Mono', monospace" text-anchor="middle">5</text>
91
+ <text x="200" y="432" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">CONDITION</text>
92
+ <text x="200" y="452" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">CI failed</text>
93
+ <text x="728" y="432" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">ROUTE</text>
94
+ <text x="728" y="452" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Fix CI if clean + equal</text>
95
+ <text x="1136" y="452" fill="#4c5665" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">otherwise no action</text>
96
+
97
+ <!-- 6 -->
98
+ <rect x="120" y="480" width="1040" height="48" rx="6" fill="#ffffff" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
99
+ <line x1="176" y1="488" x2="176" y2="520" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
100
+ <line x1="704" y1="488" x2="704" y2="520" stroke="rgba(16,24,40,0.141)" stroke-width="1"/>
101
+ <text x="148" y="512" fill="#4c5665" font-size="12" font-family="'Geist Mono', monospace" text-anchor="middle">6</text>
102
+ <text x="200" y="496" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">CONDITION</text>
103
+ <text x="200" y="516" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Other waiting or merge-local block</text>
104
+ <text x="728" y="496" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">ROUTE</text>
105
+ <text x="728" y="516" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">No action</text>
106
+ <text x="1136" y="516" fill="#4c5665" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">CI running, review or policy pending, dirty, ahead, or diverged</text>
107
+
108
+ <!-- 7 -->
109
+ <rect x="120" y="544" width="1040" height="48" rx="6" fill="rgba(29,78,216,0.08)" stroke="#1d4ed8" stroke-width="1.2"/>
110
+ <line x1="176" y1="552" x2="176" y2="584" stroke="rgba(29,78,216,0.32)" stroke-width="1"/>
111
+ <line x1="704" y1="552" x2="704" y2="584" stroke="rgba(29,78,216,0.32)" stroke-width="1"/>
112
+ <text x="148" y="576" fill="#1d4ed8" font-size="12" font-family="'Geist Mono', monospace" text-anchor="middle">7</text>
113
+ <text x="200" y="560" fill="#1d4ed8" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">CONDITION</text>
114
+ <text x="200" y="580" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Merge-ready + clean + equal or behind</text>
115
+ <text x="728" y="560" fill="#1d4ed8" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">ROUTE</text>
116
+ <text x="728" y="580" fill="#101828" font-size="12" font-weight="600" font-family="'Geist', sans-serif">Confirm, recheck, merge</text>
117
+ <text x="1136" y="580" fill="#1d4ed8" font-size="8" font-family="'Geist Mono', monospace" text-anchor="end">expected-head GraphQL mutation</text>
118
+
119
+ <!-- Legend strip -->
120
+ <line x1="44" y1="624" x2="1236" y2="624" stroke="rgba(16,24,40,0.141)" stroke-width="0.8"/>
121
+ <line x1="80" y1="636" x2="80" y2="664" stroke="#4c5665" stroke-width="1.2" marker-end="url(#pr-routing-arrow)"/>
122
+ <text x="104" y="640" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">DOWNWARD CONNECTOR</text>
123
+ <text x="104" y="664" fill="#101828" font-size="8" font-family="'Geist', sans-serif">Otherwise, continue to the next condition.</text>
124
+ <text x="668" y="660" fill="#101828" font-size="28" font-weight="400" font-family="'Instrument Serif', serif">1×</text>
125
+ <text x="724" y="640" fill="#4c5665" font-size="8" font-weight="500" font-family="'Geist Mono', monospace" letter-spacing="0.12em">ONE-ROUTE RULE</text>
126
+ <text x="724" y="664" fill="#101828" font-size="8" font-family="'Geist', sans-serif">At most one route: first matching condition wins, then /pr stops.</text>
127
+ </svg>
@@ -0,0 +1,154 @@
1
+ import type {
2
+ ExtensionAPI,
3
+ ExtensionCommandContext,
4
+ RegisteredCommand,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import {
7
+ executeGitHubMerge,
8
+ selectMergeMethod,
9
+ } from "./pr-merge.ts";
10
+ import {
11
+ loadCurrentPullRequest,
12
+ type CurrentPullRequest,
13
+ } from "./pr-github.ts";
14
+ import {
15
+ deriveNextStep,
16
+ type NextStep,
17
+ } from "./pr-routing.ts";
18
+
19
+ type WorkflowNextStep = Exclude<NextStep, "none" | "merge">;
20
+
21
+ const WORKFLOWS: Record<WorkflowNextStep, string> = {
22
+ create: "skill:pi-pr-create",
23
+ "update-branch": "skill:pi-pr-update-branch",
24
+ sweep: "skill:pi-pr-comment-sweep",
25
+ "fix-ci": "skill:pi-pr-fix-ci",
26
+ };
27
+
28
+ type PrCommandPi = Pick<ExtensionAPI, "exec" | "getCommands" | "sendUserMessage">;
29
+ export type PrCommandHandler = RegisteredCommand["handler"];
30
+
31
+ function dispatchWorkflow(pi: PrCommandPi, ctx: ExtensionCommandContext, commandName: string): void {
32
+ const command = pi.getCommands().find((candidate) =>
33
+ candidate.name === commandName &&
34
+ candidate.source === "skill" &&
35
+ candidate.sourceInfo.origin === "package",
36
+ );
37
+ if (!command) throw new Error(`${commandName} failed: bundled workflow is unavailable`);
38
+
39
+ const options = ctx.isIdle()
40
+ ? { expandPromptTemplates: true }
41
+ : { deliverAs: "followUp" as const, expandPromptTemplates: true };
42
+ pi.sendUserMessage(`/${command.name}`, options);
43
+ }
44
+
45
+ function noActionNotification(pullRequest: CurrentPullRequest): { message: string; type: "info" | "warning" } {
46
+ if (pullRequest.lifecycle === "merged" || pullRequest.lifecycle === "closed") {
47
+ return { message: `PR #${pullRequest.number} is ${pullRequest.lifecycle}; no action needed`, type: "info" };
48
+ }
49
+ if (pullRequest.conditions.draft) {
50
+ return { message: `PR #${pullRequest.number} is draft; no action available`, type: "warning" };
51
+ }
52
+ const mutatingWorkflowSelected = pullRequest.conditions.baseUpdateRequired || pullRequest.conditions.conflict ||
53
+ pullRequest.conditions.changesRequested || pullRequest.conditions.unresolvedThreads > 0 ||
54
+ pullRequest.conditions.ci === "failure";
55
+ if (mutatingWorkflowSelected && pullRequest.local.worktree === "dirty") {
56
+ return { message: `PR #${pullRequest.number} is blocked by a dirty worktree`, type: "warning" };
57
+ }
58
+ if (mutatingWorkflowSelected && pullRequest.local.head !== "equal") {
59
+ return { message: `PR #${pullRequest.number} is blocked by local HEAD ${pullRequest.local.head}`, type: "warning" };
60
+ }
61
+ if (pullRequest.conditions.ci === "running") {
62
+ return { message: `PR #${pullRequest.number} is waiting for CI`, type: "warning" };
63
+ }
64
+ if (pullRequest.conditions.review === "pending") {
65
+ return { message: `PR #${pullRequest.number} is waiting for review`, type: "warning" };
66
+ }
67
+ if (pullRequest.conditions.policy === "pending") {
68
+ return { message: `PR #${pullRequest.number} is blocked by merge policy`, type: "warning" };
69
+ }
70
+ if (pullRequest.local.worktree === "dirty") {
71
+ return { message: `PR #${pullRequest.number} is blocked by a dirty worktree`, type: "warning" };
72
+ }
73
+ if (pullRequest.local.head === "ahead" || pullRequest.local.head === "diverged") {
74
+ return { message: `PR #${pullRequest.number} is blocked by local HEAD ${pullRequest.local.head}`, type: "warning" };
75
+ }
76
+ return { message: `PR #${pullRequest.number} has no available action`, type: "warning" };
77
+ }
78
+
79
+ function isSameConfirmedMerge(current: CurrentPullRequest, fresh: CurrentPullRequest): boolean {
80
+ return current.id === fresh.id && current.number === fresh.number &&
81
+ current.url.href === fresh.url.href && current.host === fresh.host &&
82
+ current.head.repository === fresh.head.repository &&
83
+ current.head.ref === fresh.head.ref && current.head.oid === fresh.head.oid &&
84
+ current.base.repository === fresh.base.repository &&
85
+ current.base.ref === fresh.base.ref && current.base.oid === fresh.base.oid;
86
+ }
87
+
88
+ async function mergePullRequest(
89
+ pi: PrCommandPi,
90
+ ctx: ExtensionCommandContext,
91
+ current: CurrentPullRequest,
92
+ ): Promise<void> {
93
+ if (!current.merge) throw new Error(`PR #${current.number} merge failed: merge capabilities are unavailable`);
94
+ const method = selectMergeMethod(current.merge);
95
+ const confirmed = await ctx.ui.confirm(
96
+ `Merge PR #${current.number} with ${method}?`,
97
+ `Merge PR #${current.number} using ${method}.`,
98
+ );
99
+ if (!confirmed) return;
100
+
101
+ await executeGitHubMerge({
102
+ exec: (command, args, options) => pi.exec(command, args, {
103
+ ...options,
104
+ signal: ctx.signal,
105
+ timeout: 10_000,
106
+ }),
107
+ cwd: ctx.cwd,
108
+ pullRequestId: current.id,
109
+ hostname: current.host,
110
+ expectedHead: current.head.oid,
111
+ expectedBase: current.base,
112
+ headFetchSource: current.headFetchSource,
113
+ allowedMergeMethods: current.merge.allowedMergeMethods,
114
+ viewerDefaultMergeMethod: current.merge.viewerDefaultMergeMethod,
115
+ revalidateReadiness: async (local) => {
116
+ const fresh = await loadCurrentPullRequest(pi, ctx, local);
117
+ if (!fresh) throw new Error(`PR #${current.number} merge cancelled: pull request is no longer current`);
118
+ if (!isSameConfirmedMerge(current, fresh)) {
119
+ throw new Error(`PR #${current.number} merge cancelled: confirmed pull request context changed`);
120
+ }
121
+ if (deriveNextStep(fresh) !== "merge") {
122
+ throw new Error(`PR #${fresh.number} merge cancelled: pull request is no longer merge-ready`);
123
+ }
124
+ if (!fresh.merge) throw new Error(`PR #${fresh.number} merge failed: merge capabilities are unavailable`);
125
+ const freshMethod = selectMergeMethod(fresh.merge);
126
+ if (freshMethod !== method) {
127
+ throw new Error(`PR #${fresh.number} merge cancelled: merge method changed from ${method} to ${freshMethod}`);
128
+ }
129
+ },
130
+ });
131
+ }
132
+
133
+ export function createPrCommandHandler(pi: PrCommandPi): PrCommandHandler {
134
+ return async (args, ctx) => {
135
+ if (args.trim()) throw new Error("/pr does not accept arguments");
136
+
137
+ const current = await loadCurrentPullRequest(pi, ctx);
138
+ const nextStep = deriveNextStep(current);
139
+ if (nextStep === "none") {
140
+ if (current) {
141
+ const notification = noActionNotification(current);
142
+ ctx.ui.notify(notification.message, notification.type);
143
+ }
144
+ return;
145
+ }
146
+ if (nextStep === "merge") {
147
+ if (!current) throw new Error("/pr merge failed: pull request is unavailable");
148
+ await mergePullRequest(pi, ctx, current);
149
+ return;
150
+ }
151
+
152
+ dispatchWorkflow(pi, ctx, WORKFLOWS[nextStep]);
153
+ };
154
+ }