@nvice-r/pr-commands 0.1.2 → 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 +41 -9
- package/assets/bin/bbpr +7 -4
- package/assets/bin/pr-common.sh +11 -6
- package/assets/skill/pr.body.md +81 -25
- package/install.js +64 -22
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -23,6 +23,8 @@ 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`
|
|
@@ -44,7 +46,9 @@ Flags:
|
|
|
44
46
|
| `--provider <p>` | force `bitbucket`\|`github`\|`gitlab` instead of detecting |
|
|
45
47
|
| `--agent <id>` | force skill install to this agent (repeatable): `claude-code`\|`antigravity`\|`codex`\|`opencode`. Default: auto-detect |
|
|
46
48
|
| `--all` | install every ready provider binary, not just the detected one |
|
|
47
|
-
| `--
|
|
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 |
|
|
48
52
|
| `--dry-run` | print planned actions, write nothing |
|
|
49
53
|
| `--help` | usage |
|
|
50
54
|
|
|
@@ -77,7 +81,7 @@ identically from every clone and every git worktree, no repo-local symlinks.
|
|
|
77
81
|
| Shared library | `~/.local/bin/pr-common.sh` |
|
|
78
82
|
| Provider binary | `~/.local/bin/bbpr` (`ghpr`/`glpr` later) |
|
|
79
83
|
| Config | `~/.config/pr/config` |
|
|
80
|
-
| Reviewer roster | `<main-repo-root
|
|
84
|
+
| Reviewer roster | `<main-repo-root>/<agent_dir>/pr-reviewers.txt` (primary agent's folder; shared across worktrees) |
|
|
81
85
|
| Canonical skill | `~/.config/pr/skill/pr/SKILL.md` (single source) |
|
|
82
86
|
| Per-agent skill | `<agent-skills-dir>/pr` → symlink to canonical |
|
|
83
87
|
| Claude `/pr` command | `~/.claude/commands/pr.md` (dual-install, keeps the slash trigger) |
|
|
@@ -115,6 +119,30 @@ convention=trueid # see presets
|
|
|
115
119
|
Edit by hand any time. **A missing config is a hard error** — `/pr` and the binaries refuse to
|
|
116
120
|
guess. Re-run the installer to regenerate.
|
|
117
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
|
+
|
|
118
146
|
### Convention presets
|
|
119
147
|
|
|
120
148
|
| preset | title pattern | ticket regex | template sections |
|
|
@@ -135,14 +163,18 @@ template_sections=Context|Changes|Risk
|
|
|
135
163
|
|
|
136
164
|
### Reviewers
|
|
137
165
|
|
|
138
|
-
Roster lives in
|
|
139
|
-
|
|
140
|
-
|
|
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:
|
|
141
171
|
|
|
142
172
|
```
|
|
143
|
-
<main-repo-root
|
|
173
|
+
<main-repo-root>/<agent_dir>/pr-reviewers.txt
|
|
144
174
|
```
|
|
145
175
|
|
|
176
|
+
Switching primary agent = re-run the installer; the old file still loads as a fallback.
|
|
177
|
+
|
|
146
178
|
You (the author) are excluded on install — bbpr also drops self at PR time. One entry per line,
|
|
147
179
|
trailing `# Name` comment optional:
|
|
148
180
|
|
|
@@ -151,9 +183,9 @@ trailing `# Name` comment optional:
|
|
|
151
183
|
{fedcba98-7654-3210-fedc-ba9876543210} # Bob
|
|
152
184
|
```
|
|
153
185
|
|
|
154
|
-
It's plain data — edit freely, no reinstall. Consider gitignoring
|
|
155
|
-
|
|
156
|
-
(0.1.
|
|
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` →
|
|
157
189
|
legacy. Run-time overrides: `--no-reviewers` skips all, `--reviewer <uuid>` appends.
|
|
158
190
|
|
|
159
191
|
### Auth precedence (email)
|
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 ;;
|
|
@@ -129,11 +134,11 @@ pr_load_reviewers() {
|
|
|
129
134
|
# main repo root, shared across worktrees: git-common-dir/.. (empty if git call fails)
|
|
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
|
-
${mainroot:+"$mainroot/.pr/reviewers.txt"} \
|
|
133
137
|
${mainroot:+"$mainroot/${CFG_AGENT_DIR:-.claude}/pr-reviewers.txt"} \
|
|
138
|
+
${mainroot:+"$mainroot/.pr/reviewers.txt"} \
|
|
134
139
|
${mainroot:+"$mainroot/.pr-reviewers.txt"} \
|
|
135
140
|
"$HOME/.config/pr/reviewers/${slug}.txt" \
|
|
136
|
-
"$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
|
|
137
142
|
[ -f "$cand" ] && { file="$cand"; break; }
|
|
138
143
|
done
|
|
139
144
|
fi
|
package/assets/skill/pr.body.md
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
Create a pull request for the current branch. Provider (Bitbucket / GitHub / GitLab) is
|
|
2
2
|
auto-detected from the git remote; the matching binary (`bbpr` / `ghpr` / `glpr`) is invoked.
|
|
3
|
-
Convention (title pattern, ticket regex, PR template) comes
|
|
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.
|
|
4
6
|
|
|
5
7
|
Installed by `npx @nvice-r/pr-commands`. If setup is missing, this command STOPS and tells the
|
|
6
8
|
user to run the installer — it never guesses config (D8: hard fail, no hardcoded fallback).
|
|
@@ -16,14 +18,16 @@ skip the create steps entirely.
|
|
|
16
18
|
/pr — provider-agnostic PR helper (@nvice-r/pr-commands)
|
|
17
19
|
|
|
18
20
|
USAGE
|
|
19
|
-
/pr [base-branch] [draft]
|
|
21
|
+
/pr [base-branch] [draft] [cross]
|
|
22
|
+
Create a PR for the current branch (default).
|
|
20
23
|
/pr update [PR#] [short] Rewrite an existing PR's description in place.
|
|
21
24
|
/pr help Show this help.
|
|
22
25
|
|
|
23
26
|
CREATE
|
|
24
27
|
base-branch Any arg containing "/". If omitted, inferred from closest ancestor
|
|
25
28
|
remote branch (saved per-branch after first run).
|
|
26
|
-
draft Create as draft. Otherwise
|
|
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:`.
|
|
27
31
|
|
|
28
32
|
UPDATE
|
|
29
33
|
PR# PR number to rewrite. If omitted, resolved from the current branch's
|
|
@@ -31,9 +35,10 @@ UPDATE
|
|
|
31
35
|
short Compress harder (caveman brevity): cut throat-clearing, restated ticket
|
|
32
36
|
goals, filler connectors. Keeps every file + technical fact.
|
|
33
37
|
|
|
34
|
-
|
|
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)
|
|
35
40
|
PROVIDER auto from `git remote get-url origin` -> bbpr | ghpr | glpr
|
|
36
|
-
REVIEWERS <main-repo-root
|
|
41
|
+
REVIEWERS <main-repo-root>/<agent_dir>/pr-reviewers.txt (per-repo, shared across worktrees; agent_dir from config)
|
|
37
42
|
SETUP npx @nvice-r/pr-commands
|
|
38
43
|
```
|
|
39
44
|
|
|
@@ -58,6 +63,16 @@ esac
|
|
|
58
63
|
|
|
59
64
|
# c. matching binary installed?
|
|
60
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)}"
|
|
61
76
|
```
|
|
62
77
|
|
|
63
78
|
- config missing → STOP: "run `npx @nvice-r/pr-commands`".
|
|
@@ -69,7 +84,23 @@ command -v "$BIN" >/dev/null 2>&1 || echo "MISSING binary: $BIN (run installer,
|
|
|
69
84
|
- `git rev-parse --abbrev-ref HEAD` — source branch (CURR)
|
|
70
85
|
- `git config --get branch.<CURR>.bbprBase` — saved base, if any
|
|
71
86
|
- `git log --oneline -20`
|
|
72
|
-
- read
|
|
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 |
|
|
73
104
|
|
|
74
105
|
## Step 2 — determine base branch (priority)
|
|
75
106
|
a. `$1` (any arg containing `/`) → use it.
|
|
@@ -85,7 +116,7 @@ c. else infer — smallest non-zero `<ref>..HEAD` distance where `<ref>` is an a
|
|
|
85
116
|
```
|
|
86
117
|
Propose smallest-`ahead` candidate; confirm. Do NOT auto-pick a naming-pattern match — a
|
|
87
118
|
stacked PR on another in-flight ticket branch is common. Ties or all >20 ahead → ask.
|
|
88
|
-
d. still ambiguous → ask.
|
|
119
|
+
d. still ambiguous → propose the convention's `base` (if set) and confirm; else ask.
|
|
89
120
|
|
|
90
121
|
The binary auto-saves the resolved base to `branch.<CURR>.bbprBase` on success.
|
|
91
122
|
|
|
@@ -99,23 +130,30 @@ diff into seams, detect coupling (shared file/symbol → stacked; disjoint → i
|
|
|
99
130
|
→ one atomic PR), present metrics + seam table, then `AskUserQuestion`: proceed-as-one /
|
|
100
131
|
split (`guard_cmd pr-split`, degrade if absent) / adjust-threshold. Never auto-split.
|
|
101
132
|
|
|
102
|
-
## Step 4 — draft title (from
|
|
103
|
-
|
|
104
|
-
Read `title_pattern=` and `ticket_regex=` directly from `$CFG` — the installer wrote concrete,
|
|
105
|
-
possibly user-edited values, so DO NOT reconstruct them from a preset table. Extract the ticket
|
|
106
|
-
key + any version from the branch name using `ticket_regex`, then fill `title_pattern`.
|
|
133
|
+
## Step 4 — draft title (from the convention, authoritative)
|
|
107
134
|
|
|
108
|
-
`
|
|
109
|
-
|
|
110
|
-
|
|
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>`).
|
|
111
140
|
|
|
112
141
|
## Step 5 — draft body (from config)
|
|
113
142
|
|
|
114
|
-
|
|
115
|
-
|
|
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.
|
|
116
152
|
|
|
117
|
-
**trueid
|
|
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`.
|
|
118
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).
|
|
119
157
|
- Changed-file detail lives under `Insight`.
|
|
120
158
|
- 3+ files sharing a parent dir → state common root once ("Under `<root>`:") + flat list of joined
|
|
121
159
|
relative paths (no nested bullets). Android module root → Gradle path `:features:<x>:<y>`, leaf
|
|
@@ -123,24 +161,42 @@ heading. These are authoritative — the installer wrote them (preset seed, poss
|
|
|
123
161
|
- Unrelated areas → full paths per bullet, no common-root statement.
|
|
124
162
|
- `Proof of Work` empty unless user gives screenshots/links.
|
|
125
163
|
|
|
126
|
-
|
|
164
|
+
With no `body_rules` and no legacy rules, fill sections with a plain summary + changed-file list.
|
|
127
165
|
|
|
128
166
|
## Step 6 — write body to `/tmp/pr-body.md` (Write tool).
|
|
129
167
|
|
|
130
|
-
## Step
|
|
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.
|
|
131
183
|
|
|
132
184
|
## Step 8 — create. Run the detected binary (append `--draft` only if draft chosen):
|
|
133
185
|
```bash
|
|
134
|
-
<BIN> --title "<title>" --body-file /tmp/pr-body.md --dest "<base>" [--draft]
|
|
186
|
+
<BIN> --title "<title>" --body-file /tmp/pr-body.md --dest "<base>" [--draft] \
|
|
187
|
+
[--source-repo "<fork.repo>"] # cross mode only
|
|
135
188
|
```
|
|
136
|
-
|
|
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
|
|
137
192
|
per-repo reviewer roster automatically; `--no-reviewers` skips, `--reviewer <id>` appends.
|
|
138
193
|
|
|
139
194
|
## Step 9 — print final PR URL + reviewer count from binary output. On error, surface raw response.
|
|
140
195
|
|
|
141
|
-
## Step 10 —
|
|
142
|
-
|
|
143
|
-
|
|
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.
|
|
144
200
|
|
|
145
201
|
## Update mode (`/pr update [PR#] [short]`)
|
|
146
202
|
|
package/install.js
CHANGED
|
@@ -16,6 +16,7 @@ const { execFileSync } = require('child_process');
|
|
|
16
16
|
|
|
17
17
|
const HOME = os.homedir();
|
|
18
18
|
const PKG = __dirname;
|
|
19
|
+
const PKG_VERSION = require('./package.json').version;
|
|
19
20
|
const ASSETS = path.join(PKG, 'assets');
|
|
20
21
|
const BACK = Symbol('back'); // returned by a prompt when the user wants the previous step
|
|
21
22
|
|
|
@@ -36,7 +37,7 @@ const PRESET_DEFS = {
|
|
|
36
37
|
|
|
37
38
|
// ---- arg parse ----
|
|
38
39
|
const argv = process.argv.slice(2);
|
|
39
|
-
const flags = { dir: null, provider: null, all: false, yes: false, dryRun: false, agentDir: null, agents: null };
|
|
40
|
+
const flags = { dir: null, provider: null, all: false, yes: false, dryRun: false, agentDir: null, agents: null, resetReviewers: false };
|
|
40
41
|
for (let i = 0; i < argv.length; i++) {
|
|
41
42
|
const a = argv[i];
|
|
42
43
|
if (a === '--help' || a === '-h') { printHelp(); process.exit(0); }
|
|
@@ -47,9 +48,10 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
47
48
|
else if (a === '--all') flags.all = true;
|
|
48
49
|
else if (a === '--yes' || a === '-y') flags.yes = true;
|
|
49
50
|
else if (a === '--dry-run') flags.dryRun = true;
|
|
51
|
+
else if (a === '--reset-reviewers') flags.resetReviewers = true;
|
|
50
52
|
else { console.error(`unknown flag: ${a}`); process.exit(1); }
|
|
51
53
|
}
|
|
52
|
-
const
|
|
54
|
+
const cleanDir = (d) => d.replace(/^\/+|\/+$/g, ''); // strip stray slashes
|
|
53
55
|
|
|
54
56
|
// ---- portable skill ----
|
|
55
57
|
// One canonical SKILL.md (~/.config/pr/skill/pr/); every detected agent's global skills
|
|
@@ -68,17 +70,18 @@ model: sonnet
|
|
|
68
70
|
---
|
|
69
71
|
|
|
70
72
|
`;
|
|
71
|
-
// base = probe dir deciding "installed"; skills = GLOBAL skills dir we link into
|
|
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).
|
|
72
75
|
// Env vars resolved the same way the agents themselves resolve them.
|
|
73
76
|
function agentTargets() {
|
|
74
77
|
const claudeBase = process.env.CLAUDE_CONFIG_DIR || path.join(HOME, '.claude');
|
|
75
78
|
const codexBase = process.env.CODEX_HOME || path.join(HOME, '.codex');
|
|
76
79
|
const xdg = process.env.XDG_CONFIG_HOME || path.join(HOME, '.config');
|
|
77
80
|
return [
|
|
78
|
-
{ id: 'claude-code', base: claudeBase, skills: path.join(claudeBase, 'skills'), claude: true },
|
|
79
|
-
{ id: 'antigravity', base: path.join(HOME, '.gemini/antigravity'), skills: path.join(HOME, '.gemini/antigravity/skills') },
|
|
80
|
-
{ id: 'codex', base: codexBase, skills: path.join(codexBase, 'skills') },
|
|
81
|
-
{ id: 'opencode', base: path.join(xdg, 'opencode'), skills: path.join(xdg, 'opencode/skills') },
|
|
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' },
|
|
82
85
|
];
|
|
83
86
|
}
|
|
84
87
|
|
|
@@ -89,11 +92,13 @@ function printHelp() {
|
|
|
89
92
|
|
|
90
93
|
--dir <path> Repo to configure (default: cwd)
|
|
91
94
|
--provider <p> Force bitbucket|github|gitlab (default: detect from remote)
|
|
92
|
-
--agent-dir <dir>
|
|
95
|
+
--agent-dir <dir> Repo folder holding the reviewer roster (default: primary agent's
|
|
96
|
+
folder — .claude / .agent / .codex / .opencode)
|
|
93
97
|
--agent <id> Force skill install to this agent (repeatable). Ids: claude-code,
|
|
94
98
|
antigravity, codex, opencode. Default: auto-detect installed agents.
|
|
95
99
|
--all Install all provider binaries (default: only the detected one)
|
|
96
|
-
--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
|
|
97
102
|
--dry-run Print planned actions, write nothing
|
|
98
103
|
--help This help`);
|
|
99
104
|
}
|
|
@@ -259,6 +264,21 @@ function writeFile(p, content, mode) {
|
|
|
259
264
|
// Main repo root, shared across worktrees: git-common-dir points at the main .git
|
|
260
265
|
// even from a linked worktree; its parent is the main checkout root. gitCommonDir
|
|
261
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
|
+
}
|
|
262
282
|
function mainRepoRoot(wd) {
|
|
263
283
|
const gcd = shSafe('git', ['-C', wd, 'rev-parse', '--git-common-dir']);
|
|
264
284
|
return gcd ? path.resolve(wd, gcd, '..') : null;
|
|
@@ -474,7 +494,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
474
494
|
log(` WARN: no keychain token for ${pcfg.keychainSvc}/${email || '(none)'} — reviewers can't auto-fetch`);
|
|
475
495
|
log(` security add-generic-password -s ${pcfg.keychainSvc} -a ${email || 'you@example.com'} -w '<api-token>'`);
|
|
476
496
|
} else log(' token: found');
|
|
477
|
-
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
|
|
478
498
|
const mroot = mainRepoRoot(wd) || wd; // shared across worktrees; roster lives here
|
|
479
499
|
if (mroot !== wd) log(` worktree detected → roster shared at main root: ${mroot}`);
|
|
480
500
|
Object.assign(st, { workDir: wd, mainRoot: mroot, remoteUrl, provider, pcfg, slug, email, token });
|
|
@@ -483,6 +503,13 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
483
503
|
const sReviewers = async () => {
|
|
484
504
|
screen(1, 'Reviewers');
|
|
485
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
|
+
}
|
|
486
513
|
if (!(st.token && st.email)) { log(' skipped (no token) — edit the roster later.'); st.chosen = []; return; }
|
|
487
514
|
if (st.selfUuid === undefined) st.selfUuid = await fetchSelfUuid(st.email, st.token);
|
|
488
515
|
if (st.revs === undefined) st.revs = await fetchBitbucketReviewers(st.slug, st.email, st.token, st.selfUuid);
|
|
@@ -490,14 +517,18 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
490
517
|
if (!revs) { log(' WARN: could not fetch default-reviewers. Roster left empty.'); st.chosen = []; return; }
|
|
491
518
|
if (revs.length === 0) { log(' repo has no default reviewers. Roster left empty.'); st.chosen = []; return; }
|
|
492
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);
|
|
493
524
|
const picked = await multiSelect(
|
|
494
|
-
`Reviewers for ${st.slug} (default: all):`,
|
|
495
|
-
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,
|
|
496
527
|
);
|
|
497
528
|
if (picked === BACK) return BACK;
|
|
498
529
|
st.chosenIdx = picked;
|
|
499
|
-
st.chosen = picked.map((i) => revs[i]);
|
|
500
|
-
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)` : ''}`);
|
|
501
532
|
};
|
|
502
533
|
|
|
503
534
|
const sPreset = async () => {
|
|
@@ -562,17 +593,28 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
562
593
|
// --- install ---
|
|
563
594
|
screen(4, 'Install');
|
|
564
595
|
const { slug, pcfg, email, convention, conv, chosen } = st;
|
|
565
|
-
|
|
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`;
|
|
566
601
|
if (conv.title_pattern) cfg += `title_pattern=${conv.title_pattern}\n`;
|
|
567
602
|
if (conv.ticket_regex) cfg += `ticket_regex=${conv.ticket_regex}\n`;
|
|
568
603
|
if (conv.template_sections) cfg += `template_sections=${conv.template_sections}\n`;
|
|
569
604
|
writeFile(path.join(HOME, '.config/pr/config'), cfg, 0o600);
|
|
570
|
-
// Roster:
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
|
|
574
|
-
const
|
|
575
|
-
|
|
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
|
+
}
|
|
576
618
|
const binDir = path.join(HOME, '.local/bin');
|
|
577
619
|
const binsToInstall = flags.all
|
|
578
620
|
? Object.values(PROVIDERS).filter((p) => p.ready).map((p) => p.bin)
|
|
@@ -583,7 +625,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
583
625
|
if (!fs.existsSync(src)) { log(` skip ${b} (asset not present yet)`); continue; }
|
|
584
626
|
writeFile(path.join(binDir, b), fs.readFileSync(src), 0o755);
|
|
585
627
|
}
|
|
586
|
-
installSkill(
|
|
628
|
+
installSkill(agentIds);
|
|
587
629
|
if (!(process.env.PATH || '').split(':').includes(binDir)) {
|
|
588
630
|
log(`\n WARN: ${binDir} not on PATH. Add to your shell rc:`);
|
|
589
631
|
log(` export PATH="$HOME/.local/bin:$PATH"`);
|