@nvice-r/pr-commands 0.1.1 → 0.1.3
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 +65 -19
- package/assets/bin/bbpr +7 -4
- package/assets/bin/pr-common.sh +11 -5
- package/assets/skill/pr.body.md +278 -0
- package/install.js +183 -24
- package/package.json +1 -1
- package/assets/skill/pr.md +0 -157
package/README.md
CHANGED
|
@@ -23,11 +23,16 @@ The interactive wizard:
|
|
|
23
23
|
4. **Identity + token** — reads your API token from the keychain (or tells you how to add one).
|
|
24
24
|
5. **Reviewers** — fetches the repo's default reviewers into an interactive checklist
|
|
25
25
|
(`↑/↓` move · `space` toggle · `a` all · `n` none · `b` back · `enter` confirm; all pre-selected).
|
|
26
|
+
On reinstall, an existing roster is kept: its members are pre-selected instead, and entries
|
|
27
|
+
that aren't current default reviewers are carried over as-is.
|
|
26
28
|
6. **Convention** — pick a preset, then review/edit its title-pattern, ticket-regex, and
|
|
27
29
|
template-sections (each pre-filled from the preset, editable). A placeholder legend
|
|
28
30
|
(`<ver>`, `<desc>`, `<ticket>`, …) is shown. Concrete values are written to config — `/pr`
|
|
29
31
|
reads those, not a hard-coded table.
|
|
30
|
-
7. **
|
|
32
|
+
7. **Agents** — checklist of supported agents (claude-code / antigravity / codex / opencode) with
|
|
33
|
+
installed ones pre-checked; toggle which get the `pr` skill. (`--agent` skips this; `--yes` = detected.)
|
|
34
|
+
8. **Install** — writes config + roster + binaries, then installs the portable `pr` skill into
|
|
35
|
+
the chosen agents (symlinked to one canonical copy) + Claude's `/pr` command.
|
|
31
36
|
|
|
32
37
|
Every prompt is reversible: in a list press `b` to step back; in a text field, append `:b` to
|
|
33
38
|
the value and press enter (the field is pre-filled, so `:b` at the end triggers back). Back
|
|
@@ -39,8 +44,11 @@ Flags:
|
|
|
39
44
|
|------|--------|
|
|
40
45
|
| `--dir <path>` | repo to configure (default: cwd) |
|
|
41
46
|
| `--provider <p>` | force `bitbucket`\|`github`\|`gitlab` instead of detecting |
|
|
47
|
+
| `--agent <id>` | force skill install to this agent (repeatable): `claude-code`\|`antigravity`\|`codex`\|`opencode`. Default: auto-detect |
|
|
42
48
|
| `--all` | install every ready provider binary, not just the detected one |
|
|
43
|
-
| `--
|
|
49
|
+
| `--agent-dir <dir>` | repo folder for the reviewer roster (default: primary agent's — `.claude`/`.agent`/`.codex`/`.opencode`) |
|
|
50
|
+
| `--yes` | non-interactive: reviewers = existing roster if any, else all; convention = `trueid` |
|
|
51
|
+
| `--reset-reviewers` | ignore an existing roster and rebuild it from the repo's default reviewers |
|
|
44
52
|
| `--dry-run` | print planned actions, write nothing |
|
|
45
53
|
| `--help` | usage |
|
|
46
54
|
|
|
@@ -65,16 +73,23 @@ npx @nvice-r/pr-commands --dry-run --dir /path/to/repo
|
|
|
65
73
|
|
|
66
74
|
## What gets installed
|
|
67
75
|
|
|
68
|
-
Everything is **global** — nothing lands in a repo's
|
|
69
|
-
every clone and every git worktree, no symlinks.
|
|
76
|
+
Everything user-global is **global** — nothing lands in a repo's agent dir, so it works
|
|
77
|
+
identically from every clone and every git worktree, no repo-local symlinks.
|
|
70
78
|
|
|
71
79
|
| Artifact | Path |
|
|
72
80
|
|----------|------|
|
|
73
81
|
| Shared library | `~/.local/bin/pr-common.sh` |
|
|
74
82
|
| Provider binary | `~/.local/bin/bbpr` (`ghpr`/`glpr` later) |
|
|
75
83
|
| Config | `~/.config/pr/config` |
|
|
76
|
-
| Reviewer roster | `<main-repo-root>/<agent_dir>/pr-reviewers.txt` (
|
|
77
|
-
|
|
|
84
|
+
| Reviewer roster | `<main-repo-root>/<agent_dir>/pr-reviewers.txt` (primary agent's folder; shared across worktrees) |
|
|
85
|
+
| Canonical skill | `~/.config/pr/skill/pr/SKILL.md` (single source) |
|
|
86
|
+
| Per-agent skill | `<agent-skills-dir>/pr` → symlink to canonical |
|
|
87
|
+
| Claude `/pr` command | `~/.claude/commands/pr.md` (dual-install, keeps the slash trigger) |
|
|
88
|
+
|
|
89
|
+
The skill is a **portable `SKILL.md`** installed into every detected agent — **claude-code,
|
|
90
|
+
antigravity, codex, opencode** — via one canonical copy each agent symlinks to (`npx skills`
|
|
91
|
+
single-source model). Force a target with `--agent <id>`. A pre-existing non-symlink `pr` skill
|
|
92
|
+
dir is never overwritten (the installer skips and warns).
|
|
78
93
|
|
|
79
94
|
## Usage
|
|
80
95
|
|
|
@@ -104,6 +119,30 @@ convention=trueid # see presets
|
|
|
104
119
|
Edit by hand any time. **A missing config is a hard error** — `/pr` and the binaries refuse to
|
|
105
120
|
guess. Re-run the installer to regenerate.
|
|
106
121
|
|
|
122
|
+
### Per-project convention (`.agents/config/pr.yaml`)
|
|
123
|
+
|
|
124
|
+
When the repo has `.agents/config/pr.yaml` (this checkout, or the main checkout for worktrees),
|
|
125
|
+
`/pr` reads the convention from it instead of the user config. One file per project, so teams
|
|
126
|
+
with different conventions share the same skill:
|
|
127
|
+
|
|
128
|
+
```yaml
|
|
129
|
+
title_pattern: "[<ver>] Feature/<ver>/<ticket> <desc>"
|
|
130
|
+
ticket_regex: "SC-(\\d+|NOTICKET)"
|
|
131
|
+
template_sections: [What Happened, Insight, Proof of Work]
|
|
132
|
+
body_rules: |
|
|
133
|
+
- Free-text project rules the skill follows literally.
|
|
134
|
+
base: develop # default base proposal
|
|
135
|
+
draft: ask # ask | true | false
|
|
136
|
+
push: [origin] # remotes pushed before create
|
|
137
|
+
fork: # optional cross-repo mode (`/pr cross`)
|
|
138
|
+
remote: social-commerce
|
|
139
|
+
repo: workspace/fork-repo
|
|
140
|
+
pre_pr: [] # commands run before confirm; non-zero exit stops /pr
|
|
141
|
+
post_pr: [jira-update] # sibling skills offered after create
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Without the file, the convention keys below (written by the wizard) are used.
|
|
145
|
+
|
|
107
146
|
### Convention presets
|
|
108
147
|
|
|
109
148
|
| preset | title pattern | ticket regex | template sections |
|
|
@@ -124,14 +163,18 @@ template_sections=Context|Changes|Risk
|
|
|
124
163
|
|
|
125
164
|
### Reviewers
|
|
126
165
|
|
|
127
|
-
Roster lives
|
|
128
|
-
`.
|
|
129
|
-
|
|
166
|
+
Roster lives in the **primary agent's repo folder** under the main repo root — `.claude`,
|
|
167
|
+
`.agent` (Antigravity), `.codex` or `.opencode`, picked from the first agent you install for
|
|
168
|
+
(override with `--agent-dir`). The choice is saved as `agent_dir` in the config so the bash binary
|
|
169
|
+
reads the same path. Resolved via `git --git-common-dir/..`, so all worktrees of a repo share one
|
|
170
|
+
roster:
|
|
130
171
|
|
|
131
172
|
```
|
|
132
|
-
<main-repo-root
|
|
173
|
+
<main-repo-root>/<agent_dir>/pr-reviewers.txt
|
|
133
174
|
```
|
|
134
175
|
|
|
176
|
+
Switching primary agent = re-run the installer; the old file still loads as a fallback.
|
|
177
|
+
|
|
135
178
|
You (the author) are excluded on install — bbpr also drops self at PR time. One entry per line,
|
|
136
179
|
trailing `# Name` comment optional:
|
|
137
180
|
|
|
@@ -140,10 +183,10 @@ trailing `# Name` comment optional:
|
|
|
140
183
|
{fedcba98-7654-3210-fedc-ba9876543210} # Bob
|
|
141
184
|
```
|
|
142
185
|
|
|
143
|
-
It's plain data — edit freely, no reinstall. Consider gitignoring
|
|
144
|
-
|
|
145
|
-
`.pr-reviewers.txt` (pre-0.1.1) → global `~/.config/pr/reviewers/<ws>-<slug>.txt` →
|
|
146
|
-
Run-time overrides: `--no-reviewers` skips all, `--reviewer <uuid>` appends.
|
|
186
|
+
It's plain data — edit freely, no reinstall. Consider gitignoring the roster unless the team wants
|
|
187
|
+
it committed. Load order: `$BBPR_REVIEWERS` → `<agent_dir>/pr-reviewers.txt` → `.pr/reviewers.txt`
|
|
188
|
+
(0.1.2) → root `.pr-reviewers.txt` (pre-0.1.1) → global `~/.config/pr/reviewers/<ws>-<slug>.txt` →
|
|
189
|
+
legacy. Run-time overrides: `--no-reviewers` skips all, `--reviewer <uuid>` appends.
|
|
147
190
|
|
|
148
191
|
### Auth precedence (email)
|
|
149
192
|
|
|
@@ -185,7 +228,8 @@ Full rationale in [`DECISIONS.md`](./DECISIONS.md).
|
|
|
185
228
|
| reviewers not fetched | add the keychain token (see Prerequisites), re-run |
|
|
186
229
|
| `WARN ~/.local/bin not on PATH` | add the export line above to your shell rc |
|
|
187
230
|
| `not-yet-supported: <provider>` | GitHub/GitLab aren't implemented yet |
|
|
188
|
-
| worktree runs old behavior |
|
|
231
|
+
| worktree runs old behavior | skill/binaries are global, not repo-local; ensure no stale per-repo copy shadows them |
|
|
232
|
+
| agent doesn't see the skill | re-run and force it: `npx @nvice-r/pr-commands --agent <id>`; check `<agent-skills-dir>/pr` symlink exists |
|
|
189
233
|
|
|
190
234
|
## Uninstall
|
|
191
235
|
|
|
@@ -193,16 +237,18 @@ Full rationale in [`DECISIONS.md`](./DECISIONS.md).
|
|
|
193
237
|
rm ~/.local/bin/bbpr ~/.local/bin/pr-common.sh
|
|
194
238
|
rm -rf ~/.config/pr
|
|
195
239
|
rm ~/.claude/commands/pr.md
|
|
240
|
+
# per-agent skill symlinks (remove those you installed):
|
|
241
|
+
rm ~/.claude/skills/pr ~/.gemini/antigravity/skills/pr ~/.codex/skills/pr ~/.config/opencode/skills/pr
|
|
196
242
|
# roster lives in each repo — remove per repo if wanted:
|
|
197
|
-
rm <main-repo-root>/.
|
|
243
|
+
rm -rf <main-repo-root>/.pr
|
|
198
244
|
```
|
|
199
245
|
|
|
200
246
|
## Development
|
|
201
247
|
|
|
202
248
|
```
|
|
203
|
-
install.js
|
|
204
|
-
assets/bin/*
|
|
205
|
-
assets/skill/pr.md
|
|
249
|
+
install.js npx entry (wizard)
|
|
250
|
+
assets/bin/* provider binaries + shared lib (source of truth; copied on install)
|
|
251
|
+
assets/skill/pr.body.md shared /pr skill body (frontmatter added per-target on install)
|
|
206
252
|
```
|
|
207
253
|
|
|
208
254
|
`assets/` is the single source of truth — both the installer (`install.js`)
|
package/assets/bin/bbpr
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# bbpr-version:
|
|
2
|
+
# bbpr-version: 9
|
|
3
3
|
# bbpr — create a Bitbucket Cloud pull request (REST API v2.0).
|
|
4
4
|
# Provider-specific layer; all git/config/creds/reviewer logic lives in pr-common.sh.
|
|
5
5
|
# Usage: bbpr --title "..." [--body|--body-file] [--source b] [--dest b] [--repo w/s]
|
|
6
6
|
# [--draft] [--reviewer uuid]... [--no-reviewers] [--email a] [--close-source]
|
|
7
|
+
# [--source-repo ws/fork] (cross-repo: PR opens in --repo/origin, from the fork)
|
|
7
8
|
|
|
8
9
|
set -euo pipefail
|
|
9
10
|
|
|
@@ -35,7 +36,7 @@ fi
|
|
|
35
36
|
|
|
36
37
|
# --- Bitbucket payload (source/destination keys, draft, reviewers[]) ---
|
|
37
38
|
PAYLOAD="$(
|
|
38
|
-
TITLE="$TITLE" BODY="$BODY" SOURCE="$SOURCE" DEST="$DEST" \
|
|
39
|
+
TITLE="$TITLE" BODY="$BODY" SOURCE="$SOURCE" DEST="$DEST" SOURCE_REPO="$SOURCE_REPO" \
|
|
39
40
|
DRAFT="$DRAFT" CLOSE_SOURCE="$CLOSE_SOURCE" \
|
|
40
41
|
REVIEWERS_JSON="$(printf '%s\n' "${FINAL_REVIEWERS[@]:-}" | python3 -c 'import json,sys; print(json.dumps([{"uuid": l.strip()} for l in sys.stdin if l.strip()]))')" \
|
|
41
42
|
python3 -c '
|
|
@@ -48,6 +49,8 @@ p = {
|
|
|
48
49
|
"draft": os.environ["DRAFT"] == "true",
|
|
49
50
|
"close_source_branch": os.environ["CLOSE_SOURCE"] == "true",
|
|
50
51
|
}
|
|
52
|
+
if os.environ["SOURCE_REPO"]:
|
|
53
|
+
p["source"]["repository"] = {"full_name": os.environ["SOURCE_REPO"]}
|
|
51
54
|
revs = json.loads(os.environ["REVIEWERS_JSON"])
|
|
52
55
|
if revs: p["reviewers"] = revs
|
|
53
56
|
print(json.dumps(p))
|
|
@@ -56,7 +59,7 @@ print(json.dumps(p))
|
|
|
56
59
|
|
|
57
60
|
URL="https://api.bitbucket.org/2.0/repositories/${REPO}/pullrequests"
|
|
58
61
|
echo "POST $URL"
|
|
59
|
-
echo "source=$SOURCE dest=$DEST draft=$DRAFT reviewers=${#FINAL_REVIEWERS[@]}"
|
|
62
|
+
echo "source=${SOURCE_REPO:+$SOURCE_REPO:}$SOURCE dest=$DEST draft=$DRAFT reviewers=${#FINAL_REVIEWERS[@]}"
|
|
60
63
|
|
|
61
64
|
RESP="$(curl -sS -X POST "$URL" -u "${EMAIL}:${TOKEN}" \
|
|
62
65
|
-H 'Content-Type: application/json' -d "$PAYLOAD")"
|
|
@@ -67,7 +70,7 @@ try: d = json.loads(sys.stdin.read())
|
|
|
67
70
|
except Exception as e: print("non-JSON response", e); sys.exit(2)
|
|
68
71
|
if "error" in d: print("ERROR:", json.dumps(d["error"], indent=2)); sys.exit(1)
|
|
69
72
|
print(str(d.get("id")) + "\t" + str(d.get("links", {}).get("html", {}).get("href")))
|
|
70
|
-
')"
|
|
73
|
+
')" || { echo "$PR_INFO" >&2; exit 1; } # surface the API error; set -e would swallow it
|
|
71
74
|
|
|
72
75
|
NEW_PR_ID="$(echo "$PR_INFO" | cut -f1)"
|
|
73
76
|
NEW_PR_URL="$(echo "$PR_INFO" | cut -f2)"
|
package/assets/bin/pr-common.sh
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# pr-common-version:
|
|
2
|
+
# pr-common-version: 2
|
|
3
3
|
# pr-common.sh — shared library for provider PR binaries (bbpr/ghpr/glpr).
|
|
4
4
|
# NOT executable on its own — sourced by a provider binary, which then builds the
|
|
5
5
|
# provider-specific payload + POST. Everything here is provider-AGNOSTIC.
|
|
6
6
|
#
|
|
7
7
|
# Provides (after `pr_common_init "$@"`): global vars
|
|
8
|
-
# TITLE BODY SOURCE DEST REPO EMAIL TOKEN DRAFT CLOSE_SOURCE
|
|
8
|
+
# TITLE BODY SOURCE DEST REPO SOURCE_REPO EMAIL TOKEN DRAFT CLOSE_SOURCE
|
|
9
9
|
# FINAL_REVIEWERS[] (uuids, self already NOT dropped — provider does that)
|
|
10
10
|
# Provides functions:
|
|
11
11
|
# die, get_token, pr_load_config, pr_parse_args, pr_git_defaults,
|
|
@@ -38,7 +38,7 @@ pr_load_config() {
|
|
|
38
38
|
esac
|
|
39
39
|
done < "$CONFIG_FILE"
|
|
40
40
|
: "${CFG_TOKEN_SRC:=keychain}"
|
|
41
|
-
: "${CFG_AGENT_DIR:=.claude}" # agent folder holding the roster (relative to main repo root)
|
|
41
|
+
: "${CFG_AGENT_DIR:=.claude}" # primary agent's repo folder holding the roster (relative to main repo root)
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
# --- token retrieval, OS-switched (D1: OS is a function, not a binary axis) ---
|
|
@@ -61,10 +61,14 @@ get_token() {
|
|
|
61
61
|
|
|
62
62
|
# --- arg parse (common flags across all providers) ---
|
|
63
63
|
pr_parse_args() {
|
|
64
|
-
TITLE=""; BODY=""; BODY_FILE=""; SOURCE=""; DEST=""; REPO=""
|
|
64
|
+
TITLE=""; BODY=""; BODY_FILE=""; SOURCE=""; DEST=""; REPO=""; SOURCE_REPO=""
|
|
65
65
|
DRAFT="false"; CLOSE_SOURCE="false"; NO_REVIEWERS="false"; EMAIL_ARG=""
|
|
66
66
|
EXTRA_REVIEWERS=()
|
|
67
67
|
while [ $# -gt 0 ]; do
|
|
68
|
+
case "$1" in
|
|
69
|
+
--title|--body|--body-file|--source|--dest|--target|--repo|--source-repo|--reviewer|--email)
|
|
70
|
+
[ $# -ge 2 ] || die "$1 needs a value" ;;
|
|
71
|
+
esac
|
|
68
72
|
case "$1" in
|
|
69
73
|
--title) TITLE="$2"; shift 2 ;;
|
|
70
74
|
--body) BODY="$2"; shift 2 ;;
|
|
@@ -72,6 +76,7 @@ pr_parse_args() {
|
|
|
72
76
|
--source) SOURCE="$2"; shift 2 ;;
|
|
73
77
|
--dest|--target) DEST="$2"; shift 2 ;;
|
|
74
78
|
--repo) REPO="$2"; shift 2 ;;
|
|
79
|
+
--source-repo) SOURCE_REPO="$2"; shift 2 ;; # fork → cross-repo PR
|
|
75
80
|
--draft) DRAFT="true"; shift ;;
|
|
76
81
|
--close-source) CLOSE_SOURCE="true"; shift ;;
|
|
77
82
|
--reviewer) EXTRA_REVIEWERS+=("$2"); shift 2 ;;
|
|
@@ -130,9 +135,10 @@ pr_load_reviewers() {
|
|
|
130
135
|
local mainroot; mainroot="$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." 2>/dev/null && pwd)"
|
|
131
136
|
for cand in \
|
|
132
137
|
${mainroot:+"$mainroot/${CFG_AGENT_DIR:-.claude}/pr-reviewers.txt"} \
|
|
138
|
+
${mainroot:+"$mainroot/.pr/reviewers.txt"} \
|
|
133
139
|
${mainroot:+"$mainroot/.pr-reviewers.txt"} \
|
|
134
140
|
"$HOME/.config/pr/reviewers/${slug}.txt" \
|
|
135
|
-
"$HOME/.config/bbpr/bbpr-reviewers.txt"; do #
|
|
141
|
+
"$HOME/.config/bbpr/bbpr-reviewers.txt"; do # agent-dir primary; 0.1.2 .pr/ + root + global legacy fallbacks
|
|
136
142
|
[ -f "$cand" ] && { file="$cand"; break; }
|
|
137
143
|
done
|
|
138
144
|
fi
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
Create a pull request for the current branch. Provider (Bitbucket / GitHub / GitLab) is
|
|
2
|
+
auto-detected from the git remote; the matching binary (`bbpr` / `ghpr` / `glpr`) is invoked.
|
|
3
|
+
Convention (title pattern, ticket regex, PR template, body rules, push remotes, hooks) comes
|
|
4
|
+
from the repo's `.agents/config/pr.yaml` (one file per project, usually copied there by
|
|
5
|
+
`/landing`). Without that file, the convention keys in `~/.config/pr/config` are used.
|
|
6
|
+
|
|
7
|
+
Installed by `npx @nvice-r/pr-commands`. If setup is missing, this command STOPS and tells the
|
|
8
|
+
user to run the installer — it never guesses config (D8: hard fail, no hardcoded fallback).
|
|
9
|
+
|
|
10
|
+
If `$ARGUMENTS` starts with `help`/`-help`/`--help` (case-insensitive), print the **Help** block and stop.
|
|
11
|
+
|
|
12
|
+
If `$ARGUMENTS` starts with `update` (case-insensitive), run **Update mode** (below) and stop —
|
|
13
|
+
skip the create steps entirely.
|
|
14
|
+
|
|
15
|
+
## Help (`/pr help`)
|
|
16
|
+
|
|
17
|
+
```
|
|
18
|
+
/pr — provider-agnostic PR helper (@nvice-r/pr-commands)
|
|
19
|
+
|
|
20
|
+
USAGE
|
|
21
|
+
/pr [base-branch] [draft] [cross]
|
|
22
|
+
Create a PR for the current branch (default).
|
|
23
|
+
/pr update [PR#] [short] Rewrite an existing PR's description in place.
|
|
24
|
+
/pr help Show this help.
|
|
25
|
+
|
|
26
|
+
CREATE
|
|
27
|
+
base-branch Any arg containing "/". If omitted, inferred from closest ancestor
|
|
28
|
+
remote branch (saved per-branch after first run).
|
|
29
|
+
draft Create as draft. Otherwise follows `draft:` in pr.yaml (default: ask).
|
|
30
|
+
cross Fork mode: open the PR in origin from the fork in pr.yaml `fork:`.
|
|
31
|
+
|
|
32
|
+
UPDATE
|
|
33
|
+
PR# PR number to rewrite. If omitted, resolved from the current branch's
|
|
34
|
+
open PR. Re-drafts the body per convention, then PATCHes it in place.
|
|
35
|
+
short Compress harder (caveman brevity): cut throat-clearing, restated ticket
|
|
36
|
+
goals, filler connectors. Keeps every file + technical fact.
|
|
37
|
+
|
|
38
|
+
CONVENTION <repo>/.agents/config/pr.yaml (per project; else convention keys in the user config)
|
|
39
|
+
CONFIG ~/.config/pr/config (os, token_src, email, agent_dir; legacy convention keys)
|
|
40
|
+
PROVIDER auto from `git remote get-url origin` -> bbpr | ghpr | glpr
|
|
41
|
+
REVIEWERS <main-repo-root>/<agent_dir>/pr-reviewers.txt (per-repo, shared across worktrees; agent_dir from config)
|
|
42
|
+
SETUP npx @nvice-r/pr-commands
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## Step 0 — verify install (HARD FAIL, no fallback)
|
|
46
|
+
|
|
47
|
+
Run the checks; if any fails, STOP and tell the user to run `npx @nvice-r/pr-commands` (name the
|
|
48
|
+
missing piece). Do NOT improvise config or reviewers.
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
# a. config present?
|
|
52
|
+
CFG="${PR_CONFIG:-$HOME/.config/pr/config}"
|
|
53
|
+
[ -f "$CFG" ] || { echo "MISSING config: $CFG"; }
|
|
54
|
+
|
|
55
|
+
# b. detect provider from remote
|
|
56
|
+
URL="$(git remote get-url origin 2>/dev/null)" || echo "no origin remote"
|
|
57
|
+
case "$URL" in
|
|
58
|
+
*bitbucket.org*) PROVIDER=bitbucket; BIN=bbpr ;;
|
|
59
|
+
*github.com*) PROVIDER=github; BIN=ghpr ;;
|
|
60
|
+
*gitlab.com*) PROVIDER=gitlab; BIN=glpr ;;
|
|
61
|
+
*) echo "unknown provider for remote: $URL" ;;
|
|
62
|
+
esac
|
|
63
|
+
|
|
64
|
+
# c. matching binary installed?
|
|
65
|
+
command -v "$BIN" >/dev/null 2>&1 || echo "MISSING binary: $BIN (run installer, or provider not yet supported)"
|
|
66
|
+
|
|
67
|
+
# d. project convention file: this checkout first, then the main checkout (worktrees share it,
|
|
68
|
+
# because .agents/config/ is usually gitignored and only exists in the main checkout)
|
|
69
|
+
TOP="$(git rev-parse --show-toplevel)"
|
|
70
|
+
MAINROOT="$(cd "$(git rev-parse --git-common-dir)/.." && pwd)"
|
|
71
|
+
PR_YAML=""
|
|
72
|
+
for f in "$TOP/.agents/config/pr.yaml" "$MAINROOT/.agents/config/pr.yaml"; do
|
|
73
|
+
[ -f "$f" ] && { PR_YAML="$f"; break; }
|
|
74
|
+
done
|
|
75
|
+
echo "convention: ${PR_YAML:-$CFG (no .agents/config/pr.yaml)}"
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
- config missing → STOP: "run `npx @nvice-r/pr-commands`".
|
|
79
|
+
- provider unknown → STOP: only bitbucket/github/gitlab supported.
|
|
80
|
+
- `$BIN` = `ghpr`/`glpr` and not installed → STOP: "provider not yet supported (macOS+Bitbucket only for now)". (D6 stub.)
|
|
81
|
+
- all pass → continue.
|
|
82
|
+
|
|
83
|
+
## Step 1 — gather context (parallel)
|
|
84
|
+
- `git rev-parse --abbrev-ref HEAD` — source branch (CURR)
|
|
85
|
+
- `git config --get branch.<CURR>.bbprBase` — saved base, if any
|
|
86
|
+
- `git log --oneline -20`
|
|
87
|
+
- read the **convention source**: `$PR_YAML` if Step 0 found one, else the convention keys in `$CFG`.
|
|
88
|
+
Every later step that says "convention" means this source.
|
|
89
|
+
|
|
90
|
+
**Convention keys** (YAML in `pr.yaml`; `key=value` in `$CFG`, where list values are pipe-separated):
|
|
91
|
+
|
|
92
|
+
| key | meaning | if missing |
|
|
93
|
+
|-----|---------|------------|
|
|
94
|
+
| `title_pattern` | title template with `<ver> <desc> <ticket> <type> <scope> <issue>` | ask the user |
|
|
95
|
+
| `ticket_regex` | ticket key in the branch name | no ticket |
|
|
96
|
+
| `template_sections` | body `## ` headings, in order | `Summary`, `Changes` |
|
|
97
|
+
| `body_rules` | extra project rules for the body (free text, follow literally) | none |
|
|
98
|
+
| `base` | default base proposal (Step 2 d) | ask |
|
|
99
|
+
| `draft` | `ask` / `true` / `false` | `ask` |
|
|
100
|
+
| `push` | remotes to push before create (Step 7.5) | none (assume pushed) |
|
|
101
|
+
| `fork` | `{remote, repo}` for cross-repo mode | cross mode unavailable |
|
|
102
|
+
| `pre_pr` | shell commands run before confirm (Step 6.5) | none |
|
|
103
|
+
| `post_pr` | sibling skills offered after create (Step 10) | none |
|
|
104
|
+
|
|
105
|
+
## Step 2 — determine base branch (priority)
|
|
106
|
+
a. `$1` (any arg containing `/`) → use it.
|
|
107
|
+
b. else `branch.<CURR>.bbprBase` from git config → use silently.
|
|
108
|
+
c. else infer — smallest non-zero `<ref>..HEAD` distance where `<ref>` is an ancestor of HEAD:
|
|
109
|
+
```bash
|
|
110
|
+
for ref in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin | grep -v "^origin/<CURR>$"); do
|
|
111
|
+
if git merge-base --is-ancestor "$ref" HEAD 2>/dev/null; then
|
|
112
|
+
ahead=$(git rev-list --count "$ref"..HEAD)
|
|
113
|
+
[ "$ahead" -gt 0 ] && echo "$ahead $ref"
|
|
114
|
+
fi
|
|
115
|
+
done | sort -n | head -5
|
|
116
|
+
```
|
|
117
|
+
Propose smallest-`ahead` candidate; confirm. Do NOT auto-pick a naming-pattern match — a
|
|
118
|
+
stacked PR on another in-flight ticket branch is common. Ties or all >20 ahead → ask.
|
|
119
|
+
d. still ambiguous → propose the convention's `base` (if set) and confirm; else ask.
|
|
120
|
+
|
|
121
|
+
The binary auto-saves the resolved base to `branch.<CURR>.bbprBase` on success.
|
|
122
|
+
|
|
123
|
+
## Step 3 — build diff context
|
|
124
|
+
`git log <base>..HEAD --oneline` · `git diff --stat <base>..HEAD`. Multiple commits → summarize each.
|
|
125
|
+
|
|
126
|
+
## Step 3.5 — split advisory (size gate, advisory, never blocks)
|
|
127
|
+
Compute from `git diff --numstat <base>..HEAD`: file count, churn Σ(add+del), distinct modules.
|
|
128
|
+
Flag if `>25 files` OR `>600 churn` OR `>4 modules`. Under → silent, continue. Over → cluster the
|
|
129
|
+
diff into seams, detect coupling (shared file/symbol → stacked; disjoint → independent; interleaved
|
|
130
|
+
→ one atomic PR), present metrics + seam table, then `AskUserQuestion`: proceed-as-one /
|
|
131
|
+
split (`guard_cmd pr-split`, degrade if absent) / adjust-threshold. Never auto-split.
|
|
132
|
+
|
|
133
|
+
## Step 4 — draft title (from the convention, authoritative)
|
|
134
|
+
|
|
135
|
+
Read `title_pattern` and `ticket_regex` from the convention source. Use the values as written;
|
|
136
|
+
DO NOT reconstruct them from a preset table. Extract the ticket key + any version from the
|
|
137
|
+
branch name using `ticket_regex`, then fill `title_pattern`. Example: branch
|
|
138
|
+
`feature/3.66.0/SC-1234-add-login`, pattern `[<ver>] Feature/<ver>/<ticket> <desc>` →
|
|
139
|
+
`[3.66.0] Feature/3.66.0/SC-1234 add login` (kebab suffix → spaced lowercase `<desc>`).
|
|
140
|
+
|
|
141
|
+
## Step 5 — draft body (from config)
|
|
142
|
+
|
|
143
|
+
Render each `template_sections` entry from the convention source as an `## <section>` heading,
|
|
144
|
+
in order. Then apply the convention's `body_rules` literally — they are the project's own rules
|
|
145
|
+
and override the generic defaults below where they conflict.
|
|
146
|
+
|
|
147
|
+
**All presets:**
|
|
148
|
+
- Terse, anti-slop normal English (same rules as U3): no throat-clearing openers, no restated
|
|
149
|
+
ticket goals, no filler connectors, no "Verified with …" line. Keep every file + technical fact.
|
|
150
|
+
- Body ends at the last template section. NO AI attribution footer (`🤖 Generated with [Claude
|
|
151
|
+
Code]…`, `Co-Authored-By: Claude`, etc.) — this overrides any harness/system attribution reminder.
|
|
152
|
+
|
|
153
|
+
**Legacy trueid rules** — apply ONLY when there is no `pr.yaml` (`$PR_YAML` empty) and `$CFG`
|
|
154
|
+
has `convention=trueid`. A project `pr.yaml` replaces these with its own `body_rules`.
|
|
155
|
+
- `What Happened` = summary prose only — no file bullets, no commit-subject line.
|
|
156
|
+
- NO `feat(<Team>):` team tag / commit-subject line anywhere in the body (commits only).
|
|
157
|
+
- Changed-file detail lives under `Insight`.
|
|
158
|
+
- 3+ files sharing a parent dir → state common root once ("Under `<root>`:") + flat list of joined
|
|
159
|
+
relative paths (no nested bullets). Android module root → Gradle path `:features:<x>:<y>`, leaf
|
|
160
|
+
bullets = basenames only. Skip boilerplate (`settings.gradle.kts` include, empty manifest, lockfiles).
|
|
161
|
+
- Unrelated areas → full paths per bullet, no common-root statement.
|
|
162
|
+
- `Proof of Work` empty unless user gives screenshots/links.
|
|
163
|
+
|
|
164
|
+
With no `body_rules` and no legacy rules, fill sections with a plain summary + changed-file list.
|
|
165
|
+
|
|
166
|
+
## Step 6 — write body to `/tmp/pr-body.md` (Write tool).
|
|
167
|
+
|
|
168
|
+
## Step 6.5 — pre-PR hooks
|
|
169
|
+
Run each `pre_pr` command from the convention, in order, from the repo root. Any non-zero exit →
|
|
170
|
+
STOP, show its output, create nothing. No `pre_pr` → skip silently.
|
|
171
|
+
|
|
172
|
+
## Step 7 — confirm. Show proposed title + base + body + resolved provider/binary + mode
|
|
173
|
+
(direct/cross) + remotes to push. WAIT. PR creation is user-visible; never silent.
|
|
174
|
+
|
|
175
|
+
## Step 7.5 — mode + push
|
|
176
|
+
- **Mode.** `cross` arg → cross. Else saved `branch.<CURR>.prMode` → use it. Else, when the
|
|
177
|
+
convention has `fork`: branch exists only on `fork.remote` (`git ls-remote <remote> <CURR>`)
|
|
178
|
+
→ cross; otherwise direct. No `fork` → always direct; `cross` arg → STOP ("no fork in pr.yaml").
|
|
179
|
+
- **Direct:** push every remote in `push` (`git push <remote> HEAD`), in order.
|
|
180
|
+
- **Cross:** push `fork.remote` only. Never push to `origin`.
|
|
181
|
+
- Push failure → STOP and show the error. Never pass `--no-verify` (pre-push hooks may lint;
|
|
182
|
+
allow 30–60 s). No `push` key → do not push; warn if HEAD is ahead of its upstream.
|
|
183
|
+
|
|
184
|
+
## Step 8 — create. Run the detected binary (append `--draft` only if draft chosen):
|
|
185
|
+
```bash
|
|
186
|
+
<BIN> --title "<title>" --body-file /tmp/pr-body.md --dest "<base>" [--draft] \
|
|
187
|
+
[--source-repo "<fork.repo>"] # cross mode only
|
|
188
|
+
```
|
|
189
|
+
Draft: `draft` arg → draft. Else convention `draft: true|false` → use it. Else (`ask` or unset)
|
|
190
|
+
ask open-vs-draft before creating. After a cross-mode success, run
|
|
191
|
+
`git config branch.<CURR>.prMode cross` so the next `/pr` reuses the mode. The binary attaches the
|
|
192
|
+
per-repo reviewer roster automatically; `--no-reviewers` skips, `--reviewer <id>` appends.
|
|
193
|
+
|
|
194
|
+
## Step 9 — print final PR URL + reviewer count from binary output. On error, surface raw response.
|
|
195
|
+
|
|
196
|
+
## Step 10 — post-PR skills (guarded, optional).
|
|
197
|
+
For each skill in the convention's `post_pr` (legacy config without `pr.yaml`: `jira-update`):
|
|
198
|
+
`guard_cmd <skill>`; absent → one-line note, skip. Present → `AskUserQuestion`: run now
|
|
199
|
+
(`/<skill> <ticket>` with the Step 4 ticket key) or skip. No ticket key → skip ticket-based skills.
|
|
200
|
+
|
|
201
|
+
## Update mode (`/pr update [PR#] [short]`)
|
|
202
|
+
|
|
203
|
+
Rewrites an existing open PR's **description** in place — no new branch, no new PR. Same Step 0
|
|
204
|
+
install check runs first (config + provider + creds). Provider PATCH is currently Bitbucket-only,
|
|
205
|
+
matching the create path; `ghpr`/`glpr` providers → STOP with the same "not yet supported" note.
|
|
206
|
+
|
|
207
|
+
**U1 — parse args.** Tokens after `update`: a bare integer = `PR#`; the word `short` (or
|
|
208
|
+
`caveman`) = compress-hard flag. Missing `PR#` → resolve from the current branch: query the
|
|
209
|
+
provider for the open PR whose source branch is `HEAD` (Bitbucket: `GET /pullrequests?q=source.branch.name="<CURR>"&state=OPEN`). Zero or multiple matches → ask.
|
|
210
|
+
|
|
211
|
+
**U2 — fetch current PR.** Resolve creds the same way the binary does — email from `$CFG`
|
|
212
|
+
(`email=`), token from the OS keychain (macOS service `bitbucket-api-token`, account = email).
|
|
213
|
+
`REPO` = workspace/slug from `git remote get-url origin`. Fetch title + description:
|
|
214
|
+
```bash
|
|
215
|
+
curl -sS -u "$EMAIL:$TOKEN" \
|
|
216
|
+
"https://api.bitbucket.org/2.0/repositories/$REPO/pullrequests/$PRNUM"
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
**U3 — re-draft the body.** Rebuild the description with the **Step 5** rules (trueid template
|
|
220
|
+
when `convention=trueid`): `What Happened` prose, `Insight` file detail, `Proof of Work`. Prefer
|
|
221
|
+
the diff/commits as the source of truth over the old text; carry forward Proof-of-Work links the
|
|
222
|
+
old body already had. Default output = terse, anti-slop normal English:
|
|
223
|
+
- Cut throat-clearing openers ("The X feature needs to…", "This adds…"), restated ticket goals,
|
|
224
|
+
and filler connectors ("as a consequence", "in order to").
|
|
225
|
+
- Keep every changed file, every behavioural fact, and any bug-fix detail — brevity, not omission.
|
|
226
|
+
|
|
227
|
+
With the `short` flag, compress harder toward caveman brevity (fragments, dropped articles) while
|
|
228
|
+
keeping the section headings and every technical fact. Title stays unchanged unless the user asks.
|
|
229
|
+
|
|
230
|
+
**U4 — confirm (diff-style).** Write the new body to `/tmp/pr-body.md`. Show a short before/after
|
|
231
|
+
summary (what was cut) + the full new body. WAIT — editing a live PR is user-visible; never silent.
|
|
232
|
+
|
|
233
|
+
**U5 — PATCH in place** (Bitbucket `PUT`, description only):
|
|
234
|
+
```bash
|
|
235
|
+
python3 - "$REPO" "$PRNUM" "$EMAIL" "$TOKEN" /tmp/pr-body.md <<'PY'
|
|
236
|
+
import sys, json, base64, urllib.request, urllib.error
|
|
237
|
+
repo, num, email, token, bodyfile = sys.argv[1:6]
|
|
238
|
+
desc = open(bodyfile).read()
|
|
239
|
+
data = json.dumps({"description": desc}).encode()
|
|
240
|
+
url = f"https://api.bitbucket.org/2.0/repositories/{repo}/pullrequests/{num}"
|
|
241
|
+
req = urllib.request.Request(url, data=data, method="PUT", headers={"Content-Type": "application/json"})
|
|
242
|
+
req.add_header("Authorization", "Basic " + base64.b64encode(f"{email}:{token}".encode()).decode())
|
|
243
|
+
try:
|
|
244
|
+
r = urllib.request.urlopen(req)
|
|
245
|
+
print(r.status, json.loads(r.read())["links"]["html"]["href"])
|
|
246
|
+
except urllib.error.HTTPError as e:
|
|
247
|
+
print("ERR", e.code, e.read().decode()[:500])
|
|
248
|
+
PY
|
|
249
|
+
```
|
|
250
|
+
On non-2xx, surface the raw response body. Reviewers/base/title are untouched — description only.
|
|
251
|
+
|
|
252
|
+
**U6 — print PR URL.** No Jira sync offer on update (status unchanged).
|
|
253
|
+
|
|
254
|
+
## Cross-command guard
|
|
255
|
+
Sibling commands (`/pr-split`, `/jira-update`) are optional — a teammate may not have them, and
|
|
256
|
+
they may live under any agent's skills/commands dir. Before invoking any, confirm it exists; if
|
|
257
|
+
absent, degrade gracefully (one-line note). Reusable check (scans Claude command dir + every known
|
|
258
|
+
agent skills dir):
|
|
259
|
+
```bash
|
|
260
|
+
guard_cmd() { # usage: guard_cmd pr-split → prints path or "no-<cmd>"
|
|
261
|
+
local c="$1"
|
|
262
|
+
ls "$PWD/.claude/commands/$c.md" "$HOME/.claude/commands/$c.md" \
|
|
263
|
+
"${CLAUDE_CONFIG_DIR:-$HOME/.claude}/skills/$c/SKILL.md" \
|
|
264
|
+
"$HOME/.gemini/antigravity/skills/$c/SKILL.md" \
|
|
265
|
+
"${CODEX_HOME:-$HOME/.codex}/skills/$c/SKILL.md" \
|
|
266
|
+
"${XDG_CONFIG_HOME:-$HOME/.config}/opencode/skills/$c/SKILL.md" \
|
|
267
|
+
"$PWD/.claude/skills/$c/SKILL.md" "$PWD/.agents/skills/$c/SKILL.md" 2>/dev/null | head -1 \
|
|
268
|
+
|| echo "no-$c"
|
|
269
|
+
}
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
## Notes
|
|
273
|
+
- Does NOT push — assumes branch pushed. Unpushed commits ahead of upstream → warn + ask.
|
|
274
|
+
- Provider/token/convention all from config + remote — NO hardcoded team values here (D3/D4).
|
|
275
|
+
- Verify worktree before any split branch work (`git symbolic-ref --short HEAD` + `git worktree list`);
|
|
276
|
+
background restacks can move branches mid-turn.
|
|
277
|
+
- Caveman mode applies to replies — PR title/body default to normal English. Exception: `/pr update
|
|
278
|
+
… short` (a.k.a. `caveman`) is an explicit user opt-in to compress the body toward caveman brevity.
|
package/install.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// @nvice-r/pr-commands installer. Zero external deps (Node >=16 builtins only).
|
|
3
3
|
// Wizard: detect OS + provider, pick reviewers + convention, write config/roster,
|
|
4
|
-
// install binaries -> ~/.local/bin,
|
|
4
|
+
// install binaries -> ~/.local/bin, install the portable `pr` SKILL.md into every
|
|
5
|
+
// detected agent (symlinked to one canonical copy) + Claude /pr slash-command.
|
|
5
6
|
//
|
|
6
|
-
// Flags: --dir <path> --provider <p> --all --yes --dry-run --help
|
|
7
|
+
// Flags: --dir <path> --provider <p> --agent <id> --all --yes --dry-run --help
|
|
7
8
|
|
|
8
9
|
'use strict';
|
|
9
10
|
const fs = require('fs');
|
|
@@ -15,6 +16,7 @@ const { execFileSync } = require('child_process');
|
|
|
15
16
|
|
|
16
17
|
const HOME = os.homedir();
|
|
17
18
|
const PKG = __dirname;
|
|
19
|
+
const PKG_VERSION = require('./package.json').version;
|
|
18
20
|
const ASSETS = path.join(PKG, 'assets');
|
|
19
21
|
const BACK = Symbol('back'); // returned by a prompt when the user wants the previous step
|
|
20
22
|
|
|
@@ -35,19 +37,53 @@ const PRESET_DEFS = {
|
|
|
35
37
|
|
|
36
38
|
// ---- arg parse ----
|
|
37
39
|
const argv = process.argv.slice(2);
|
|
38
|
-
const flags = { dir: null, provider: null, all: false, yes: false, dryRun: false, agentDir: null };
|
|
40
|
+
const flags = { dir: null, provider: null, all: false, yes: false, dryRun: false, agentDir: null, agents: null, resetReviewers: false };
|
|
39
41
|
for (let i = 0; i < argv.length; i++) {
|
|
40
42
|
const a = argv[i];
|
|
41
43
|
if (a === '--help' || a === '-h') { printHelp(); process.exit(0); }
|
|
42
44
|
else if (a === '--dir') flags.dir = argv[++i];
|
|
43
45
|
else if (a === '--provider') flags.provider = argv[++i];
|
|
44
46
|
else if (a === '--agent-dir') flags.agentDir = argv[++i];
|
|
47
|
+
else if (a === '--agent') (flags.agents ||= []).push(argv[++i]);
|
|
45
48
|
else if (a === '--all') flags.all = true;
|
|
46
49
|
else if (a === '--yes' || a === '-y') flags.yes = true;
|
|
47
50
|
else if (a === '--dry-run') flags.dryRun = true;
|
|
51
|
+
else if (a === '--reset-reviewers') flags.resetReviewers = true;
|
|
48
52
|
else { console.error(`unknown flag: ${a}`); process.exit(1); }
|
|
49
53
|
}
|
|
50
|
-
const
|
|
54
|
+
const cleanDir = (d) => d.replace(/^\/+|\/+$/g, ''); // strip stray slashes
|
|
55
|
+
|
|
56
|
+
// ---- portable skill ----
|
|
57
|
+
// One canonical SKILL.md (~/.config/pr/skill/pr/); every detected agent's global skills
|
|
58
|
+
// dir gets a symlink `pr` -> that dir (skills-CLI single-source model). Claude additionally
|
|
59
|
+
// gets a /pr slash-command. Frontmatter differs per target; the body is shared (pr.body.md).
|
|
60
|
+
const SKILL_FM = `---
|
|
61
|
+
name: pr
|
|
62
|
+
description: Create a pull request for the current branch, with the title and body drafted in the team's convention and the provider (Bitbucket/GitHub/GitLab) auto-detected from the git remote. Use when the user asks to open, create, raise, or draft a pull request / PR / merge request for the current branch, or types /pr.
|
|
63
|
+
---
|
|
64
|
+
|
|
65
|
+
`;
|
|
66
|
+
const CMD_FM = `---
|
|
67
|
+
description: Create a pull request for the current branch (provider auto-detected)
|
|
68
|
+
argument-hint: [base-branch] [draft]
|
|
69
|
+
model: sonnet
|
|
70
|
+
---
|
|
71
|
+
|
|
72
|
+
`;
|
|
73
|
+
// base = probe dir deciding "installed"; skills = GLOBAL skills dir we link into;
|
|
74
|
+
// repoDir = the agent's per-repo folder (holds the reviewer roster when it's the primary agent).
|
|
75
|
+
// Env vars resolved the same way the agents themselves resolve them.
|
|
76
|
+
function agentTargets() {
|
|
77
|
+
const claudeBase = process.env.CLAUDE_CONFIG_DIR || path.join(HOME, '.claude');
|
|
78
|
+
const codexBase = process.env.CODEX_HOME || path.join(HOME, '.codex');
|
|
79
|
+
const xdg = process.env.XDG_CONFIG_HOME || path.join(HOME, '.config');
|
|
80
|
+
return [
|
|
81
|
+
{ id: 'claude-code', base: claudeBase, skills: path.join(claudeBase, 'skills'), repoDir: '.claude', claude: true },
|
|
82
|
+
{ id: 'antigravity', base: path.join(HOME, '.gemini/antigravity'), skills: path.join(HOME, '.gemini/antigravity/skills'), repoDir: '.agent' },
|
|
83
|
+
{ id: 'codex', base: codexBase, skills: path.join(codexBase, 'skills'), repoDir: '.codex' },
|
|
84
|
+
{ id: 'opencode', base: path.join(xdg, 'opencode'), skills: path.join(xdg, 'opencode/skills'), repoDir: '.opencode' },
|
|
85
|
+
];
|
|
86
|
+
}
|
|
51
87
|
|
|
52
88
|
function printHelp() {
|
|
53
89
|
console.log(`@nvice-r/pr-commands installer
|
|
@@ -56,9 +92,13 @@ function printHelp() {
|
|
|
56
92
|
|
|
57
93
|
--dir <path> Repo to configure (default: cwd)
|
|
58
94
|
--provider <p> Force bitbucket|github|gitlab (default: detect from remote)
|
|
59
|
-
--agent-dir <dir>
|
|
95
|
+
--agent-dir <dir> Repo folder holding the reviewer roster (default: primary agent's
|
|
96
|
+
folder — .claude / .agent / .codex / .opencode)
|
|
97
|
+
--agent <id> Force skill install to this agent (repeatable). Ids: claude-code,
|
|
98
|
+
antigravity, codex, opencode. Default: auto-detect installed agents.
|
|
60
99
|
--all Install all provider binaries (default: only the detected one)
|
|
61
|
-
--yes Accept defaults, no prompts (reviewers=all, convention=trueid)
|
|
100
|
+
--yes Accept defaults, no prompts (reviewers=existing roster or all, convention=trueid)
|
|
101
|
+
--reset-reviewers Ignore an existing roster and rebuild it from the repo's default reviewers
|
|
62
102
|
--dry-run Print planned actions, write nothing
|
|
63
103
|
--help This help`);
|
|
64
104
|
}
|
|
@@ -73,8 +113,8 @@ const ask = (q) => new Promise((res) => {
|
|
|
73
113
|
});
|
|
74
114
|
// Editable pre-filled default: edit the shown value or hit enter to keep it.
|
|
75
115
|
// `:b` (or `:back`) returns BACK; empty keeps the default.
|
|
76
|
-
const askPrefill = (q, def, allowBack = true) => new Promise((res) => {
|
|
77
|
-
const r = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
116
|
+
const askPrefill = (q, def, allowBack = true, completer = undefined) => new Promise((res) => {
|
|
117
|
+
const r = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true, completer });
|
|
78
118
|
r.question(q, (a) => {
|
|
79
119
|
r.close();
|
|
80
120
|
const t = a.trim();
|
|
@@ -84,6 +124,22 @@ const askPrefill = (q, def, allowBack = true) => new Promise((res) => {
|
|
|
84
124
|
});
|
|
85
125
|
if (def && process.stdin.isTTY) r.write(def); // inject editable text
|
|
86
126
|
});
|
|
127
|
+
// Tab-completion for filesystem paths (directories only — work dir is always a dir).
|
|
128
|
+
// Preserves the user's typed prefix style (~, relative, absolute); appends "/" to each match.
|
|
129
|
+
function pathCompleter(line) {
|
|
130
|
+
const cut = line.lastIndexOf('/');
|
|
131
|
+
const dirPart = cut >= 0 ? line.slice(0, cut + 1) : ''; // keeps trailing slash, e.g. "~/Doc" -> "~/"
|
|
132
|
+
const base = cut >= 0 ? line.slice(cut + 1) : line;
|
|
133
|
+
let realDir = dirPart || '.';
|
|
134
|
+
if (realDir.startsWith('~')) realDir = HOME + realDir.slice(1);
|
|
135
|
+
let ents;
|
|
136
|
+
try { ents = fs.readdirSync(realDir, { withFileTypes: true }); } catch { return [[], line]; }
|
|
137
|
+
const hits = ents
|
|
138
|
+
.filter((e) => e.isDirectory() && e.name.startsWith(base))
|
|
139
|
+
.map((e) => dirPart + e.name + '/')
|
|
140
|
+
.sort();
|
|
141
|
+
return [hits, line];
|
|
142
|
+
}
|
|
87
143
|
|
|
88
144
|
// Interactive checklist (↑/↓ move · space toggle · a all · n none · enter confirm).
|
|
89
145
|
// Zero-dep raw-mode TTY. Returns array of selected indices. items: [{label}].
|
|
@@ -208,10 +264,69 @@ function writeFile(p, content, mode) {
|
|
|
208
264
|
// Main repo root, shared across worktrees: git-common-dir points at the main .git
|
|
209
265
|
// even from a linked worktree; its parent is the main checkout root. gitCommonDir
|
|
210
266
|
// may be relative ('.git') or absolute ('/main/.git') — path.resolve handles both.
|
|
267
|
+
// Existing roster, so a reinstall keeps it: --agent-dir, each agent's repo folder, then the
|
|
268
|
+
// 0.1.2 neutral .pr/ location. Returns { file, entries: [{uuid, name}] } or null.
|
|
269
|
+
function findExistingRoster(mroot) {
|
|
270
|
+
const dirs = [flags.agentDir && cleanDir(flags.agentDir), ...agentTargets().map((t) => t.repoDir)].filter(Boolean);
|
|
271
|
+
const files = [...new Set(dirs)].map((d) => path.join(mroot, d, 'pr-reviewers.txt'))
|
|
272
|
+
.concat(path.join(mroot, '.pr', 'reviewers.txt'));
|
|
273
|
+
const file = files.find((f) => fs.existsSync(f));
|
|
274
|
+
if (!file) return null;
|
|
275
|
+
const entries = [];
|
|
276
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
277
|
+
const m = line.match(/^\s*(\{[^}]+\})\s*(?:#\s*(.*))?$/);
|
|
278
|
+
if (m) entries.push({ uuid: m[1], name: (m[2] || '').trim() });
|
|
279
|
+
}
|
|
280
|
+
return { file, entries };
|
|
281
|
+
}
|
|
211
282
|
function mainRepoRoot(wd) {
|
|
212
283
|
const gcd = shSafe('git', ['-C', wd, 'rev-parse', '--git-common-dir']);
|
|
213
284
|
return gcd ? path.resolve(wd, gcd, '..') : null;
|
|
214
285
|
}
|
|
286
|
+
// Write the canonical SKILL.md, then link/copy it into each targeted agent. Claude also
|
|
287
|
+
// gets the /pr slash-command. --agent forces a set; otherwise install to every detected
|
|
288
|
+
// agent (claude-code is always included — it's this tool's native home).
|
|
289
|
+
// Agents whose config dir exists (claude-code always included — this tool's native home).
|
|
290
|
+
function detectedAgentIds() {
|
|
291
|
+
return agentTargets().filter((t) => t.id === 'claude-code' || fs.existsSync(t.base)).map((t) => t.id);
|
|
292
|
+
}
|
|
293
|
+
// Install the skill into exactly the agents in `ids`.
|
|
294
|
+
function installSkill(ids) {
|
|
295
|
+
const set = new Set(ids || []);
|
|
296
|
+
const body = fs.readFileSync(path.join(ASSETS, 'skill/pr.body.md'), 'utf8');
|
|
297
|
+
const canonDir = path.join(HOME, '.config/pr/skill/pr');
|
|
298
|
+
writeFile(path.join(canonDir, 'SKILL.md'), SKILL_FM + body);
|
|
299
|
+
|
|
300
|
+
const hits = [];
|
|
301
|
+
for (const t of agentTargets()) {
|
|
302
|
+
if (!set.has(t.id)) continue;
|
|
303
|
+
linkSkill(t, canonDir);
|
|
304
|
+
if (t.claude) writeFile(path.join(t.base, 'commands/pr.md'), CMD_FM + body); // dual: keep /pr
|
|
305
|
+
hits.push(t.id);
|
|
306
|
+
}
|
|
307
|
+
log(` skill targeted: ${hits.join(', ') || '(none)'}`);
|
|
308
|
+
}
|
|
309
|
+
// Symlink <agent>/skills/pr -> canonical dir. Replace only OUR symlink; never clobber a
|
|
310
|
+
// pre-existing real dir/file (could be the user's own `pr` skill). Copy if symlinks fail.
|
|
311
|
+
function linkSkill(t, canonDir) {
|
|
312
|
+
const dst = path.join(t.skills, 'pr');
|
|
313
|
+
if (flags.dryRun) { log(` [dry-run] link ${dst} -> ${canonDir}`); return; }
|
|
314
|
+
fs.mkdirSync(t.skills, { recursive: true });
|
|
315
|
+
let cur = null;
|
|
316
|
+
try { cur = fs.lstatSync(dst); } catch { /* absent */ }
|
|
317
|
+
if (cur) {
|
|
318
|
+
if (cur.isSymbolicLink()) fs.unlinkSync(dst);
|
|
319
|
+
else { log(` SKIP ${dst} — exists and is not our symlink; remove it and re-run`); return; }
|
|
320
|
+
}
|
|
321
|
+
try {
|
|
322
|
+
fs.symlinkSync(canonDir, dst, 'dir');
|
|
323
|
+
log(` linked ${dst} -> ${canonDir}`);
|
|
324
|
+
} catch (e) {
|
|
325
|
+
fs.mkdirSync(dst, { recursive: true });
|
|
326
|
+
fs.copyFileSync(path.join(canonDir, 'SKILL.md'), path.join(dst, 'SKILL.md'));
|
|
327
|
+
log(` copied ${dst}/SKILL.md (symlink unavailable: ${e.code})`);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
215
330
|
// Depth-limited scan for git repos beneath a non-repo dir. Stops descending once a
|
|
216
331
|
// repo is found (no nested-repo noise); skips dotdirs + node_modules; caps results.
|
|
217
332
|
function scanGitRepos(root, maxDepth = 3, cap = 50) {
|
|
@@ -316,6 +431,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
316
431
|
}
|
|
317
432
|
if (stage > 1 && st.chosen) L.push(` \x1b[32m✓\x1b[0m reviewers ${st.chosen.length} selected`);
|
|
318
433
|
if (stage > 2 && st.convention) L.push(` \x1b[32m✓\x1b[0m convention ${st.convention}`);
|
|
434
|
+
if (stage > 3 && st.agents) L.push(` \x1b[32m✓\x1b[0m agents ${st.agents.join(', ') || '(none)'}`);
|
|
319
435
|
return L;
|
|
320
436
|
};
|
|
321
437
|
// Full-screen redraw: wipe, reprint header + breadcrumb + active-step marker. No accumulation on back.
|
|
@@ -336,7 +452,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
336
452
|
let wd;
|
|
337
453
|
if (flags.dir || flags.yes) wd = path.resolve(flags.dir || process.cwd());
|
|
338
454
|
else {
|
|
339
|
-
const a = await askPrefill(' repo path: ', st.workDir || process.cwd());
|
|
455
|
+
const a = await askPrefill(' repo path: ', st.workDir || process.cwd(), true, pathCompleter);
|
|
340
456
|
if (a === BACK) return BACK; // first step — driver just stays here
|
|
341
457
|
wd = path.resolve(a);
|
|
342
458
|
}
|
|
@@ -378,7 +494,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
378
494
|
log(` WARN: no keychain token for ${pcfg.keychainSvc}/${email || '(none)'} — reviewers can't auto-fetch`);
|
|
379
495
|
log(` security add-generic-password -s ${pcfg.keychainSvc} -a ${email || 'you@example.com'} -w '<api-token>'`);
|
|
380
496
|
} else log(' token: found');
|
|
381
|
-
if (st.slug !== slug) st.revs = undefined; //
|
|
497
|
+
if (st.slug !== slug) { st.revs = undefined; st.existing = undefined; st.chosenIdx = undefined; } // repo changed → drop reviewer caches
|
|
382
498
|
const mroot = mainRepoRoot(wd) || wd; // shared across worktrees; roster lives here
|
|
383
499
|
if (mroot !== wd) log(` worktree detected → roster shared at main root: ${mroot}`);
|
|
384
500
|
Object.assign(st, { workDir: wd, mainRoot: mroot, remoteUrl, provider, pcfg, slug, email, token });
|
|
@@ -387,6 +503,13 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
387
503
|
const sReviewers = async () => {
|
|
388
504
|
screen(1, 'Reviewers');
|
|
389
505
|
st.chosen = st.chosen || [];
|
|
506
|
+
if (st.existing === undefined) st.existing = flags.resetReviewers ? null : findExistingRoster(st.mainRoot);
|
|
507
|
+
const ex = st.existing;
|
|
508
|
+
if (ex) log(` existing roster: ${ex.file} (${ex.entries.length})`);
|
|
509
|
+
// --yes (or no token to fetch with) + existing roster → keep it untouched, skip the fetch.
|
|
510
|
+
if (ex && (flags.yes || !(st.token && st.email))) {
|
|
511
|
+
st.chosen = ex.entries; st.extra = []; log(' kept existing roster (--reset-reviewers to rebuild)'); return;
|
|
512
|
+
}
|
|
390
513
|
if (!(st.token && st.email)) { log(' skipped (no token) — edit the roster later.'); st.chosen = []; return; }
|
|
391
514
|
if (st.selfUuid === undefined) st.selfUuid = await fetchSelfUuid(st.email, st.token);
|
|
392
515
|
if (st.revs === undefined) st.revs = await fetchBitbucketReviewers(st.slug, st.email, st.token, st.selfUuid);
|
|
@@ -394,14 +517,18 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
394
517
|
if (!revs) { log(' WARN: could not fetch default-reviewers. Roster left empty.'); st.chosen = []; return; }
|
|
395
518
|
if (revs.length === 0) { log(' repo has no default reviewers. Roster left empty.'); st.chosen = []; return; }
|
|
396
519
|
if (flags.yes) { st.chosen = revs; st.chosenIdx = revs.map((_, i) => i); log(` selected all ${revs.length}`); return; }
|
|
520
|
+
// Existing roster → pre-check its members; entries not among the defaults are kept as-is.
|
|
521
|
+
const exIds = ex ? new Set(ex.entries.map((e) => e.uuid)) : null;
|
|
522
|
+
st.extra = ex ? ex.entries.filter((e) => !revs.some((r) => r.uuid === e.uuid)) : [];
|
|
523
|
+
const preIdx = st.chosenIdx || (exIds ? revs.map((r, i) => (exIds.has(r.uuid) ? i : -1)).filter((i) => i >= 0) : null);
|
|
397
524
|
const picked = await multiSelect(
|
|
398
|
-
`Reviewers for ${st.slug} (default: all):`,
|
|
399
|
-
revs.map((r) => ({ label: r.name })), true,
|
|
525
|
+
`Reviewers for ${st.slug} (default: ${ex ? 'existing roster' : 'all'}):`,
|
|
526
|
+
revs.map((r) => ({ label: r.name })), true, preIdx,
|
|
400
527
|
);
|
|
401
528
|
if (picked === BACK) return BACK;
|
|
402
529
|
st.chosenIdx = picked;
|
|
403
|
-
st.chosen = picked.map((i) => revs[i]);
|
|
404
|
-
log(` chosen: ${st.chosen.length}`);
|
|
530
|
+
st.chosen = picked.map((i) => revs[i]).concat(st.extra);
|
|
531
|
+
log(` chosen: ${st.chosen.length}${st.extra.length ? ` (incl. ${st.extra.length} kept from existing roster, not in defaults)` : ''}`);
|
|
405
532
|
};
|
|
406
533
|
|
|
407
534
|
const sPreset = async () => {
|
|
@@ -429,12 +556,32 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
429
556
|
st.conv[key] = v;
|
|
430
557
|
};
|
|
431
558
|
|
|
559
|
+
// Pick which agents get the /pr skill. --agent overrides (non-interactive); --yes auto-detects;
|
|
560
|
+
// else an interactive checklist with detected agents pre-checked.
|
|
561
|
+
const sAgents = async () => {
|
|
562
|
+
screen(3, 'Agents');
|
|
563
|
+
const targets = agentTargets();
|
|
564
|
+
if (flags.agents) { st.agents = flags.agents; log(` forced (--agent): ${st.agents.join(', ')}`); return; }
|
|
565
|
+
const detected = new Set(detectedAgentIds());
|
|
566
|
+
if (flags.yes) { st.agents = [...detected]; log(` auto-detected: ${st.agents.join(', ')}`); return; }
|
|
567
|
+
const preIdx = targets.map((t, i) => (detected.has(t.id) ? i : -1)).filter((i) => i >= 0);
|
|
568
|
+
const picked = await multiSelect(
|
|
569
|
+
'Install the /pr skill into which agents? (detected pre-checked):',
|
|
570
|
+
targets.map((t) => ({ label: `${t.id}${detected.has(t.id) ? ' \x1b[2m(detected)\x1b[0m' : ''}` })),
|
|
571
|
+
false, preIdx,
|
|
572
|
+
);
|
|
573
|
+
if (picked === BACK) return BACK;
|
|
574
|
+
st.agents = picked.map((i) => targets[i].id);
|
|
575
|
+
log(` agents: ${st.agents.join(', ') || '(none)'}`);
|
|
576
|
+
};
|
|
577
|
+
|
|
432
578
|
const steps = flags.yes
|
|
433
|
-
? [sWorkDir, sReviewers, sPreset]
|
|
579
|
+
? [sWorkDir, sReviewers, sPreset, sAgents]
|
|
434
580
|
: [sWorkDir, sReviewers, sPreset,
|
|
435
581
|
editField('title_pattern: ', 'title_pattern'),
|
|
436
582
|
editField('ticket_regex: ', 'ticket_regex'),
|
|
437
|
-
editField('template_sections:', 'template_sections')
|
|
583
|
+
editField('template_sections:', 'template_sections'),
|
|
584
|
+
sAgents];
|
|
438
585
|
|
|
439
586
|
let i = 0;
|
|
440
587
|
while (i < steps.length) {
|
|
@@ -444,18 +591,30 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
444
591
|
}
|
|
445
592
|
|
|
446
593
|
// --- install ---
|
|
447
|
-
screen(
|
|
594
|
+
screen(4, 'Install');
|
|
448
595
|
const { slug, pcfg, email, convention, conv, chosen } = st;
|
|
449
|
-
|
|
596
|
+
// Roster folder = --agent-dir, else the first chosen agent's repo folder, else .claude.
|
|
597
|
+
const agentIds = st.agents || detectedAgentIds();
|
|
598
|
+
const primary = agentTargets().find((t) => t.id === agentIds[0]);
|
|
599
|
+
const agentDir = cleanDir(flags.agentDir || (primary && primary.repoDir) || '.claude');
|
|
600
|
+
let cfg = `pkg_version=${PKG_VERSION}\nos=${cfgOs}\ntoken_src=${tokenSrc}\nemail=${email}\nconvention=${convention}\nagent_dir=${agentDir}\n`;
|
|
450
601
|
if (conv.title_pattern) cfg += `title_pattern=${conv.title_pattern}\n`;
|
|
451
602
|
if (conv.ticket_regex) cfg += `ticket_regex=${conv.ticket_regex}\n`;
|
|
452
603
|
if (conv.template_sections) cfg += `template_sections=${conv.template_sections}\n`;
|
|
453
604
|
writeFile(path.join(HOME, '.config/pr/config'), cfg, 0o600);
|
|
454
|
-
// Roster lives
|
|
455
|
-
//
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
605
|
+
// Roster: <main-repo-root>/<agent_dir>/pr-reviewers.txt — lives with the primary agent's repo
|
|
606
|
+
// folder, shared across worktrees. agent_dir is recorded in the config so the bash binary
|
|
607
|
+
// (which can't know the calling agent) reads the same path; legacy .pr/ + root + global paths
|
|
608
|
+
// remain load fallbacks.
|
|
609
|
+
const rosterFile = path.join(st.mainRoot, agentDir, 'pr-reviewers.txt');
|
|
610
|
+
const kept = st.existing && chosen === st.existing.entries; // untouched existing roster
|
|
611
|
+
if (kept && st.existing.file === rosterFile) log(` kept ${rosterFile}`);
|
|
612
|
+
else if (kept) writeFile(rosterFile, fs.readFileSync(st.existing.file)); // move to new agent_dir; old file left as fallback
|
|
613
|
+
else {
|
|
614
|
+
const roster = chosen.map((r) => `${r.uuid} # ${r.name}`).join('\n') + (chosen.length ? '\n' : '');
|
|
615
|
+
const header = `# pr reviewers for ${slug} — {uuid} # Name per line; blanks/# ignored. Edit freely.\n`;
|
|
616
|
+
writeFile(rosterFile, header + (roster || '# (none selected)\n'));
|
|
617
|
+
}
|
|
459
618
|
const binDir = path.join(HOME, '.local/bin');
|
|
460
619
|
const binsToInstall = flags.all
|
|
461
620
|
? Object.values(PROVIDERS).filter((p) => p.ready).map((p) => p.bin)
|
|
@@ -466,7 +625,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
466
625
|
if (!fs.existsSync(src)) { log(` skip ${b} (asset not present yet)`); continue; }
|
|
467
626
|
writeFile(path.join(binDir, b), fs.readFileSync(src), 0o755);
|
|
468
627
|
}
|
|
469
|
-
|
|
628
|
+
installSkill(agentIds);
|
|
470
629
|
if (!(process.env.PATH || '').split(':').includes(binDir)) {
|
|
471
630
|
log(`\n WARN: ${binDir} not on PATH. Add to your shell rc:`);
|
|
472
631
|
log(` export PATH="$HOME/.local/bin:$PATH"`);
|
package/package.json
CHANGED
package/assets/skill/pr.md
DELETED
|
@@ -1,157 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
description: Create a pull request for the current branch (provider auto-detected)
|
|
3
|
-
argument-hint: [base-branch] [draft]
|
|
4
|
-
model: sonnet
|
|
5
|
-
---
|
|
6
|
-
|
|
7
|
-
Create a pull request for the current branch. Provider (Bitbucket / GitHub / GitLab) is
|
|
8
|
-
auto-detected from the git remote; the matching binary (`bbpr` / `ghpr` / `glpr`) is invoked.
|
|
9
|
-
Convention (title pattern, ticket regex, PR template) comes from `~/.config/pr/config`.
|
|
10
|
-
|
|
11
|
-
Installed by `npx @nvice-r/pr-commands`. If setup is missing, this command STOPS and tells the
|
|
12
|
-
user to run the installer — it never guesses config (D8: hard fail, no hardcoded fallback).
|
|
13
|
-
|
|
14
|
-
If `$ARGUMENTS` starts with `help`/`-help`/`--help` (case-insensitive), print the **Help** block and stop.
|
|
15
|
-
|
|
16
|
-
## Help (`/pr help`)
|
|
17
|
-
|
|
18
|
-
```
|
|
19
|
-
/pr — provider-agnostic PR helper (@nvice-r/pr-commands)
|
|
20
|
-
|
|
21
|
-
USAGE
|
|
22
|
-
/pr [base-branch] [draft] Create a PR for the current branch (default).
|
|
23
|
-
/pr help Show this help.
|
|
24
|
-
|
|
25
|
-
CREATE
|
|
26
|
-
base-branch Any arg containing "/". If omitted, inferred from closest ancestor
|
|
27
|
-
remote branch (saved per-branch after first run).
|
|
28
|
-
draft Create as draft. Otherwise opens ready (asks to confirm).
|
|
29
|
-
|
|
30
|
-
CONFIG ~/.config/pr/config (os, token_src, email, convention)
|
|
31
|
-
PROVIDER auto from `git remote get-url origin` -> bbpr | ghpr | glpr
|
|
32
|
-
REVIEWERS ~/.config/pr/reviewers/<workspace>-<slug>.txt (per-repo, global location)
|
|
33
|
-
SETUP npx @nvice-r/pr-commands
|
|
34
|
-
```
|
|
35
|
-
|
|
36
|
-
## Step 0 — verify install (HARD FAIL, no fallback)
|
|
37
|
-
|
|
38
|
-
Run the checks; if any fails, STOP and tell the user to run `npx @nvice-r/pr-commands` (name the
|
|
39
|
-
missing piece). Do NOT improvise config or reviewers.
|
|
40
|
-
|
|
41
|
-
```bash
|
|
42
|
-
# a. config present?
|
|
43
|
-
CFG="${PR_CONFIG:-$HOME/.config/pr/config}"
|
|
44
|
-
[ -f "$CFG" ] || { echo "MISSING config: $CFG"; }
|
|
45
|
-
|
|
46
|
-
# b. detect provider from remote
|
|
47
|
-
URL="$(git remote get-url origin 2>/dev/null)" || echo "no origin remote"
|
|
48
|
-
case "$URL" in
|
|
49
|
-
*bitbucket.org*) PROVIDER=bitbucket; BIN=bbpr ;;
|
|
50
|
-
*github.com*) PROVIDER=github; BIN=ghpr ;;
|
|
51
|
-
*gitlab.com*) PROVIDER=gitlab; BIN=glpr ;;
|
|
52
|
-
*) echo "unknown provider for remote: $URL" ;;
|
|
53
|
-
esac
|
|
54
|
-
|
|
55
|
-
# c. matching binary installed?
|
|
56
|
-
command -v "$BIN" >/dev/null 2>&1 || echo "MISSING binary: $BIN (run installer, or provider not yet supported)"
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
- config missing → STOP: "run `npx @nvice-r/pr-commands`".
|
|
60
|
-
- provider unknown → STOP: only bitbucket/github/gitlab supported.
|
|
61
|
-
- `$BIN` = `ghpr`/`glpr` and not installed → STOP: "provider not yet supported (macOS+Bitbucket only for now)". (D6 stub.)
|
|
62
|
-
- all pass → continue.
|
|
63
|
-
|
|
64
|
-
## Step 1 — gather context (parallel)
|
|
65
|
-
- `git rev-parse --abbrev-ref HEAD` — source branch (CURR)
|
|
66
|
-
- `git config --get branch.<CURR>.bbprBase` — saved base, if any
|
|
67
|
-
- `git log --oneline -20`
|
|
68
|
-
- read `convention=` from `$CFG` (default `trueid` if unset — but config presence already enforced in Step 0)
|
|
69
|
-
|
|
70
|
-
## Step 2 — determine base branch (priority)
|
|
71
|
-
a. `$1` (any arg containing `/`) → use it.
|
|
72
|
-
b. else `branch.<CURR>.bbprBase` from git config → use silently.
|
|
73
|
-
c. else infer — smallest non-zero `<ref>..HEAD` distance where `<ref>` is an ancestor of HEAD:
|
|
74
|
-
```bash
|
|
75
|
-
for ref in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin | grep -v "^origin/<CURR>$"); do
|
|
76
|
-
if git merge-base --is-ancestor "$ref" HEAD 2>/dev/null; then
|
|
77
|
-
ahead=$(git rev-list --count "$ref"..HEAD)
|
|
78
|
-
[ "$ahead" -gt 0 ] && echo "$ahead $ref"
|
|
79
|
-
fi
|
|
80
|
-
done | sort -n | head -5
|
|
81
|
-
```
|
|
82
|
-
Propose smallest-`ahead` candidate; confirm. Do NOT auto-pick a naming-pattern match — a
|
|
83
|
-
stacked PR on another in-flight ticket branch is common. Ties or all >20 ahead → ask.
|
|
84
|
-
d. still ambiguous → ask.
|
|
85
|
-
|
|
86
|
-
The binary auto-saves the resolved base to `branch.<CURR>.bbprBase` on success.
|
|
87
|
-
|
|
88
|
-
## Step 3 — build diff context
|
|
89
|
-
`git log <base>..HEAD --oneline` · `git diff --stat <base>..HEAD`. Multiple commits → summarize each.
|
|
90
|
-
|
|
91
|
-
## Step 3.5 — split advisory (size gate, advisory, never blocks)
|
|
92
|
-
Compute from `git diff --numstat <base>..HEAD`: file count, churn Σ(add+del), distinct modules.
|
|
93
|
-
Flag if `>25 files` OR `>600 churn` OR `>4 modules`. Under → silent, continue. Over → cluster the
|
|
94
|
-
diff into seams, detect coupling (shared file/symbol → stacked; disjoint → independent; interleaved
|
|
95
|
-
→ one atomic PR), present metrics + seam table, then `AskUserQuestion`: proceed-as-one /
|
|
96
|
-
split (`guard_cmd pr-split`, degrade if absent) / adjust-threshold. Never auto-split.
|
|
97
|
-
|
|
98
|
-
## Step 4 — draft title (from config, authoritative)
|
|
99
|
-
|
|
100
|
-
Read `title_pattern=` and `ticket_regex=` directly from `$CFG` — the installer wrote concrete,
|
|
101
|
-
possibly user-edited values, so DO NOT reconstruct them from a preset table. Extract the ticket
|
|
102
|
-
key + any version from the branch name using `ticket_regex`, then fill `title_pattern`.
|
|
103
|
-
|
|
104
|
-
`convention=` is only a behavior label (drives the trueid body rules in Step 5). Example
|
|
105
|
-
(trueid): branch `feature/<ver>/SC-XXXX-...`, pattern `[<ver>] Feature/<ver>/SC-XXXX <desc>` →
|
|
106
|
-
kebab suffix becomes spaced lowercase `<desc>`.
|
|
107
|
-
|
|
108
|
-
## Step 5 — draft body (from config)
|
|
109
|
-
|
|
110
|
-
Read `template_sections=` from `$CFG` (pipe-separated) and render each as an `## <section>`
|
|
111
|
-
heading. These are authoritative — the installer wrote them (preset seed, possibly edited).
|
|
112
|
-
|
|
113
|
-
**trueid template rules** (only when `convention=trueid`):
|
|
114
|
-
- `What Happened` = summary prose only — no file bullets, no commit-subject line.
|
|
115
|
-
- Changed-file detail lives under `Insight`.
|
|
116
|
-
- 3+ files sharing a parent dir → state common root once ("Under `<root>`:") + flat list of joined
|
|
117
|
-
relative paths (no nested bullets). Android module root → Gradle path `:features:<x>:<y>`, leaf
|
|
118
|
-
bullets = basenames only. Skip boilerplate (`settings.gradle.kts` include, empty manifest, lockfiles).
|
|
119
|
-
- Unrelated areas → full paths per bullet, no common-root statement.
|
|
120
|
-
- `Proof of Work` empty unless user gives screenshots/links.
|
|
121
|
-
|
|
122
|
-
For non-trueid presets, fill sections with a plain summary + changed-file list; no Gradle shorthand.
|
|
123
|
-
|
|
124
|
-
## Step 6 — write body to `/tmp/pr-body.md` (Write tool).
|
|
125
|
-
|
|
126
|
-
## Step 7 — confirm. Show proposed title + base + body + resolved provider/binary. WAIT. PR creation is user-visible; never silent.
|
|
127
|
-
|
|
128
|
-
## Step 8 — create. Run the detected binary (append `--draft` only if draft chosen):
|
|
129
|
-
```bash
|
|
130
|
-
<BIN> --title "<title>" --body-file /tmp/pr-body.md --dest "<base>" [--draft]
|
|
131
|
-
```
|
|
132
|
-
Always ask open-vs-draft before creating unless `draft` was passed. The binary attaches the
|
|
133
|
-
per-repo reviewer roster automatically; `--no-reviewers` skips, `--reviewer <id>` appends.
|
|
134
|
-
|
|
135
|
-
## Step 9 — print final PR URL + reviewer count from binary output. On error, surface raw response.
|
|
136
|
-
|
|
137
|
-
## Step 10 — offer Jira sync (guarded, optional).
|
|
138
|
-
Reuse ticket key from Step 4. If none, skip. `guard_cmd jira-update`; if absent, just print URL + stop.
|
|
139
|
-
If present, `AskUserQuestion`: sync now → `/jira-update <KEY>`, or skip (print reminder).
|
|
140
|
-
|
|
141
|
-
## Cross-command guard
|
|
142
|
-
Sibling commands (`/pr-split`, `/jira-update`) are optional — a teammate may not have them. Before
|
|
143
|
-
invoking any, confirm it exists; if absent, degrade gracefully (one-line note). Reusable check:
|
|
144
|
-
```bash
|
|
145
|
-
guard_cmd() { # usage: guard_cmd pr-split → prints path or "no-<cmd>"
|
|
146
|
-
ls "$PWD/.claude/commands/$1.md" "$HOME/.claude/commands/$1.md" \
|
|
147
|
-
"$PWD/.claude/skills/$1/SKILL.md" "$HOME/.claude/skills/$1/SKILL.md" 2>/dev/null | head -1 \
|
|
148
|
-
|| echo "no-$1"
|
|
149
|
-
}
|
|
150
|
-
```
|
|
151
|
-
|
|
152
|
-
## Notes
|
|
153
|
-
- Does NOT push — assumes branch pushed. Unpushed commits ahead of upstream → warn + ask.
|
|
154
|
-
- Provider/token/convention all from config + remote — NO hardcoded team values here (D3/D4).
|
|
155
|
-
- Verify worktree before any split branch work (`git symbolic-ref --short HEAD` + `git worktree list`);
|
|
156
|
-
background restacks can move branches mid-turn.
|
|
157
|
-
- Caveman mode applies to replies — but PR title/body must be normal English.
|