@nvice-r/pr-commands 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +220 -0
- package/assets/bin/bbpr +78 -0
- package/assets/bin/pr-common.sh +168 -0
- package/assets/skill/pr.md +157 -0
- package/install.js +472 -0
- package/package.json +21 -0
package/README.md
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
# @nvice-r/pr-commands
|
|
2
|
+
|
|
3
|
+
Provider-agnostic pull-request helper for [Claude Code](https://claude.com/claude-code).
|
|
4
|
+
One `/pr` command that drafts a PR title + body in your team's convention, infers the base
|
|
5
|
+
branch, warns on oversized diffs, attaches reviewers, and creates the PR — with the provider
|
|
6
|
+
(Bitbucket / GitHub / GitLab) auto-detected from your git remote.
|
|
7
|
+
|
|
8
|
+
> **Status:** macOS + Bitbucket today. GitHub / GitLab and Linux are stubbed and land without
|
|
9
|
+
> new command files — see [Architecture](#architecture).
|
|
10
|
+
|
|
11
|
+
## Install
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
npx @nvice-r/pr-commands
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The interactive wizard:
|
|
18
|
+
|
|
19
|
+
1. **Environment** — detects OS → token source (macOS keychain).
|
|
20
|
+
2. **Work directory** — the repo to configure. Prompted with the current dir pre-filled and
|
|
21
|
+
editable (or pass `--dir`). Must be a git repo.
|
|
22
|
+
3. **Provider** — detected from `git remote get-url origin`.
|
|
23
|
+
4. **Identity + token** — reads your API token from the keychain (or tells you how to add one).
|
|
24
|
+
5. **Reviewers** — fetches the repo's default reviewers into an interactive checklist
|
|
25
|
+
(`↑/↓` move · `space` toggle · `a` all · `n` none · `b` back · `enter` confirm; all pre-selected).
|
|
26
|
+
6. **Convention** — pick a preset, then review/edit its title-pattern, ticket-regex, and
|
|
27
|
+
template-sections (each pre-filled from the preset, editable). A placeholder legend
|
|
28
|
+
(`<ver>`, `<desc>`, `<ticket>`, …) is shown. Concrete values are written to config — `/pr`
|
|
29
|
+
reads those, not a hard-coded table.
|
|
30
|
+
7. **Install** — writes config + roster, drops the binaries and the `/pr` skill.
|
|
31
|
+
|
|
32
|
+
Every prompt is reversible: in a list press `b` to step back; in a text field, append `:b` to
|
|
33
|
+
the value and press enter (the field is pre-filled, so `:b` at the end triggers back). Back
|
|
34
|
+
navigation is field-level (you can revisit any single convention field).
|
|
35
|
+
|
|
36
|
+
Flags:
|
|
37
|
+
|
|
38
|
+
| flag | effect |
|
|
39
|
+
|------|--------|
|
|
40
|
+
| `--dir <path>` | repo to configure (default: cwd) |
|
|
41
|
+
| `--provider <p>` | force `bitbucket`\|`github`\|`gitlab` instead of detecting |
|
|
42
|
+
| `--all` | install every ready provider binary, not just the detected one |
|
|
43
|
+
| `--yes` | non-interactive: reviewers = all, convention = `trueid` |
|
|
44
|
+
| `--dry-run` | print planned actions, write nothing |
|
|
45
|
+
| `--help` | usage |
|
|
46
|
+
|
|
47
|
+
**Dry-run first** to see exactly what will be written:
|
|
48
|
+
|
|
49
|
+
```bash
|
|
50
|
+
npx @nvice-r/pr-commands --dry-run --dir /path/to/repo
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Prerequisites
|
|
54
|
+
|
|
55
|
+
- **Node ≥ 16** (for the installer only; the installed tools are bash).
|
|
56
|
+
- **`~/.local/bin` on your `PATH`.** The installer warns if it isn't:
|
|
57
|
+
```bash
|
|
58
|
+
export PATH="$HOME/.local/bin:$PATH"
|
|
59
|
+
```
|
|
60
|
+
- **A Bitbucket API token in the keychain** (the installer can't mint secrets):
|
|
61
|
+
```bash
|
|
62
|
+
security add-generic-password -s bitbucket-api-token -a you@example.com -w '<api-token>'
|
|
63
|
+
```
|
|
64
|
+
Without it, install still completes but reviewers aren't auto-fetched.
|
|
65
|
+
|
|
66
|
+
## What gets installed
|
|
67
|
+
|
|
68
|
+
Everything is **global** — nothing lands in a repo's `.claude/`, so it works identically from
|
|
69
|
+
every clone and every git worktree, no symlinks.
|
|
70
|
+
|
|
71
|
+
| Artifact | Path |
|
|
72
|
+
|----------|------|
|
|
73
|
+
| Shared library | `~/.local/bin/pr-common.sh` |
|
|
74
|
+
| Provider binary | `~/.local/bin/bbpr` (`ghpr`/`glpr` later) |
|
|
75
|
+
| Config | `~/.config/pr/config` |
|
|
76
|
+
| Reviewer roster | `<main-repo-root>/.pr-reviewers.txt` (shared across worktrees) |
|
|
77
|
+
| `/pr` skill | `~/.claude/commands/pr.md` |
|
|
78
|
+
|
|
79
|
+
## Usage
|
|
80
|
+
|
|
81
|
+
In Claude Code, from any configured repo:
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
/pr # infer base, draft PR, confirm, create
|
|
85
|
+
/pr feature/2.3.0/center # explicit base branch
|
|
86
|
+
/pr draft # create as draft
|
|
87
|
+
/pr help # help
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`/pr` gathers git context, proposes a title + body in your convention, shows it for
|
|
91
|
+
confirmation, then calls the detected binary. It never creates a PR silently.
|
|
92
|
+
|
|
93
|
+
## Configuration
|
|
94
|
+
|
|
95
|
+
`~/.config/pr/config` (written by the installer):
|
|
96
|
+
|
|
97
|
+
```ini
|
|
98
|
+
os=macos # macos | linux
|
|
99
|
+
token_src=keychain # keychain | secret-tool | env
|
|
100
|
+
email=you@example.com
|
|
101
|
+
convention=trueid # see presets
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
Edit by hand any time. **A missing config is a hard error** — `/pr` and the binaries refuse to
|
|
105
|
+
guess. Re-run the installer to regenerate.
|
|
106
|
+
|
|
107
|
+
### Convention presets
|
|
108
|
+
|
|
109
|
+
| preset | title pattern | ticket regex | template sections |
|
|
110
|
+
|--------|---------------|--------------|-------------------|
|
|
111
|
+
| `trueid` | `[<ver>] Feature/<ver>/SC-XXXX <desc>` | `SC-\d+` | What Happened / Insight / Proof of Work |
|
|
112
|
+
| `conventional` | `<type>(<scope>): <desc>` | `[A-Z]+-\d+` | Summary / Changes / Testing |
|
|
113
|
+
| `github-default` | `<desc> (#<issue>)` | `#\d+` | Summary / Test plan |
|
|
114
|
+
| `custom` | your `title_pattern=` | your `ticket_regex=` | your `template_sections=` (pipe-separated) |
|
|
115
|
+
|
|
116
|
+
`custom` example:
|
|
117
|
+
|
|
118
|
+
```ini
|
|
119
|
+
convention=custom
|
|
120
|
+
title_pattern=<ticket>: <desc>
|
|
121
|
+
ticket_regex=JIRA-\d+
|
|
122
|
+
template_sections=Context|Changes|Risk
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Reviewers
|
|
126
|
+
|
|
127
|
+
Roster lives **in the work dir** at the main repo root, so it's editable next to the code.
|
|
128
|
+
Resolved via `git --git-common-dir/..`, so all worktrees of a repo share one roster:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
<main-repo-root>/.pr-reviewers.txt
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
You (the author) are excluded on install — bbpr also drops self at PR time. One entry per line,
|
|
135
|
+
trailing `# Name` comment optional:
|
|
136
|
+
|
|
137
|
+
```
|
|
138
|
+
{01234567-89ab-cdef-0123-456789abcdef} # Alice
|
|
139
|
+
{fedcba98-7654-3210-fedc-ba9876543210} # Bob
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
It's plain data — edit freely, no reinstall. Consider gitignoring it unless the team wants the
|
|
143
|
+
roster committed. Load order: `$BBPR_REVIEWERS` → work-dir file → global
|
|
144
|
+
`~/.config/pr/reviewers/<ws>-<slug>.txt` → legacy. Run-time overrides: `--no-reviewers` skips
|
|
145
|
+
all, `--reviewer <uuid>` appends.
|
|
146
|
+
|
|
147
|
+
### Auth precedence (email)
|
|
148
|
+
|
|
149
|
+
`--email` flag → `$BB_EMAIL` → `git config bbpr.email` → config `email` → keychain account.
|
|
150
|
+
The token is always read from the keychain (or `$PR_TOKEN` when `token_src=env`).
|
|
151
|
+
|
|
152
|
+
## Provider support
|
|
153
|
+
|
|
154
|
+
| provider | binary | status |
|
|
155
|
+
|----------|--------|--------|
|
|
156
|
+
| Bitbucket Cloud | `bbpr` | ✅ ready |
|
|
157
|
+
| GitHub | `ghpr` | 🚧 stub — errors `not-yet-supported` |
|
|
158
|
+
| GitLab | `glpr` | 🚧 stub |
|
|
159
|
+
|
|
160
|
+
| OS | token source | status |
|
|
161
|
+
|----|--------------|--------|
|
|
162
|
+
| macOS | keychain | ✅ ready |
|
|
163
|
+
| Linux | `secret-tool` / `$PR_TOKEN` | 🚧 present, untested |
|
|
164
|
+
|
|
165
|
+
## Architecture
|
|
166
|
+
|
|
167
|
+
- **Split by provider, not by OS.** Each provider gets its own small binary (`bbpr`/`ghpr`/`glpr`)
|
|
168
|
+
because the REST APIs genuinely differ. OS differences (token retrieval) are a single
|
|
169
|
+
`get_token()` function switched by `uname`, not a separate binary. Ceiling: 3 binaries, never
|
|
170
|
+
3 × OS.
|
|
171
|
+
- **Shared logic in `pr-common.sh`** — git scan, base save, reviewer load, arg parse, token —
|
|
172
|
+
sourced by every binary. A provider binary is ~40 lines: payload shape + endpoint + parse.
|
|
173
|
+
- **One generic `/pr` skill.** Convention lives in config, not the skill, so the skill is
|
|
174
|
+
provider- and team-neutral and installs globally. Adding a provider = one binary + one dispatch
|
|
175
|
+
line, zero new skill files.
|
|
176
|
+
|
|
177
|
+
Full rationale in [`DECISIONS.md`](./DECISIONS.md).
|
|
178
|
+
|
|
179
|
+
## Troubleshooting
|
|
180
|
+
|
|
181
|
+
| symptom | fix |
|
|
182
|
+
|---------|-----|
|
|
183
|
+
| `no ~/.config/pr/config` | run `npx @nvice-r/pr-commands` |
|
|
184
|
+
| reviewers not fetched | add the keychain token (see Prerequisites), re-run |
|
|
185
|
+
| `WARN ~/.local/bin not on PATH` | add the export line above to your shell rc |
|
|
186
|
+
| `not-yet-supported: <provider>` | GitHub/GitLab aren't implemented yet |
|
|
187
|
+
| worktree runs old behavior | nothing is repo-local here; ensure `pr.md` is at `~/.claude/commands/pr.md`, not a stale repo copy |
|
|
188
|
+
|
|
189
|
+
## Uninstall
|
|
190
|
+
|
|
191
|
+
```bash
|
|
192
|
+
rm ~/.local/bin/bbpr ~/.local/bin/pr-common.sh
|
|
193
|
+
rm -rf ~/.config/pr
|
|
194
|
+
rm ~/.claude/commands/pr.md
|
|
195
|
+
# roster lives in each repo — remove per repo if wanted:
|
|
196
|
+
rm <main-repo-root>/.pr-reviewers.txt
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Development
|
|
200
|
+
|
|
201
|
+
```
|
|
202
|
+
install.js npx entry (wizard)
|
|
203
|
+
assets/bin/* provider binaries + shared lib (source of truth; copied on install)
|
|
204
|
+
assets/skill/pr.md generic /pr skill (source of truth; copied on install)
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
`assets/` is the single source of truth — both the installer (`install.js`)
|
|
208
|
+
and the published package (`files` in `package.json`) read from it. No duplicate
|
|
209
|
+
tree to keep in sync.
|
|
210
|
+
|
|
211
|
+
Publish (owner only):
|
|
212
|
+
|
|
213
|
+
```bash
|
|
214
|
+
npm login
|
|
215
|
+
npm publish --access public
|
|
216
|
+
```
|
|
217
|
+
|
|
218
|
+
## License
|
|
219
|
+
|
|
220
|
+
MIT © nvice.r
|
package/assets/bin/bbpr
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# bbpr-version: 8
|
|
3
|
+
# bbpr — create a Bitbucket Cloud pull request (REST API v2.0).
|
|
4
|
+
# Provider-specific layer; all git/config/creds/reviewer logic lives in pr-common.sh.
|
|
5
|
+
# Usage: bbpr --title "..." [--body|--body-file] [--source b] [--dest b] [--repo w/s]
|
|
6
|
+
# [--draft] [--reviewer uuid]... [--no-reviewers] [--email a] [--close-source]
|
|
7
|
+
|
|
8
|
+
set -euo pipefail
|
|
9
|
+
|
|
10
|
+
PR_CMD="bbpr"
|
|
11
|
+
KEYCHAIN_SERVICE="bitbucket-api-token"
|
|
12
|
+
|
|
13
|
+
# locate + source the shared lib (same dir as this binary)
|
|
14
|
+
_here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
15
|
+
# shellcheck source=pr-common.sh
|
|
16
|
+
. "$_here/pr-common.sh"
|
|
17
|
+
|
|
18
|
+
pr_common_init "$@"
|
|
19
|
+
|
|
20
|
+
# --- Bitbucket: drop the PR author's own UUID (BB rejects author-as-reviewer) ---
|
|
21
|
+
if [ "${#FINAL_REVIEWERS[@]}" -gt 0 ]; then
|
|
22
|
+
SELF_UUID="$(curl -sS -u "${EMAIL}:${TOKEN}" "https://api.bitbucket.org/2.0/user" 2>/dev/null \
|
|
23
|
+
| python3 -c 'import json,sys
|
|
24
|
+
try: print(json.load(sys.stdin).get("uuid",""))
|
|
25
|
+
except Exception: print("")' 2>/dev/null || true)"
|
|
26
|
+
if [ -n "$SELF_UUID" ]; then
|
|
27
|
+
FILTERED=()
|
|
28
|
+
for r in "${FINAL_REVIEWERS[@]}"; do
|
|
29
|
+
[ "$r" = "$SELF_UUID" ] || FILTERED+=("$r")
|
|
30
|
+
done
|
|
31
|
+
FINAL_REVIEWERS=("${FILTERED[@]:-}")
|
|
32
|
+
[ -z "${FINAL_REVIEWERS[0]:-}" ] && FINAL_REVIEWERS=()
|
|
33
|
+
fi
|
|
34
|
+
fi
|
|
35
|
+
|
|
36
|
+
# --- Bitbucket payload (source/destination keys, draft, reviewers[]) ---
|
|
37
|
+
PAYLOAD="$(
|
|
38
|
+
TITLE="$TITLE" BODY="$BODY" SOURCE="$SOURCE" DEST="$DEST" \
|
|
39
|
+
DRAFT="$DRAFT" CLOSE_SOURCE="$CLOSE_SOURCE" \
|
|
40
|
+
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
|
+
python3 -c '
|
|
42
|
+
import json, os
|
|
43
|
+
p = {
|
|
44
|
+
"title": os.environ["TITLE"],
|
|
45
|
+
"description": os.environ["BODY"],
|
|
46
|
+
"source": {"branch": {"name": os.environ["SOURCE"]}},
|
|
47
|
+
"destination": {"branch": {"name": os.environ["DEST"]}},
|
|
48
|
+
"draft": os.environ["DRAFT"] == "true",
|
|
49
|
+
"close_source_branch": os.environ["CLOSE_SOURCE"] == "true",
|
|
50
|
+
}
|
|
51
|
+
revs = json.loads(os.environ["REVIEWERS_JSON"])
|
|
52
|
+
if revs: p["reviewers"] = revs
|
|
53
|
+
print(json.dumps(p))
|
|
54
|
+
'
|
|
55
|
+
)"
|
|
56
|
+
|
|
57
|
+
URL="https://api.bitbucket.org/2.0/repositories/${REPO}/pullrequests"
|
|
58
|
+
echo "POST $URL"
|
|
59
|
+
echo "source=$SOURCE dest=$DEST draft=$DRAFT reviewers=${#FINAL_REVIEWERS[@]}"
|
|
60
|
+
|
|
61
|
+
RESP="$(curl -sS -X POST "$URL" -u "${EMAIL}:${TOKEN}" \
|
|
62
|
+
-H 'Content-Type: application/json' -d "$PAYLOAD")"
|
|
63
|
+
|
|
64
|
+
PR_INFO="$(echo "$RESP" | python3 -c '
|
|
65
|
+
import json, sys
|
|
66
|
+
try: d = json.loads(sys.stdin.read())
|
|
67
|
+
except Exception as e: print("non-JSON response", e); sys.exit(2)
|
|
68
|
+
if "error" in d: print("ERROR:", json.dumps(d["error"], indent=2)); sys.exit(1)
|
|
69
|
+
print(str(d.get("id")) + "\t" + str(d.get("links", {}).get("html", {}).get("href")))
|
|
70
|
+
')"
|
|
71
|
+
|
|
72
|
+
NEW_PR_ID="$(echo "$PR_INFO" | cut -f1)"
|
|
73
|
+
NEW_PR_URL="$(echo "$PR_INFO" | cut -f2)"
|
|
74
|
+
ATTACHED="$(echo "$RESP" | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get("reviewers", [])))')"
|
|
75
|
+
echo "created PR #${NEW_PR_ID} with ${ATTACHED} reviewers"
|
|
76
|
+
echo "$NEW_PR_URL"
|
|
77
|
+
|
|
78
|
+
[ -n "$NEW_PR_ID" ] && pr_save_base
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# pr-common-version: 1
|
|
3
|
+
# pr-common.sh — shared library for provider PR binaries (bbpr/ghpr/glpr).
|
|
4
|
+
# NOT executable on its own — sourced by a provider binary, which then builds the
|
|
5
|
+
# provider-specific payload + POST. Everything here is provider-AGNOSTIC.
|
|
6
|
+
#
|
|
7
|
+
# Provides (after `pr_common_init "$@"`): global vars
|
|
8
|
+
# TITLE BODY SOURCE DEST REPO EMAIL TOKEN DRAFT CLOSE_SOURCE
|
|
9
|
+
# FINAL_REVIEWERS[] (uuids, self already NOT dropped — provider does that)
|
|
10
|
+
# Provides functions:
|
|
11
|
+
# die, get_token, pr_load_config, pr_parse_args, pr_git_defaults,
|
|
12
|
+
# pr_resolve_creds, pr_load_reviewers, pr_save_base, pr_common_init
|
|
13
|
+
#
|
|
14
|
+
# Config: ~/.config/pr/config (KEY=value). HARD FAIL if missing (D8).
|
|
15
|
+
|
|
16
|
+
set -euo pipefail
|
|
17
|
+
|
|
18
|
+
CONFIG_FILE="${PR_CONFIG:-$HOME/.config/pr/config}"
|
|
19
|
+
|
|
20
|
+
die() { echo "${PR_CMD:-pr}: $*" >&2; exit 1; }
|
|
21
|
+
|
|
22
|
+
# --- config load (safe parse, no `source` of untrusted file) ---
|
|
23
|
+
# Sets: CFG_OS CFG_TOKEN_SRC CFG_EMAIL CFG_CONVENTION (+ optional custom_* keys)
|
|
24
|
+
pr_load_config() {
|
|
25
|
+
[ -f "$CONFIG_FILE" ] || die "no $CONFIG_FILE — run: npx @nvice-r/pr-commands"
|
|
26
|
+
local k v
|
|
27
|
+
CFG_OS=""; CFG_TOKEN_SRC=""; CFG_EMAIL=""; CFG_CONVENTION=""
|
|
28
|
+
while IFS='=' read -r k v; do
|
|
29
|
+
k="${k%%#*}"; k="${k//[[:space:]]/}" # strip comment + whitespace from key
|
|
30
|
+
[ -z "$k" ] && continue
|
|
31
|
+
v="${v%%#*}"; v="${v#"${v%%[![:space:]]*}"}"; v="${v%"${v##*[![:space:]]}"}" # trim v
|
|
32
|
+
case "$k" in
|
|
33
|
+
os) CFG_OS="$v" ;;
|
|
34
|
+
token_src) CFG_TOKEN_SRC="$v" ;;
|
|
35
|
+
email) CFG_EMAIL="$v" ;;
|
|
36
|
+
convention) CFG_CONVENTION="$v" ;;
|
|
37
|
+
esac
|
|
38
|
+
done < "$CONFIG_FILE"
|
|
39
|
+
: "${CFG_TOKEN_SRC:=keychain}"
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
# --- token retrieval, OS-switched (D1: OS is a function, not a binary axis) ---
|
|
43
|
+
# arg1 = email. Echoes token or dies.
|
|
44
|
+
get_token() {
|
|
45
|
+
local email="$1"
|
|
46
|
+
case "$CFG_TOKEN_SRC" in
|
|
47
|
+
keychain)
|
|
48
|
+
security find-generic-password -s "$KEYCHAIN_SERVICE" -a "$email" -w 2>/dev/null \
|
|
49
|
+
|| die "no keychain entry for service=$KEYCHAIN_SERVICE account=$email" ;;
|
|
50
|
+
secret-tool) # Linux (libsecret)
|
|
51
|
+
secret-tool lookup service "$KEYCHAIN_SERVICE" account "$email" 2>/dev/null \
|
|
52
|
+
|| die "no secret-tool entry for service=$KEYCHAIN_SERVICE account=$email" ;;
|
|
53
|
+
env)
|
|
54
|
+
[ -n "${PR_TOKEN:-}" ] || die "token_src=env but \$PR_TOKEN is empty"
|
|
55
|
+
printf '%s' "$PR_TOKEN" ;;
|
|
56
|
+
*) die "not-yet-supported: token_src=$CFG_TOKEN_SRC" ;;
|
|
57
|
+
esac
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
# --- arg parse (common flags across all providers) ---
|
|
61
|
+
pr_parse_args() {
|
|
62
|
+
TITLE=""; BODY=""; BODY_FILE=""; SOURCE=""; DEST=""; REPO=""
|
|
63
|
+
DRAFT="false"; CLOSE_SOURCE="false"; NO_REVIEWERS="false"; EMAIL_ARG=""
|
|
64
|
+
EXTRA_REVIEWERS=()
|
|
65
|
+
while [ $# -gt 0 ]; do
|
|
66
|
+
case "$1" in
|
|
67
|
+
--title) TITLE="$2"; shift 2 ;;
|
|
68
|
+
--body) BODY="$2"; shift 2 ;;
|
|
69
|
+
--body-file) BODY_FILE="$2"; shift 2 ;;
|
|
70
|
+
--source) SOURCE="$2"; shift 2 ;;
|
|
71
|
+
--dest|--target) DEST="$2"; shift 2 ;;
|
|
72
|
+
--repo) REPO="$2"; shift 2 ;;
|
|
73
|
+
--draft) DRAFT="true"; shift ;;
|
|
74
|
+
--close-source) CLOSE_SOURCE="true"; shift ;;
|
|
75
|
+
--reviewer) EXTRA_REVIEWERS+=("$2"); shift 2 ;;
|
|
76
|
+
--no-reviewers) NO_REVIEWERS="true"; shift ;;
|
|
77
|
+
--email) EMAIL_ARG="$2"; shift 2 ;;
|
|
78
|
+
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
|
|
79
|
+
*) die "unknown flag: $1" ;;
|
|
80
|
+
esac
|
|
81
|
+
done
|
|
82
|
+
[ -n "$TITLE" ] || die "--title required"
|
|
83
|
+
if [ -n "$BODY_FILE" ]; then
|
|
84
|
+
[ -f "$BODY_FILE" ] || die "body file not found: $BODY_FILE"
|
|
85
|
+
BODY="$(cat "$BODY_FILE")"
|
|
86
|
+
fi
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
# --- git-derived defaults: SOURCE, DEST, REPO ---
|
|
90
|
+
# REPO slug parse is generic (strip host + .git) — works for all providers.
|
|
91
|
+
pr_git_defaults() {
|
|
92
|
+
if [ -z "$SOURCE" ]; then
|
|
93
|
+
SOURCE="$(git rev-parse --abbrev-ref HEAD)"
|
|
94
|
+
[ "$SOURCE" = "HEAD" ] && die "detached HEAD — pass --source"
|
|
95
|
+
fi
|
|
96
|
+
if [ -z "$DEST" ]; then
|
|
97
|
+
DEST="$(git config --get "branch.${SOURCE}.bbprBase" 2>/dev/null || true)"
|
|
98
|
+
[ -n "$DEST" ] && echo "${PR_CMD:-pr}: using saved base (branch.${SOURCE}.bbprBase = ${DEST})" >&2
|
|
99
|
+
fi
|
|
100
|
+
[ -n "$DEST" ] || die "--dest required (no saved branch.${SOURCE}.bbprBase)"
|
|
101
|
+
if [ -z "$REPO" ]; then
|
|
102
|
+
local url
|
|
103
|
+
url="$(git remote get-url origin 2>/dev/null)" || die "no origin remote"
|
|
104
|
+
# generic: drop scp-style git@HOST: or https://HOST/ prefix and trailing .git
|
|
105
|
+
REPO="$(echo "$url" | sed -E 's#^git@[^:]+:##; s#^https?://[^/]+/##; s#\.git$##')"
|
|
106
|
+
[[ "$REPO" == */* ]] || die "could not parse repo from $url"
|
|
107
|
+
fi
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
# --- creds: email precedence + token via OS ---
|
|
111
|
+
pr_resolve_creds() {
|
|
112
|
+
# --email > $BB_EMAIL > git config bbpr.email > config email > keychain acct
|
|
113
|
+
EMAIL="$EMAIL_ARG"
|
|
114
|
+
[ -n "$EMAIL" ] || EMAIL="${BB_EMAIL:-}"
|
|
115
|
+
[ -n "$EMAIL" ] || EMAIL="$(git config bbpr.email 2>/dev/null || true)"
|
|
116
|
+
[ -n "$EMAIL" ] || EMAIL="${CFG_EMAIL:-}"
|
|
117
|
+
[ -n "$EMAIL" ] || die "no email resolved (set config email or pass --email)"
|
|
118
|
+
TOKEN="$(get_token "$EMAIL")"
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
# --- reviewer roster: work-dir file (shared across worktrees) then global fallbacks ---
|
|
122
|
+
pr_load_reviewers() {
|
|
123
|
+
DEFAULT_REVIEWERS=()
|
|
124
|
+
local file="${BBPR_REVIEWERS:-}"
|
|
125
|
+
if [ -z "$file" ]; then
|
|
126
|
+
local slug; slug="$(echo "$REPO" | tr '/' '-')" # workspace/slug -> workspace-slug
|
|
127
|
+
# main repo root, shared across worktrees: git-common-dir/.. (empty if git call fails)
|
|
128
|
+
local mainroot; mainroot="$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." 2>/dev/null && pwd)"
|
|
129
|
+
for cand in \
|
|
130
|
+
${mainroot:+"$mainroot/.pr-reviewers.txt"} \
|
|
131
|
+
"$HOME/.config/pr/reviewers/${slug}.txt" \
|
|
132
|
+
"$HOME/.config/bbpr/bbpr-reviewers.txt"; do # legacy fallback
|
|
133
|
+
[ -f "$cand" ] && { file="$cand"; break; }
|
|
134
|
+
done
|
|
135
|
+
fi
|
|
136
|
+
if [ -n "$file" ] && [ -f "$file" ]; then
|
|
137
|
+
local line uuid
|
|
138
|
+
while IFS= read -r line; do
|
|
139
|
+
uuid="${line%%#*}"; uuid="${uuid//[[:space:]]/}"
|
|
140
|
+
[ -n "$uuid" ] && DEFAULT_REVIEWERS+=("$uuid")
|
|
141
|
+
done < "$file"
|
|
142
|
+
else
|
|
143
|
+
echo "${PR_CMD:-pr}: WARN no reviewer roster for $REPO — none attached" >&2
|
|
144
|
+
fi
|
|
145
|
+
# merge defaults + --reviewer extras unless --no-reviewers
|
|
146
|
+
FINAL_REVIEWERS=()
|
|
147
|
+
if [ "$NO_REVIEWERS" != "true" ]; then
|
|
148
|
+
[ "${#DEFAULT_REVIEWERS[@]}" -gt 0 ] && FINAL_REVIEWERS+=("${DEFAULT_REVIEWERS[@]}")
|
|
149
|
+
[ "${#EXTRA_REVIEWERS[@]}" -gt 0 ] && FINAL_REVIEWERS+=("${EXTRA_REVIEWERS[@]}")
|
|
150
|
+
fi
|
|
151
|
+
return 0 # never let a false &&-guard above abort the caller under `set -e`
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# --- persist resolved base for next run ---
|
|
155
|
+
pr_save_base() {
|
|
156
|
+
git config "branch.${SOURCE}.bbprBase" "$DEST" 2>/dev/null \
|
|
157
|
+
&& echo "${PR_CMD:-pr}: saved branch.${SOURCE}.bbprBase = ${DEST}" >&2
|
|
158
|
+
return 0 # base-save is best-effort; never abort the binary on failure
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
# --- orchestrate everything a provider needs before its POST ---
|
|
162
|
+
pr_common_init() {
|
|
163
|
+
pr_load_config
|
|
164
|
+
pr_parse_args "$@"
|
|
165
|
+
pr_git_defaults
|
|
166
|
+
pr_resolve_creds
|
|
167
|
+
pr_load_reviewers
|
|
168
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
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.
|
package/install.js
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @nvice-r/pr-commands installer. Zero external deps (Node >=16 builtins only).
|
|
3
|
+
// Wizard: detect OS + provider, pick reviewers + convention, write config/roster,
|
|
4
|
+
// install binaries -> ~/.local/bin, copy /pr skill -> ~/.claude/commands.
|
|
5
|
+
//
|
|
6
|
+
// Flags: --dir <path> --provider <p> --all --yes --dry-run --help
|
|
7
|
+
|
|
8
|
+
'use strict';
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const os = require('os');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const https = require('https');
|
|
13
|
+
const readline = require('readline');
|
|
14
|
+
const { execFileSync } = require('child_process');
|
|
15
|
+
|
|
16
|
+
const HOME = os.homedir();
|
|
17
|
+
const PKG = __dirname;
|
|
18
|
+
const ASSETS = path.join(PKG, 'assets');
|
|
19
|
+
const BACK = Symbol('back'); // returned by a prompt when the user wants the previous step
|
|
20
|
+
|
|
21
|
+
const PROVIDERS = {
|
|
22
|
+
bitbucket: { bin: 'bbpr', host: 'bitbucket.org', keychainSvc: 'bitbucket-api-token', ready: true },
|
|
23
|
+
github: { bin: 'ghpr', host: 'github.com', keychainSvc: 'github-api-token', ready: false },
|
|
24
|
+
gitlab: { bin: 'glpr', host: 'gitlab.com', keychainSvc: 'gitlab-api-token', ready: false },
|
|
25
|
+
};
|
|
26
|
+
const CONVENTIONS = ['trueid', 'conventional', 'github-default', 'custom'];
|
|
27
|
+
// Concrete, editable defaults per preset. Whatever the user confirms is written to config,
|
|
28
|
+
// so `custom` is just "a preset with blank seeds" — same edit flow for every choice.
|
|
29
|
+
const PRESET_DEFS = {
|
|
30
|
+
trueid: { title_pattern: '[<ver>] Feature/<ver>/SC-XXXX <desc>', ticket_regex: 'SC-\\d+', template_sections: 'What Happened|Insight|Proof of Work' },
|
|
31
|
+
conventional: { title_pattern: '<type>(<scope>): <desc>', ticket_regex: '[A-Z]+-\\d+', template_sections: 'Summary|Changes|Testing' },
|
|
32
|
+
'github-default': { title_pattern: '<desc> (#<issue>)', ticket_regex: '#\\d+', template_sections: 'Summary|Test plan' },
|
|
33
|
+
custom: { title_pattern: '', ticket_regex: '', template_sections: '' },
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
// ---- arg parse ----
|
|
37
|
+
const argv = process.argv.slice(2);
|
|
38
|
+
const flags = { dir: null, provider: null, all: false, yes: false, dryRun: false };
|
|
39
|
+
for (let i = 0; i < argv.length; i++) {
|
|
40
|
+
const a = argv[i];
|
|
41
|
+
if (a === '--help' || a === '-h') { printHelp(); process.exit(0); }
|
|
42
|
+
else if (a === '--dir') flags.dir = argv[++i];
|
|
43
|
+
else if (a === '--provider') flags.provider = argv[++i];
|
|
44
|
+
else if (a === '--all') flags.all = true;
|
|
45
|
+
else if (a === '--yes' || a === '-y') flags.yes = true;
|
|
46
|
+
else if (a === '--dry-run') flags.dryRun = true;
|
|
47
|
+
else { console.error(`unknown flag: ${a}`); process.exit(1); }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function printHelp() {
|
|
51
|
+
console.log(`@nvice-r/pr-commands installer
|
|
52
|
+
|
|
53
|
+
npx @nvice-r/pr-commands [flags]
|
|
54
|
+
|
|
55
|
+
--dir <path> Repo to configure (default: cwd)
|
|
56
|
+
--provider <p> Force bitbucket|github|gitlab (default: detect from remote)
|
|
57
|
+
--all Install all provider binaries (default: only the detected one)
|
|
58
|
+
--yes Accept defaults, no prompts (reviewers=all, convention=trueid)
|
|
59
|
+
--dry-run Print planned actions, write nothing
|
|
60
|
+
--help This help`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---- helpers ----
|
|
64
|
+
// Each text prompt gets its OWN terminal-mode readline, closed right after. Never share one
|
|
65
|
+
// readline across prompts + raw-mode selectors — the terminal modes corrupt each other
|
|
66
|
+
// (arrows leak into the line as literal ^[[A escape codes after a selector runs).
|
|
67
|
+
const ask = (q) => new Promise((res) => {
|
|
68
|
+
const r = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
69
|
+
r.question(q, (a) => { r.close(); res(a.trim()); });
|
|
70
|
+
});
|
|
71
|
+
// Editable pre-filled default: edit the shown value or hit enter to keep it.
|
|
72
|
+
// `:b` (or `:back`) returns BACK; empty keeps the default.
|
|
73
|
+
const askPrefill = (q, def, allowBack = true) => new Promise((res) => {
|
|
74
|
+
const r = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
|
75
|
+
r.question(q, (a) => {
|
|
76
|
+
r.close();
|
|
77
|
+
const t = a.trim();
|
|
78
|
+
// value is pre-filled, so `:b` typed at the end appends → match as a suffix, not just equality
|
|
79
|
+
if (allowBack && (t.endsWith(':b') || t.endsWith(':back'))) return res(BACK);
|
|
80
|
+
res((t || def || '').trim());
|
|
81
|
+
});
|
|
82
|
+
if (def && process.stdin.isTTY) r.write(def); // inject editable text
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Interactive checklist (↑/↓ move · space toggle · a all · n none · enter confirm).
|
|
86
|
+
// Zero-dep raw-mode TTY. Returns array of selected indices. items: [{label}].
|
|
87
|
+
// allDefault=true pre-selects everything. Falls back to select-all on a non-TTY.
|
|
88
|
+
function multiSelect(title, items, allDefault = true, preIdx = null) {
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
if (!process.stdin.isTTY || items.length === 0) {
|
|
91
|
+
resolve(preIdx || (allDefault ? items.map((_, i) => i) : []));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const sel = items.map((_, i) => (preIdx ? preIdx.includes(i) : allDefault));
|
|
95
|
+
let cur = 0, top = 0;
|
|
96
|
+
const VIEW = Math.min(items.length, 20); // visible rows; longer lists scroll/page through this window (tune this)
|
|
97
|
+
const LINES = VIEW + 3; // title + VIEW rows + counter + hint (fixed → cursor-up math stays correct)
|
|
98
|
+
const out = process.stdout;
|
|
99
|
+
const render = (first) => {
|
|
100
|
+
if (cur < top) top = cur; // scrolled above window → page up
|
|
101
|
+
else if (cur >= top + VIEW) top = cur - VIEW + 1; // scrolled below → page down
|
|
102
|
+
if (!first) out.write(`\x1b[${LINES}F`); // up LINES, to col 0
|
|
103
|
+
out.write(`\x1b[J`); // clear from cursor down
|
|
104
|
+
out.write(`\r\x1b[K ${title}\n`);
|
|
105
|
+
for (let r = 0; r < VIEW; r++) {
|
|
106
|
+
const i = top + r;
|
|
107
|
+
const it = items[i];
|
|
108
|
+
const box = sel[i] ? '[x]' : '[ ]';
|
|
109
|
+
const arrow = i === cur ? '\x1b[36m>\x1b[0m' : ' ';
|
|
110
|
+
const lbl = i === cur ? `\x1b[36m${it.label}\x1b[0m` : it.label;
|
|
111
|
+
out.write(`\r\x1b[K ${arrow} ${box} ${lbl}\n`);
|
|
112
|
+
}
|
|
113
|
+
const nsel = sel.filter(Boolean).length;
|
|
114
|
+
const range = items.length > VIEW ? `${top + 1}-${Math.min(top + VIEW, items.length)}/${items.length}` : `${items.length}`;
|
|
115
|
+
out.write(`\r\x1b[K \x1b[2m${range} shown · ${nsel} selected\x1b[0m\n`);
|
|
116
|
+
out.write(`\r\x1b[K \x1b[2m↑/↓ move · space toggle · a all · n none · b back · enter confirm\x1b[0m\n`);
|
|
117
|
+
};
|
|
118
|
+
process.stdin.setRawMode(true);
|
|
119
|
+
process.stdin.resume();
|
|
120
|
+
render(true);
|
|
121
|
+
const onData = (buf) => {
|
|
122
|
+
const k = buf.toString();
|
|
123
|
+
if (k === '\x03') { cleanup(); process.exit(130); } // ctrl-c
|
|
124
|
+
else if (k === 'b') { cleanup(); resolve(BACK); return; } // b = back (NOT esc — esc is the arrow-key prefix)
|
|
125
|
+
else if (k === '\x1b[A' || k === 'k') cur = (cur - 1 + items.length) % items.length;
|
|
126
|
+
else if (k === '\x1b[B' || k === 'j') cur = (cur + 1) % items.length;
|
|
127
|
+
else if (k === ' ') sel[cur] = !sel[cur];
|
|
128
|
+
else if (k === 'a') sel.fill(true);
|
|
129
|
+
else if (k === 'n') sel.fill(false);
|
|
130
|
+
else if (k === '\r' || k === '\n') {
|
|
131
|
+
cleanup();
|
|
132
|
+
resolve(items.map((_, i) => i).filter((i) => sel[i]));
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
render(false);
|
|
136
|
+
};
|
|
137
|
+
function cleanup() {
|
|
138
|
+
process.stdin.removeListener('data', onData);
|
|
139
|
+
process.stdin.setRawMode(false);
|
|
140
|
+
process.stdin.pause();
|
|
141
|
+
}
|
|
142
|
+
process.stdin.on('data', onData);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Single-select list (↑/↓ move · enter pick). Returns chosen index. Non-TTY → def.
|
|
147
|
+
function multiSelectOne(title, labels, def = 0) {
|
|
148
|
+
return new Promise((resolve) => {
|
|
149
|
+
if (!process.stdin.isTTY || labels.length === 0) { resolve(def); return; }
|
|
150
|
+
let cur = def;
|
|
151
|
+
const out = process.stdout;
|
|
152
|
+
const render = (first) => {
|
|
153
|
+
if (!first) out.write(`\x1b[${labels.length + 2}F`);
|
|
154
|
+
out.write(`\x1b[J`);
|
|
155
|
+
out.write(`\r\x1b[K ${title}\n`);
|
|
156
|
+
labels.forEach((l, i) => {
|
|
157
|
+
const arrow = i === cur ? '\x1b[36m>\x1b[0m' : ' ';
|
|
158
|
+
out.write(`\r\x1b[K ${arrow} ${i === cur ? `\x1b[36m${l}\x1b[0m` : l}\n`);
|
|
159
|
+
});
|
|
160
|
+
out.write(`\r\x1b[K \x1b[2m↑/↓ move · b back · enter select\x1b[0m\n`);
|
|
161
|
+
};
|
|
162
|
+
process.stdin.setRawMode(true); process.stdin.resume(); render(true);
|
|
163
|
+
const onData = (buf) => {
|
|
164
|
+
const k = buf.toString();
|
|
165
|
+
if (k === '\x03') { cleanup(); process.exit(130); }
|
|
166
|
+
else if (k === 'b') { cleanup(); resolve(BACK); return; } // b = back (NOT esc — esc is the arrow-key prefix)
|
|
167
|
+
else if (k === '\x1b[A' || k === 'k') cur = (cur - 1 + labels.length) % labels.length;
|
|
168
|
+
else if (k === '\x1b[B' || k === 'j') cur = (cur + 1) % labels.length;
|
|
169
|
+
else if (k === '\r' || k === '\n') { cleanup(); resolve(cur); return; }
|
|
170
|
+
render(false);
|
|
171
|
+
};
|
|
172
|
+
function cleanup() {
|
|
173
|
+
process.stdin.removeListener('data', onData);
|
|
174
|
+
process.stdin.setRawMode(false); process.stdin.pause();
|
|
175
|
+
}
|
|
176
|
+
process.stdin.on('data', onData);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function log(...a) { console.log(...a); }
|
|
181
|
+
function printLegend() {
|
|
182
|
+
console.log(' Placeholders (used in title_pattern; filled by /pr from the branch):');
|
|
183
|
+
console.log(' \x1b[2m<ver> branch version e.g. 2.3.0\x1b[0m');
|
|
184
|
+
console.log(' \x1b[2m<desc> description from branch suffix / commits\x1b[0m');
|
|
185
|
+
console.log(' \x1b[2m<ticket> ticket key matched by ticket_regex, e.g. SC-5424\x1b[0m');
|
|
186
|
+
console.log(' \x1b[2m<type> conventional type feat | fix | chore | ...\x1b[0m');
|
|
187
|
+
console.log(' \x1b[2m<scope> conventional scope e.g. auth\x1b[0m');
|
|
188
|
+
console.log(' \x1b[2m<issue> issue number e.g. 42\x1b[0m');
|
|
189
|
+
console.log(' \x1b[2menter keeps shown value · append :b then enter to go back\x1b[0m\n');
|
|
190
|
+
}
|
|
191
|
+
function sh(cmd, args, opts = {}) {
|
|
192
|
+
return execFileSync(cmd, args, { encoding: 'utf8', ...opts }).trim();
|
|
193
|
+
}
|
|
194
|
+
function shSafe(cmd, args, opts = {}) {
|
|
195
|
+
// probe variant: swallow the child's stderr (e.g. git's "fatal: not a git repository")
|
|
196
|
+
try { return sh(cmd, args, { stdio: ['ignore', 'pipe', 'ignore'], ...opts }); } catch { return null; }
|
|
197
|
+
}
|
|
198
|
+
function writeFile(p, content, mode) {
|
|
199
|
+
if (flags.dryRun) { log(` [dry-run] write ${p}`); return; }
|
|
200
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
201
|
+
fs.writeFileSync(p, content);
|
|
202
|
+
if (mode) fs.chmodSync(p, mode);
|
|
203
|
+
log(` wrote ${p}`);
|
|
204
|
+
}
|
|
205
|
+
// Main repo root, shared across worktrees: git-common-dir points at the main .git
|
|
206
|
+
// even from a linked worktree; its parent is the main checkout root. gitCommonDir
|
|
207
|
+
// may be relative ('.git') or absolute ('/main/.git') — path.resolve handles both.
|
|
208
|
+
function mainRepoRoot(wd) {
|
|
209
|
+
const gcd = shSafe('git', ['-C', wd, 'rev-parse', '--git-common-dir']);
|
|
210
|
+
return gcd ? path.resolve(wd, gcd, '..') : null;
|
|
211
|
+
}
|
|
212
|
+
// Depth-limited scan for git repos beneath a non-repo dir. Stops descending once a
|
|
213
|
+
// repo is found (no nested-repo noise); skips dotdirs + node_modules; caps results.
|
|
214
|
+
function scanGitRepos(root, maxDepth = 3, cap = 50) {
|
|
215
|
+
const found = [];
|
|
216
|
+
const walk = (dir, depth) => {
|
|
217
|
+
if (depth > maxDepth || found.length >= cap) return;
|
|
218
|
+
let ents;
|
|
219
|
+
try { ents = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
|
|
220
|
+
if (ents.some((e) => e.name === '.git')) { found.push(dir); return; } // this IS a repo
|
|
221
|
+
for (const e of ents) {
|
|
222
|
+
if (!e.isDirectory() || e.name === 'node_modules' || e.name.startsWith('.')) continue;
|
|
223
|
+
walk(path.join(dir, e.name), depth + 1);
|
|
224
|
+
}
|
|
225
|
+
};
|
|
226
|
+
walk(root, 0);
|
|
227
|
+
return found;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// ---- provider detection ----
|
|
231
|
+
function detectProvider(remoteUrl) {
|
|
232
|
+
if (flags.provider) return flags.provider;
|
|
233
|
+
for (const [name, cfg] of Object.entries(PROVIDERS)) {
|
|
234
|
+
if (remoteUrl && remoteUrl.includes(cfg.host)) return name;
|
|
235
|
+
}
|
|
236
|
+
return null;
|
|
237
|
+
}
|
|
238
|
+
function parseSlug(remoteUrl) {
|
|
239
|
+
return remoteUrl
|
|
240
|
+
.replace(/^git@[^:]+:/, '')
|
|
241
|
+
.replace(/^https?:\/\/[^/]+\//, '')
|
|
242
|
+
.replace(/\.git$/, '');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ---- keychain (macOS) ----
|
|
246
|
+
function keychainAccounts(svc) {
|
|
247
|
+
if (os.platform() !== 'darwin') return [];
|
|
248
|
+
// dump-keychain is noisy + may prompt; instead try find-generic-password for the service
|
|
249
|
+
const out = shSafe('security', ['find-generic-password', '-s', svc]);
|
|
250
|
+
if (!out) return [];
|
|
251
|
+
const m = out.match(/"acct"<blob>="([^"]*)"/);
|
|
252
|
+
return m ? [m[1]] : [];
|
|
253
|
+
}
|
|
254
|
+
function keychainToken(svc, acct) {
|
|
255
|
+
return shSafe('security', ['find-generic-password', '-s', svc, '-a', acct, '-w']);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// ---- reviewer fetch (Bitbucket default-reviewers) ----
|
|
259
|
+
function httpsGetJson(url, auth) {
|
|
260
|
+
return new Promise((resolve, reject) => {
|
|
261
|
+
const u = new URL(url);
|
|
262
|
+
const opts = { method: 'GET', headers: { 'User-Agent': 'pr-commands' } };
|
|
263
|
+
if (auth) opts.headers.Authorization = 'Basic ' + Buffer.from(auth).toString('base64');
|
|
264
|
+
https.get(u, opts, (res) => {
|
|
265
|
+
let data = '';
|
|
266
|
+
res.on('data', (c) => (data += c));
|
|
267
|
+
res.on('end', () => {
|
|
268
|
+
try { resolve({ status: res.statusCode, json: JSON.parse(data) }); }
|
|
269
|
+
catch { resolve({ status: res.statusCode, json: null }); }
|
|
270
|
+
});
|
|
271
|
+
}).on('error', reject);
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
async function fetchSelfUuid(email, token) {
|
|
275
|
+
const { status, json } = await httpsGetJson('https://api.bitbucket.org/2.0/user', `${email}:${token}`);
|
|
276
|
+
if (status !== 200 || !json) return null;
|
|
277
|
+
return json.uuid || null;
|
|
278
|
+
}
|
|
279
|
+
async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
280
|
+
const PAGELEN = 100; // max Bitbucket page; loop follows `next` if a repo ever exceeds it
|
|
281
|
+
let url = `https://api.bitbucket.org/2.0/repositories/${slug}/default-reviewers?pagelen=${PAGELEN}`;
|
|
282
|
+
const out = [];
|
|
283
|
+
let page = 0;
|
|
284
|
+
while (url) {
|
|
285
|
+
const { status, json } = await httpsGetJson(url, `${email}:${token}`);
|
|
286
|
+
if (status !== 200 || !json || !json.values) return page ? out : null; // page>0 → return what we got
|
|
287
|
+
page++;
|
|
288
|
+
for (const v of json.values) out.push({ uuid: v.uuid, name: v.display_name || v.nickname || v.uuid });
|
|
289
|
+
url = json.next || null; // Bitbucket gives a full next-page URL when more remain
|
|
290
|
+
}
|
|
291
|
+
const filtered = selfUuid ? out.filter((r) => r.uuid !== selfUuid) : out;
|
|
292
|
+
log(` fetched ${out.length} default-reviewer(s) across ${page} page(s)` +
|
|
293
|
+
(selfUuid && out.length !== filtered.length ? ` — excluded yourself` : ''));
|
|
294
|
+
return filtered;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ---- main ----
|
|
298
|
+
(async () => {
|
|
299
|
+
// --- environment (computed once, not a back-able step; shown in every screen header) ---
|
|
300
|
+
const platform = os.platform();
|
|
301
|
+
const cfgOs = platform === 'darwin' ? 'macos' : platform === 'linux' ? 'linux' : platform;
|
|
302
|
+
const tokenSrc = cfgOs === 'macos' ? 'keychain' : 'env';
|
|
303
|
+
|
|
304
|
+
const st = { conv: {} }; // accumulates across steps; survives back/forward
|
|
305
|
+
|
|
306
|
+
// Breadcrumb of decisions committed BEFORE `stage` (so re-entering a step never shows its own value).
|
|
307
|
+
const crumb = (stage) => {
|
|
308
|
+
const L = [];
|
|
309
|
+
if (stage > 0 && st.workDir) {
|
|
310
|
+
L.push(` \x1b[32m✓\x1b[0m work dir ${st.workDir}`);
|
|
311
|
+
L.push(` \x1b[32m✓\x1b[0m provider ${st.provider} (${st.slug}) → ${st.pcfg.bin}`);
|
|
312
|
+
L.push(` \x1b[32m✓\x1b[0m auth ${st.email || '(none)'}${st.token ? ' · token ok' : ' · no token'}`);
|
|
313
|
+
}
|
|
314
|
+
if (stage > 1 && st.chosen) L.push(` \x1b[32m✓\x1b[0m reviewers ${st.chosen.length} selected`);
|
|
315
|
+
if (stage > 2 && st.convention) L.push(` \x1b[32m✓\x1b[0m convention ${st.convention}`);
|
|
316
|
+
return L;
|
|
317
|
+
};
|
|
318
|
+
// Full-screen redraw: wipe, reprint header + breadcrumb + active-step marker. No accumulation on back.
|
|
319
|
+
const screen = (stage, active) => {
|
|
320
|
+
if (process.stdout.isTTY) process.stdout.write('\x1b[2J\x1b[3J\x1b[H');
|
|
321
|
+
console.log('@nvice-r/pr-commands installer' + (flags.dryRun ? ' (dry-run)' : ''));
|
|
322
|
+
console.log(` os=${cfgOs} token_src=${tokenSrc}` + (cfgOs !== 'macos' ? ' \x1b[33m(only macOS fully supported)\x1b[0m' : ''));
|
|
323
|
+
const c = crumb(stage);
|
|
324
|
+
if (c.length) { console.log(''); c.forEach((l) => console.log(l)); }
|
|
325
|
+
console.log(`\n▶ ${active}`);
|
|
326
|
+
};
|
|
327
|
+
|
|
328
|
+
// --- back-able steps: each returns BACK to revisit the previous step, else falls through ---
|
|
329
|
+
|
|
330
|
+
// Work dir + everything derived from it (provider/slug/identity/token).
|
|
331
|
+
const sWorkDir = async () => {
|
|
332
|
+
screen(0, 'Work directory');
|
|
333
|
+
let wd;
|
|
334
|
+
if (flags.dir || flags.yes) wd = path.resolve(flags.dir || process.cwd());
|
|
335
|
+
else {
|
|
336
|
+
const a = await askPrefill(' repo path: ', st.workDir || process.cwd());
|
|
337
|
+
if (a === BACK) return BACK; // first step — driver just stays here
|
|
338
|
+
wd = path.resolve(a);
|
|
339
|
+
}
|
|
340
|
+
log(` ${wd}`);
|
|
341
|
+
if (!shSafe('git', ['-C', wd, 'rev-parse', '--git-dir'])) {
|
|
342
|
+
log(' not a git repo here — scanning up to 3 levels below for repos…');
|
|
343
|
+
const repos = scanGitRepos(wd);
|
|
344
|
+
if (repos.length === 0) {
|
|
345
|
+
console.error(` ERROR: no git repo at ${wd}, and none found within 3 levels.`);
|
|
346
|
+
console.error(' Point --dir at a git repo, or cd into one and re-run.');
|
|
347
|
+
process.exit(1);
|
|
348
|
+
}
|
|
349
|
+
if (flags.yes) { wd = repos[0]; log(` picked ${wd}`); }
|
|
350
|
+
else {
|
|
351
|
+
const labels = repos.map((r) => path.relative(wd, r) || '.');
|
|
352
|
+
const idx = await multiSelectOne(`Found ${repos.length} repo(s) under ${wd} — pick one:`, labels, 0);
|
|
353
|
+
if (idx === BACK) return BACK;
|
|
354
|
+
wd = repos[idx];
|
|
355
|
+
screen(0, 'Work directory'); // repaint (selector left its own frame)
|
|
356
|
+
log(` ${wd}`);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const remoteUrl = shSafe('git', ['-C', wd, 'remote', 'get-url', 'origin']);
|
|
360
|
+
if (!remoteUrl) { console.error(' ERROR: no git origin remote'); process.exit(1); }
|
|
361
|
+
const provider = detectProvider(remoteUrl);
|
|
362
|
+
if (!provider) { console.error(` ERROR: could not detect provider from ${remoteUrl}`); process.exit(1); }
|
|
363
|
+
const pcfg = PROVIDERS[provider];
|
|
364
|
+
const slug = parseSlug(remoteUrl);
|
|
365
|
+
log(` remote: ${remoteUrl}\n provider=${provider} slug=${slug} binary=${pcfg.bin}`);
|
|
366
|
+
if (!pcfg.ready) { console.error(` ERROR: ${provider} not yet supported (macOS+Bitbucket only). Aborting.`); process.exit(1); }
|
|
367
|
+
// identity + token (derived from provider)
|
|
368
|
+
let email = '';
|
|
369
|
+
const accts = keychainAccounts(pcfg.keychainSvc);
|
|
370
|
+
if (accts.length === 1) { email = accts[0]; log(` email (keychain): ${email}`); }
|
|
371
|
+
else if (!flags.yes) { const e = await ask(' Atlassian email: '); email = e || (accts[0] || ''); }
|
|
372
|
+
else if (accts.length) email = accts[0];
|
|
373
|
+
const token = email ? keychainToken(pcfg.keychainSvc, email) : null;
|
|
374
|
+
if (!token) {
|
|
375
|
+
log(` WARN: no keychain token for ${pcfg.keychainSvc}/${email || '(none)'} — reviewers can't auto-fetch`);
|
|
376
|
+
log(` security add-generic-password -s ${pcfg.keychainSvc} -a ${email || 'you@example.com'} -w '<api-token>'`);
|
|
377
|
+
} else log(' token: found');
|
|
378
|
+
if (st.slug !== slug) st.revs = undefined; // invalidate reviewer cache if repo changed
|
|
379
|
+
const mroot = mainRepoRoot(wd) || wd; // shared across worktrees; roster lives here
|
|
380
|
+
if (mroot !== wd) log(` worktree detected → roster shared at main root: ${mroot}`);
|
|
381
|
+
Object.assign(st, { workDir: wd, mainRoot: mroot, remoteUrl, provider, pcfg, slug, email, token });
|
|
382
|
+
};
|
|
383
|
+
|
|
384
|
+
const sReviewers = async () => {
|
|
385
|
+
screen(1, 'Reviewers');
|
|
386
|
+
st.chosen = st.chosen || [];
|
|
387
|
+
if (!(st.token && st.email)) { log(' skipped (no token) — edit the roster later.'); st.chosen = []; return; }
|
|
388
|
+
if (st.selfUuid === undefined) st.selfUuid = await fetchSelfUuid(st.email, st.token);
|
|
389
|
+
if (st.revs === undefined) st.revs = await fetchBitbucketReviewers(st.slug, st.email, st.token, st.selfUuid);
|
|
390
|
+
const revs = st.revs;
|
|
391
|
+
if (!revs) { log(' WARN: could not fetch default-reviewers. Roster left empty.'); st.chosen = []; return; }
|
|
392
|
+
if (revs.length === 0) { log(' repo has no default reviewers. Roster left empty.'); st.chosen = []; return; }
|
|
393
|
+
if (flags.yes) { st.chosen = revs; st.chosenIdx = revs.map((_, i) => i); log(` selected all ${revs.length}`); return; }
|
|
394
|
+
const picked = await multiSelect(
|
|
395
|
+
`Reviewers for ${st.slug} (default: all):`,
|
|
396
|
+
revs.map((r) => ({ label: r.name })), true, st.chosenIdx || null,
|
|
397
|
+
);
|
|
398
|
+
if (picked === BACK) return BACK;
|
|
399
|
+
st.chosenIdx = picked;
|
|
400
|
+
st.chosen = picked.map((i) => revs[i]);
|
|
401
|
+
log(` chosen: ${st.chosen.length}`);
|
|
402
|
+
};
|
|
403
|
+
|
|
404
|
+
const sPreset = async () => {
|
|
405
|
+
screen(2, 'Convention — preset');
|
|
406
|
+
if (flags.yes) {
|
|
407
|
+
st.convention = st.convention || 'trueid';
|
|
408
|
+
st.conv = { ...PRESET_DEFS[st.convention] };
|
|
409
|
+
return;
|
|
410
|
+
}
|
|
411
|
+
const idx = Math.max(0, CONVENTIONS.indexOf(st.convention || 'trueid'));
|
|
412
|
+
const pick = await multiSelectOne('Convention preset:', CONVENTIONS, idx);
|
|
413
|
+
if (pick === BACK) return BACK;
|
|
414
|
+
const chosenConv = CONVENTIONS[pick];
|
|
415
|
+
if (chosenConv !== st.convention) { // reseed fields only when preset actually changes
|
|
416
|
+
st.convention = chosenConv;
|
|
417
|
+
st.conv = { ...PRESET_DEFS[chosenConv] };
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
const editField = (label, key) => async () => {
|
|
422
|
+
screen(3, `Convention — ${key}`);
|
|
423
|
+
printLegend();
|
|
424
|
+
const v = await askPrefill(` ${label} `, st.conv[key] ?? PRESET_DEFS[st.convention][key]);
|
|
425
|
+
if (v === BACK) return BACK;
|
|
426
|
+
st.conv[key] = v;
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
const steps = flags.yes
|
|
430
|
+
? [sWorkDir, sReviewers, sPreset]
|
|
431
|
+
: [sWorkDir, sReviewers, sPreset,
|
|
432
|
+
editField('title_pattern: ', 'title_pattern'),
|
|
433
|
+
editField('ticket_regex: ', 'ticket_regex'),
|
|
434
|
+
editField('template_sections:', 'template_sections')];
|
|
435
|
+
|
|
436
|
+
let i = 0;
|
|
437
|
+
while (i < steps.length) {
|
|
438
|
+
const r = await steps[i]();
|
|
439
|
+
if (r === BACK) i = Math.max(0, i - 1);
|
|
440
|
+
else i++;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// --- install ---
|
|
444
|
+
screen(3, 'Install');
|
|
445
|
+
const { slug, pcfg, email, convention, conv, chosen } = st;
|
|
446
|
+
let cfg = `os=${cfgOs}\ntoken_src=${tokenSrc}\nemail=${email}\nconvention=${convention}\n`;
|
|
447
|
+
if (conv.title_pattern) cfg += `title_pattern=${conv.title_pattern}\n`;
|
|
448
|
+
if (conv.ticket_regex) cfg += `ticket_regex=${conv.ticket_regex}\n`;
|
|
449
|
+
if (conv.template_sections) cfg += `template_sections=${conv.template_sections}\n`;
|
|
450
|
+
writeFile(path.join(HOME, '.config/pr/config'), cfg, 0o600);
|
|
451
|
+
// Roster lives in the work dir (main repo root — shared across worktrees). Editable
|
|
452
|
+
// in place; pr-common.sh finds it first, falling back to the global path.
|
|
453
|
+
const roster = chosen.map((r) => `${r.uuid} # ${r.name}`).join('\n') + (chosen.length ? '\n' : '');
|
|
454
|
+
const header = `# pr reviewers for ${slug} — {uuid} # Name per line; blanks/# ignored. Edit freely.\n`;
|
|
455
|
+
writeFile(path.join(st.mainRoot, '.pr-reviewers.txt'), header + (roster || '# (none selected)\n'));
|
|
456
|
+
const binDir = path.join(HOME, '.local/bin');
|
|
457
|
+
const binsToInstall = flags.all
|
|
458
|
+
? Object.values(PROVIDERS).filter((p) => p.ready).map((p) => p.bin)
|
|
459
|
+
: [pcfg.bin];
|
|
460
|
+
writeFile(path.join(binDir, 'pr-common.sh'), fs.readFileSync(path.join(ASSETS, 'bin/pr-common.sh')), 0o755);
|
|
461
|
+
for (const b of binsToInstall) {
|
|
462
|
+
const src = path.join(ASSETS, 'bin', b);
|
|
463
|
+
if (!fs.existsSync(src)) { log(` skip ${b} (asset not present yet)`); continue; }
|
|
464
|
+
writeFile(path.join(binDir, b), fs.readFileSync(src), 0o755);
|
|
465
|
+
}
|
|
466
|
+
writeFile(path.join(HOME, '.claude/commands/pr.md'), fs.readFileSync(path.join(ASSETS, 'skill/pr.md')));
|
|
467
|
+
if (!(process.env.PATH || '').split(':').includes(binDir)) {
|
|
468
|
+
log(`\n WARN: ${binDir} not on PATH. Add to your shell rc:`);
|
|
469
|
+
log(` export PATH="$HOME/.local/bin:$PATH"`);
|
|
470
|
+
}
|
|
471
|
+
log('\nDone. Try: /pr help (in Claude Code, from ' + st.workDir + ')');
|
|
472
|
+
})().catch((e) => { console.error(e); process.exit(1); });
|
package/package.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nvice-r/pr-commands",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Provider-agnostic PR helper for Claude Code — npx installer + bbpr/ghpr/glpr binaries + /pr skill",
|
|
5
|
+
"bin": {
|
|
6
|
+
"pr-commands": "install.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"install.js",
|
|
10
|
+
"assets/"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=16"
|
|
14
|
+
},
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"keywords": ["claude-code", "pull-request", "bitbucket", "github", "gitlab", "cli"],
|
|
19
|
+
"author": "nvice.r",
|
|
20
|
+
"license": "MIT"
|
|
21
|
+
}
|