@nvice-r/pr-commands 0.1.2 → 0.1.4
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 +68 -28
- package/assets/bin/pr-commands +71 -0
- package/assets/{bin → share/pr-commands}/pr-common.sh +41 -17
- package/assets/share/pr-commands/providers/bitbucket.sh +77 -0
- package/assets/skill/pr.body.md +98 -48
- package/install.js +101 -38
- package/package.json +3 -3
- package/assets/bin/bbpr +0 -78
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
|
|
|
@@ -74,10 +78,11 @@ identically from every clone and every git worktree, no repo-local symlinks.
|
|
|
74
78
|
|
|
75
79
|
| Artifact | Path |
|
|
76
80
|
|----------|------|
|
|
77
|
-
|
|
|
78
|
-
|
|
|
81
|
+
| Command (only file on `PATH`) | `~/.local/bin/pr-commands` |
|
|
82
|
+
| Shared library | `~/.local/share/pr-commands/pr-common.sh` |
|
|
83
|
+
| Host provider | `~/.local/share/pr-commands/providers/bitbucket.sh` (`github.sh`/`gitlab.sh` later) |
|
|
79
84
|
| Config | `~/.config/pr/config` |
|
|
80
|
-
| Reviewer roster | `<main-repo-root
|
|
85
|
+
| Reviewer roster | `<main-repo-root>/<agent_dir>/pr-reviewers.txt` (primary agent's folder; shared across worktrees) |
|
|
81
86
|
| Canonical skill | `~/.config/pr/skill/pr/SKILL.md` (single source) |
|
|
82
87
|
| Per-agent skill | `<agent-skills-dir>/pr` → symlink to canonical |
|
|
83
88
|
| Claude `/pr` command | `~/.claude/commands/pr.md` (dual-install, keeps the slash trigger) |
|
|
@@ -115,6 +120,30 @@ convention=trueid # see presets
|
|
|
115
120
|
Edit by hand any time. **A missing config is a hard error** — `/pr` and the binaries refuse to
|
|
116
121
|
guess. Re-run the installer to regenerate.
|
|
117
122
|
|
|
123
|
+
### Per-project convention (`.agents/config/pr.yaml`)
|
|
124
|
+
|
|
125
|
+
When the repo has `.agents/config/pr.yaml` (this checkout, or the main checkout for worktrees),
|
|
126
|
+
`/pr` reads the convention from it instead of the user config. One file per project, so teams
|
|
127
|
+
with different conventions share the same skill:
|
|
128
|
+
|
|
129
|
+
```yaml
|
|
130
|
+
title_pattern: "[<ver>] Feature/<ver>/<ticket> <desc>"
|
|
131
|
+
ticket_regex: "SC-(\\d+|NOTICKET)"
|
|
132
|
+
template_sections: [What Happened, Insight, Proof of Work]
|
|
133
|
+
body_rules: |
|
|
134
|
+
- Free-text project rules the skill follows literally.
|
|
135
|
+
base: develop # default base proposal
|
|
136
|
+
draft: ask # ask | true | false
|
|
137
|
+
push: [origin] # remotes pushed before create
|
|
138
|
+
fork: # optional cross-repo mode (`/pr cross`)
|
|
139
|
+
remote: social-commerce
|
|
140
|
+
repo: workspace/fork-repo
|
|
141
|
+
pre_pr: [] # commands run before confirm; non-zero exit stops /pr
|
|
142
|
+
post_pr: [jira-update] # sibling skills offered after create
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
Without the file, the convention keys below (written by the wizard) are used.
|
|
146
|
+
|
|
118
147
|
### Convention presets
|
|
119
148
|
|
|
120
149
|
| preset | title pattern | ticket regex | template sections |
|
|
@@ -135,15 +164,19 @@ template_sections=Context|Changes|Risk
|
|
|
135
164
|
|
|
136
165
|
### Reviewers
|
|
137
166
|
|
|
138
|
-
Roster lives in
|
|
139
|
-
|
|
140
|
-
|
|
167
|
+
Roster lives in the **primary agent's repo folder** under the main repo root — `.claude`,
|
|
168
|
+
`.agent` (Antigravity), `.codex` or `.opencode`, picked from the first agent you install for
|
|
169
|
+
(override with `--agent-dir`). The choice is saved as `agent_dir` in the config so the bash binary
|
|
170
|
+
reads the same path. Resolved via `git --git-common-dir/..`, so all worktrees of a repo share one
|
|
171
|
+
roster:
|
|
141
172
|
|
|
142
173
|
```
|
|
143
|
-
<main-repo-root
|
|
174
|
+
<main-repo-root>/<agent_dir>/pr-reviewers.txt
|
|
144
175
|
```
|
|
145
176
|
|
|
146
|
-
|
|
177
|
+
Switching primary agent = re-run the installer; the old file still loads as a fallback.
|
|
178
|
+
|
|
179
|
+
You (the author) are excluded on install — `pr-commands` also drops self at PR time. One entry per line,
|
|
147
180
|
trailing `# Name` comment optional:
|
|
148
181
|
|
|
149
182
|
```
|
|
@@ -151,23 +184,23 @@ trailing `# Name` comment optional:
|
|
|
151
184
|
{fedcba98-7654-3210-fedc-ba9876543210} # Bob
|
|
152
185
|
```
|
|
153
186
|
|
|
154
|
-
It's plain data — edit freely, no reinstall. Consider gitignoring
|
|
155
|
-
|
|
156
|
-
(0.1.
|
|
187
|
+
It's plain data — edit freely, no reinstall. Consider gitignoring the roster unless the team wants
|
|
188
|
+
it committed. Load order: `$PR_REVIEWERS` → `<agent_dir>/pr-reviewers.txt` → `.pr/reviewers.txt`
|
|
189
|
+
(0.1.2) → root `.pr-reviewers.txt` (pre-0.1.1) → global `~/.config/pr/reviewers/<ws>-<slug>.txt` →
|
|
157
190
|
legacy. Run-time overrides: `--no-reviewers` skips all, `--reviewer <uuid>` appends.
|
|
158
191
|
|
|
159
192
|
### Auth precedence (email)
|
|
160
193
|
|
|
161
|
-
`--email` flag → `$BB_EMAIL` → `git config
|
|
194
|
+
`--email` flag → `$BB_EMAIL` → `git config pr.email` → config `email` → keychain account.
|
|
162
195
|
The token is always read from the keychain (or `$PR_TOKEN` when `token_src=env`).
|
|
163
196
|
|
|
164
197
|
## Provider support
|
|
165
198
|
|
|
166
|
-
| provider |
|
|
167
|
-
|
|
168
|
-
| Bitbucket Cloud | `
|
|
169
|
-
| GitHub | `
|
|
170
|
-
| GitLab | `
|
|
199
|
+
| provider | file | status |
|
|
200
|
+
|----------|------|--------|
|
|
201
|
+
| Bitbucket Cloud | `providers/bitbucket.sh` | ✅ ready |
|
|
202
|
+
| GitHub | `providers/github.sh` | 🚧 not yet — `pr-commands` errors `github not yet supported` |
|
|
203
|
+
| GitLab | `providers/gitlab.sh` | 🚧 not yet |
|
|
171
204
|
|
|
172
205
|
| OS | token source | status |
|
|
173
206
|
|----|--------------|--------|
|
|
@@ -176,15 +209,21 @@ The token is always read from the keychain (or `$PR_TOKEN` when `token_src=env`)
|
|
|
176
209
|
|
|
177
210
|
## Architecture
|
|
178
211
|
|
|
179
|
-
- **
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
212
|
+
- **One command, one file per host.** `pr-commands` is the only thing on `PATH`. It reads
|
|
213
|
+
`git remote get-url origin` (override: `$PR_PROVIDER`) and loads `providers/<host>.sh`.
|
|
214
|
+
Each host keeps its own small file because the REST APIs genuinely differ. OS differences
|
|
215
|
+
(token retrieval) are a single `get_token()` function, not a separate file.
|
|
183
216
|
- **Shared logic in `pr-common.sh`** — git scan, base save, reviewer load, arg parse, token —
|
|
184
|
-
sourced
|
|
217
|
+
sourced before the provider. A provider file is ~60 lines: payload shape + endpoint + parse.
|
|
185
218
|
- **One generic `/pr` skill.** Convention lives in config, not the skill, so the skill is
|
|
186
|
-
|
|
187
|
-
|
|
219
|
+
host- and team-neutral and installs globally. It always calls `pr-commands create`, so adding
|
|
220
|
+
a host = one provider file + `ready: true` in `install.js`, zero skill changes.
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
pr-commands create --title "..." --body-file body.md --dest develop [--draft]
|
|
224
|
+
pr-commands check # which host, and is it supported?
|
|
225
|
+
pr-commands help
|
|
226
|
+
```
|
|
188
227
|
|
|
189
228
|
Full rationale in [`DECISIONS.md`](./DECISIONS.md).
|
|
190
229
|
|
|
@@ -195,14 +234,15 @@ Full rationale in [`DECISIONS.md`](./DECISIONS.md).
|
|
|
195
234
|
| `no ~/.config/pr/config` | run `npx @nvice-r/pr-commands` |
|
|
196
235
|
| reviewers not fetched | add the keychain token (see Prerequisites), re-run |
|
|
197
236
|
| `WARN ~/.local/bin not on PATH` | add the export line above to your shell rc |
|
|
198
|
-
|
|
|
199
|
-
| worktree runs old behavior | skill/
|
|
237
|
+
| `<provider> not yet supported` | GitHub/GitLab aren't implemented yet |
|
|
238
|
+
| worktree runs old behavior | skill/command are global, not repo-local; ensure no stale per-repo copy shadows them |
|
|
200
239
|
| agent doesn't see the skill | re-run and force it: `npx @nvice-r/pr-commands --agent <id>`; check `<agent-skills-dir>/pr` symlink exists |
|
|
201
240
|
|
|
202
241
|
## Uninstall
|
|
203
242
|
|
|
204
243
|
```bash
|
|
205
|
-
rm ~/.local/bin/
|
|
244
|
+
rm ~/.local/bin/pr-commands
|
|
245
|
+
rm -rf ~/.local/share/pr-commands
|
|
206
246
|
rm -rf ~/.config/pr
|
|
207
247
|
rm ~/.claude/commands/pr.md
|
|
208
248
|
# per-agent skill symlinks (remove those you installed):
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# pr-commands — create pull requests on any git host (one entry point).
|
|
3
|
+
#
|
|
4
|
+
# Usage:
|
|
5
|
+
# pr-commands create --title "..." [--body|--body-file f] [--source b] [--dest b]
|
|
6
|
+
# [--repo owner/name] [--draft] [--reviewer id]... [--no-reviewers]
|
|
7
|
+
# [--email a] [--close-source] [--source-repo owner/fork]
|
|
8
|
+
# pr-commands check detect the host from the origin remote; exit 0 if supported
|
|
9
|
+
# pr-commands help this help
|
|
10
|
+
# pr-commands --version
|
|
11
|
+
#
|
|
12
|
+
# The host is detected from `git remote get-url origin` (override: $PR_PROVIDER).
|
|
13
|
+
# Host code lives in <share>/providers/<provider>.sh; shared code in <share>/pr-common.sh,
|
|
14
|
+
# where <share> = $PR_COMMANDS_HOME, else <this dir>/../share/pr-commands.
|
|
15
|
+
# pr-commands-version: 1
|
|
16
|
+
|
|
17
|
+
set -euo pipefail
|
|
18
|
+
|
|
19
|
+
PR_CMD="pr-commands"
|
|
20
|
+
_here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
21
|
+
SHARE="${PR_COMMANDS_HOME:-$_here/../share/pr-commands}"
|
|
22
|
+
|
|
23
|
+
usage() { sed -n '2,14p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'; }
|
|
24
|
+
fail() { echo "$PR_CMD: $*" >&2; exit 1; }
|
|
25
|
+
|
|
26
|
+
# Echo the provider name for the origin remote (or $PR_PROVIDER), or fail.
|
|
27
|
+
detect_provider() {
|
|
28
|
+
if [ -n "${PR_PROVIDER:-}" ]; then echo "$PR_PROVIDER"; return; fi
|
|
29
|
+
local url
|
|
30
|
+
url="$(git remote get-url origin 2>/dev/null)" || fail "no origin remote"
|
|
31
|
+
case "$url" in
|
|
32
|
+
*bitbucket.org*) echo bitbucket ;;
|
|
33
|
+
*github.com*) echo github ;;
|
|
34
|
+
*gitlab*) echo gitlab ;;
|
|
35
|
+
*) fail "unknown git host for remote: $url (set PR_PROVIDER to override)" ;;
|
|
36
|
+
esac
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Source the shared lib + the provider file. Fails if the provider is not installed.
|
|
40
|
+
load_provider() {
|
|
41
|
+
PROVIDER="$(detect_provider)"
|
|
42
|
+
local pfile="$SHARE/providers/${PROVIDER}.sh"
|
|
43
|
+
[ -f "$SHARE/pr-common.sh" ] || fail "missing $SHARE/pr-common.sh — run: npx @nvice-r/pr-commands"
|
|
44
|
+
[ -f "$pfile" ] || fail "${PROVIDER} not yet supported (no $pfile)"
|
|
45
|
+
# shellcheck source=../share/pr-commands/pr-common.sh
|
|
46
|
+
. "$SHARE/pr-common.sh"
|
|
47
|
+
# shellcheck disable=SC1090
|
|
48
|
+
. "$pfile"
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
sub="${1:-help}"
|
|
52
|
+
case "$sub" in
|
|
53
|
+
-h|--help|--version) ;;
|
|
54
|
+
--*) set -- create "$@"; sub=create ;; # bare flags = create
|
|
55
|
+
esac
|
|
56
|
+
|
|
57
|
+
case "$sub" in
|
|
58
|
+
create)
|
|
59
|
+
shift
|
|
60
|
+
load_provider
|
|
61
|
+
declare -F provider_create >/dev/null || fail "${PROVIDER}: create not implemented"
|
|
62
|
+
provider_create "$@"
|
|
63
|
+
;;
|
|
64
|
+
check)
|
|
65
|
+
load_provider
|
|
66
|
+
echo "provider=${PROVIDER} ok"
|
|
67
|
+
;;
|
|
68
|
+
help|-h|--help) usage ;;
|
|
69
|
+
--version|version) sed -n 's/^# pr-commands-version: //p' "${BASH_SOURCE[0]}" ;;
|
|
70
|
+
*) fail "unknown command: $sub (try: $PR_CMD help)" ;;
|
|
71
|
+
esac
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# pr-common-version:
|
|
3
|
-
# pr-common.sh — shared library for
|
|
4
|
-
# NOT executable on its own — sourced by
|
|
5
|
-
#
|
|
2
|
+
# pr-common-version: 3
|
|
3
|
+
# pr-common.sh — shared library for the `pr-commands` entry point.
|
|
4
|
+
# NOT executable on its own — sourced by `pr-commands` before providers/<host>.sh, which
|
|
5
|
+
# builds the host-specific payload + POST. Everything here is host-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,12 +76,13 @@ 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 ;;
|
|
78
83
|
--no-reviewers) NO_REVIEWERS="true"; shift ;;
|
|
79
84
|
--email) EMAIL_ARG="$2"; shift 2 ;;
|
|
80
|
-
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
|
|
85
|
+
-h|--help) if declare -F usage >/dev/null; then usage; else sed -n '2,20p' "$0"; fi; exit 0 ;;
|
|
81
86
|
*) die "unknown flag: $1" ;;
|
|
82
87
|
esac
|
|
83
88
|
done
|
|
@@ -96,10 +101,11 @@ pr_git_defaults() {
|
|
|
96
101
|
[ "$SOURCE" = "HEAD" ] && die "detached HEAD — pass --source"
|
|
97
102
|
fi
|
|
98
103
|
if [ -z "$DEST" ]; then
|
|
99
|
-
|
|
100
|
-
|
|
104
|
+
pr_migrate_base
|
|
105
|
+
DEST="$(git config --get "branch.${SOURCE}.base" 2>/dev/null || true)"
|
|
106
|
+
[ -n "$DEST" ] && echo "${PR_CMD:-pr}: using saved base (branch.${SOURCE}.base = ${DEST})" >&2
|
|
101
107
|
fi
|
|
102
|
-
[ -n "$DEST" ] || die "--dest required (no saved branch.${SOURCE}.
|
|
108
|
+
[ -n "$DEST" ] || die "--dest required (no saved branch.${SOURCE}.base)"
|
|
103
109
|
if [ -z "$REPO" ]; then
|
|
104
110
|
local url
|
|
105
111
|
url="$(git remote get-url origin 2>/dev/null)" || die "no origin remote"
|
|
@@ -111,9 +117,11 @@ pr_git_defaults() {
|
|
|
111
117
|
|
|
112
118
|
# --- creds: email precedence + token via OS ---
|
|
113
119
|
pr_resolve_creds() {
|
|
114
|
-
# --email > $BB_EMAIL > git config
|
|
120
|
+
# --email > $BB_EMAIL > git config pr.email > config email > keychain acct
|
|
121
|
+
# (legacy git config bbpr.email still read as fallback after pr.email)
|
|
115
122
|
EMAIL="$EMAIL_ARG"
|
|
116
123
|
[ -n "$EMAIL" ] || EMAIL="${BB_EMAIL:-}"
|
|
124
|
+
[ -n "$EMAIL" ] || EMAIL="$(git config pr.email 2>/dev/null || true)"
|
|
117
125
|
[ -n "$EMAIL" ] || EMAIL="$(git config bbpr.email 2>/dev/null || true)"
|
|
118
126
|
[ -n "$EMAIL" ] || EMAIL="${CFG_EMAIL:-}"
|
|
119
127
|
[ -n "$EMAIL" ] || die "no email resolved (set config email or pass --email)"
|
|
@@ -123,17 +131,17 @@ pr_resolve_creds() {
|
|
|
123
131
|
# --- reviewer roster: work-dir file (shared across worktrees) then global fallbacks ---
|
|
124
132
|
pr_load_reviewers() {
|
|
125
133
|
DEFAULT_REVIEWERS=()
|
|
126
|
-
local file="${BBPR_REVIEWERS:-}"
|
|
134
|
+
local file="${PR_REVIEWERS:-${BBPR_REVIEWERS:-}}" # BBPR_REVIEWERS = legacy name
|
|
127
135
|
if [ -z "$file" ]; then
|
|
128
136
|
local slug; slug="$(echo "$REPO" | tr '/' '-')" # workspace/slug -> workspace-slug
|
|
129
137
|
# main repo root, shared across worktrees: git-common-dir/.. (empty if git call fails)
|
|
130
138
|
local mainroot; mainroot="$(cd "$(git rev-parse --git-common-dir 2>/dev/null)/.." 2>/dev/null && pwd)"
|
|
131
139
|
for cand in \
|
|
132
|
-
${mainroot:+"$mainroot/.pr/reviewers.txt"} \
|
|
133
140
|
${mainroot:+"$mainroot/${CFG_AGENT_DIR:-.claude}/pr-reviewers.txt"} \
|
|
141
|
+
${mainroot:+"$mainroot/.pr/reviewers.txt"} \
|
|
134
142
|
${mainroot:+"$mainroot/.pr-reviewers.txt"} \
|
|
135
143
|
"$HOME/.config/pr/reviewers/${slug}.txt" \
|
|
136
|
-
"$HOME/.config/bbpr/bbpr-reviewers.txt"; do #
|
|
144
|
+
"$HOME/.config/bbpr/bbpr-reviewers.txt"; do # agent-dir primary; 0.1.2 .pr/ + root + global legacy fallbacks
|
|
137
145
|
[ -f "$cand" ] && { file="$cand"; break; }
|
|
138
146
|
done
|
|
139
147
|
fi
|
|
@@ -156,12 +164,28 @@ pr_load_reviewers() {
|
|
|
156
164
|
}
|
|
157
165
|
|
|
158
166
|
# --- persist resolved base for next run ---
|
|
167
|
+
# branch.<b>.base is the shared, host-neutral key (also read by
|
|
168
|
+
# @nvice-r/git-commands git-restack).
|
|
159
169
|
pr_save_base() {
|
|
160
|
-
git config "branch.${SOURCE}.
|
|
161
|
-
&& echo "${PR_CMD:-pr}: saved branch.${SOURCE}.
|
|
170
|
+
git config "branch.${SOURCE}.base" "$DEST" 2>/dev/null \
|
|
171
|
+
&& echo "${PR_CMD:-pr}: saved branch.${SOURCE}.base = ${DEST}" >&2
|
|
162
172
|
return 0 # base-save is best-effort; never abort the binary on failure
|
|
163
173
|
}
|
|
164
174
|
|
|
175
|
+
# One-time migration: pr-commands <= 0.1.3 saved the base under the legacy key
|
|
176
|
+
# branch.<b>.bbprBase. Copy it to branch.<b>.base and unset the old key.
|
|
177
|
+
# Remove this function once no branch can still carry the legacy key.
|
|
178
|
+
pr_migrate_base() {
|
|
179
|
+
local old
|
|
180
|
+
git config --get "branch.${SOURCE}.base" >/dev/null 2>&1 && return 0
|
|
181
|
+
old="$(git config --get "branch.${SOURCE}.bbprBase" 2>/dev/null || true)"
|
|
182
|
+
[ -n "$old" ] || return 0
|
|
183
|
+
git config "branch.${SOURCE}.base" "$old" 2>/dev/null \
|
|
184
|
+
&& git config --unset "branch.${SOURCE}.bbprBase" 2>/dev/null \
|
|
185
|
+
&& echo "${PR_CMD:-pr}: migrated branch.${SOURCE}.bbprBase -> branch.${SOURCE}.base" >&2
|
|
186
|
+
return 0 # best-effort; never abort the binary on failure
|
|
187
|
+
}
|
|
188
|
+
|
|
165
189
|
# --- orchestrate everything a provider needs before its POST ---
|
|
166
190
|
pr_common_init() {
|
|
167
191
|
pr_load_config
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# bitbucket-provider-version: 10
|
|
2
|
+
# providers/bitbucket.sh — Bitbucket Cloud (REST API v2.0). Sourced by `pr-commands`
|
|
3
|
+
# after pr-common.sh; never run directly. Only Bitbucket-specific code lives here:
|
|
4
|
+
# payload shape, endpoint, self-as-reviewer rule, response parse.
|
|
5
|
+
|
|
6
|
+
KEYCHAIN_SERVICE="bitbucket-api-token"
|
|
7
|
+
|
|
8
|
+
provider_create() {
|
|
9
|
+
pr_common_init "$@"
|
|
10
|
+
|
|
11
|
+
# --- Bitbucket: drop the PR author's own UUID (BB rejects author-as-reviewer) ---
|
|
12
|
+
if [ "${#FINAL_REVIEWERS[@]}" -gt 0 ]; then
|
|
13
|
+
local SELF_UUID
|
|
14
|
+
SELF_UUID="$(curl -sS -u "${EMAIL}:${TOKEN}" "https://api.bitbucket.org/2.0/user" 2>/dev/null \
|
|
15
|
+
| python3 -c 'import json,sys
|
|
16
|
+
try: print(json.load(sys.stdin).get("uuid",""))
|
|
17
|
+
except Exception: print("")' 2>/dev/null || true)"
|
|
18
|
+
if [ -n "$SELF_UUID" ]; then
|
|
19
|
+
local FILTERED=() r
|
|
20
|
+
for r in "${FINAL_REVIEWERS[@]}"; do
|
|
21
|
+
[ "$r" = "$SELF_UUID" ] || FILTERED+=("$r")
|
|
22
|
+
done
|
|
23
|
+
FINAL_REVIEWERS=("${FILTERED[@]:-}")
|
|
24
|
+
[ -z "${FINAL_REVIEWERS[0]:-}" ] && FINAL_REVIEWERS=()
|
|
25
|
+
fi
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
# --- Bitbucket payload (source/destination keys, draft, reviewers[]) ---
|
|
29
|
+
local PAYLOAD
|
|
30
|
+
PAYLOAD="$(
|
|
31
|
+
TITLE="$TITLE" BODY="$BODY" SOURCE="$SOURCE" DEST="$DEST" SOURCE_REPO="$SOURCE_REPO" \
|
|
32
|
+
DRAFT="$DRAFT" CLOSE_SOURCE="$CLOSE_SOURCE" \
|
|
33
|
+
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()]))')" \
|
|
34
|
+
python3 -c '
|
|
35
|
+
import json, os
|
|
36
|
+
p = {
|
|
37
|
+
"title": os.environ["TITLE"],
|
|
38
|
+
"description": os.environ["BODY"],
|
|
39
|
+
"source": {"branch": {"name": os.environ["SOURCE"]}},
|
|
40
|
+
"destination": {"branch": {"name": os.environ["DEST"]}},
|
|
41
|
+
"draft": os.environ["DRAFT"] == "true",
|
|
42
|
+
"close_source_branch": os.environ["CLOSE_SOURCE"] == "true",
|
|
43
|
+
}
|
|
44
|
+
if os.environ["SOURCE_REPO"]:
|
|
45
|
+
p["source"]["repository"] = {"full_name": os.environ["SOURCE_REPO"]}
|
|
46
|
+
revs = json.loads(os.environ["REVIEWERS_JSON"])
|
|
47
|
+
if revs: p["reviewers"] = revs
|
|
48
|
+
print(json.dumps(p))
|
|
49
|
+
'
|
|
50
|
+
)"
|
|
51
|
+
|
|
52
|
+
local URL="https://api.bitbucket.org/2.0/repositories/${REPO}/pullrequests"
|
|
53
|
+
echo "POST $URL"
|
|
54
|
+
echo "source=${SOURCE_REPO:+$SOURCE_REPO:}$SOURCE dest=$DEST draft=$DRAFT reviewers=${#FINAL_REVIEWERS[@]}"
|
|
55
|
+
|
|
56
|
+
local RESP PR_INFO
|
|
57
|
+
RESP="$(curl -sS -X POST "$URL" -u "${EMAIL}:${TOKEN}" \
|
|
58
|
+
-H 'Content-Type: application/json' -d "$PAYLOAD")"
|
|
59
|
+
|
|
60
|
+
PR_INFO="$(echo "$RESP" | python3 -c '
|
|
61
|
+
import json, sys
|
|
62
|
+
try: d = json.loads(sys.stdin.read())
|
|
63
|
+
except Exception as e: print("non-JSON response", e); sys.exit(2)
|
|
64
|
+
if "error" in d: print("ERROR:", json.dumps(d["error"], indent=2)); sys.exit(1)
|
|
65
|
+
print(str(d.get("id")) + "\t" + str(d.get("links", {}).get("html", {}).get("href")))
|
|
66
|
+
')" || { echo "$PR_INFO" >&2; exit 1; } # surface the API error; set -e would swallow it
|
|
67
|
+
|
|
68
|
+
local NEW_PR_ID NEW_PR_URL ATTACHED
|
|
69
|
+
NEW_PR_ID="$(echo "$PR_INFO" | cut -f1)"
|
|
70
|
+
NEW_PR_URL="$(echo "$PR_INFO" | cut -f2)"
|
|
71
|
+
ATTACHED="$(echo "$RESP" | python3 -c 'import json,sys; print(len(json.load(sys.stdin).get("reviewers", [])))')"
|
|
72
|
+
echo "created PR #${NEW_PR_ID} with ${ATTACHED} reviewers"
|
|
73
|
+
echo "$NEW_PR_URL"
|
|
74
|
+
|
|
75
|
+
[ -n "$NEW_PR_ID" ] && pr_save_base
|
|
76
|
+
return 0
|
|
77
|
+
}
|
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
|
-
auto-detected from the git remote
|
|
3
|
-
Convention (title pattern, ticket regex, PR template) comes
|
|
2
|
+
auto-detected from the git remote by the `pr-commands` command, which runs that host's provider.
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
38
|
+
CONVENTION <repo>/.agents/config/pr.yaml (per project; else convention keys in the user config)
|
|
39
|
+
CONFIG ~/.config/pr/config (os, token_src, email, agent_dir; legacy convention keys)
|
|
40
|
+
PROVIDER auto from `git remote get-url origin` (run by `pr-commands`)
|
|
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
|
|
|
@@ -47,33 +52,53 @@ missing piece). Do NOT improvise config or reviewers.
|
|
|
47
52
|
CFG="${PR_CONFIG:-$HOME/.config/pr/config}"
|
|
48
53
|
[ -f "$CFG" ] || { echo "MISSING config: $CFG"; }
|
|
49
54
|
|
|
50
|
-
# b.
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
55
|
+
# b. command installed?
|
|
56
|
+
command -v pr-commands >/dev/null 2>&1 || echo "MISSING command: pr-commands"
|
|
57
|
+
|
|
58
|
+
# c. host detected + supported? prints "provider=<name> ok", or the reason it is not
|
|
59
|
+
pr-commands check
|
|
60
|
+
|
|
61
|
+
# d. project convention file: this checkout first, then the main checkout (worktrees share it,
|
|
62
|
+
# because .agents/config/ is usually gitignored and only exists in the main checkout)
|
|
63
|
+
TOP="$(git rev-parse --show-toplevel)"
|
|
64
|
+
MAINROOT="$(cd "$(git rev-parse --git-common-dir)/.." && pwd)"
|
|
65
|
+
PR_YAML=""
|
|
66
|
+
for f in "$TOP/.agents/config/pr.yaml" "$MAINROOT/.agents/config/pr.yaml"; do
|
|
67
|
+
[ -f "$f" ] && { PR_YAML="$f"; break; }
|
|
68
|
+
done
|
|
69
|
+
echo "convention: ${PR_YAML:-$CFG (no .agents/config/pr.yaml)}"
|
|
61
70
|
```
|
|
62
71
|
|
|
63
|
-
- config missing → STOP: "run `npx @nvice-r/pr-commands`".
|
|
64
|
-
-
|
|
65
|
-
|
|
72
|
+
- config or `pr-commands` missing → STOP: "run `npx @nvice-r/pr-commands`".
|
|
73
|
+
- `pr-commands check` fails → STOP and show its message (unknown host, or
|
|
74
|
+
"<provider> not yet supported" — only Bitbucket for now). (D6 stub.)
|
|
66
75
|
- all pass → continue.
|
|
67
76
|
|
|
68
77
|
## Step 1 — gather context (parallel)
|
|
69
78
|
- `git rev-parse --abbrev-ref HEAD` — source branch (CURR)
|
|
70
|
-
- `git config --get branch.<CURR>.
|
|
79
|
+
- `git config --get branch.<CURR>.base` — saved base, if any
|
|
71
80
|
- `git log --oneline -20`
|
|
72
|
-
- read
|
|
81
|
+
- read the **convention source**: `$PR_YAML` if Step 0 found one, else the convention keys in `$CFG`.
|
|
82
|
+
Every later step that says "convention" means this source.
|
|
83
|
+
|
|
84
|
+
**Convention keys** (YAML in `pr.yaml`; `key=value` in `$CFG`, where list values are pipe-separated):
|
|
85
|
+
|
|
86
|
+
| key | meaning | if missing |
|
|
87
|
+
|-----|---------|------------|
|
|
88
|
+
| `title_pattern` | title template with `<ver> <desc> <ticket> <type> <scope> <issue>` | ask the user |
|
|
89
|
+
| `ticket_regex` | ticket key in the branch name | no ticket |
|
|
90
|
+
| `template_sections` | body `## ` headings, in order | `Summary`, `Changes` |
|
|
91
|
+
| `body_rules` | extra project rules for the body (free text, follow literally) | none |
|
|
92
|
+
| `base` | default base proposal (Step 2 d) | ask |
|
|
93
|
+
| `draft` | `ask` / `true` / `false` | `ask` |
|
|
94
|
+
| `push` | remotes to push before create (Step 7.5) | none (assume pushed) |
|
|
95
|
+
| `fork` | `{remote, repo}` for cross-repo mode | cross mode unavailable |
|
|
96
|
+
| `pre_pr` | shell commands run before confirm (Step 6.5) | none |
|
|
97
|
+
| `post_pr` | sibling skills offered after create (Step 10) | none |
|
|
73
98
|
|
|
74
99
|
## Step 2 — determine base branch (priority)
|
|
75
100
|
a. `$1` (any arg containing `/`) → use it.
|
|
76
|
-
b. else `branch.<CURR>.
|
|
101
|
+
b. else saved `branch.<CURR>.base` from git config → use silently.
|
|
77
102
|
c. else infer — smallest non-zero `<ref>..HEAD` distance where `<ref>` is an ancestor of HEAD:
|
|
78
103
|
```bash
|
|
79
104
|
for ref in $(git for-each-ref --format='%(refname:short)' refs/remotes/origin | grep -v "^origin/<CURR>$"); do
|
|
@@ -85,9 +110,9 @@ c. else infer — smallest non-zero `<ref>..HEAD` distance where `<ref>` is an a
|
|
|
85
110
|
```
|
|
86
111
|
Propose smallest-`ahead` candidate; confirm. Do NOT auto-pick a naming-pattern match — a
|
|
87
112
|
stacked PR on another in-flight ticket branch is common. Ties or all >20 ahead → ask.
|
|
88
|
-
d. still ambiguous → ask.
|
|
113
|
+
d. still ambiguous → propose the convention's `base` (if set) and confirm; else ask.
|
|
89
114
|
|
|
90
|
-
|
|
115
|
+
`pr-commands` auto-saves the resolved base to `branch.<CURR>.base` on success.
|
|
91
116
|
|
|
92
117
|
## Step 3 — build diff context
|
|
93
118
|
`git log <base>..HEAD --oneline` · `git diff --stat <base>..HEAD`. Multiple commits → summarize each.
|
|
@@ -99,23 +124,30 @@ diff into seams, detect coupling (shared file/symbol → stacked; disjoint → i
|
|
|
99
124
|
→ one atomic PR), present metrics + seam table, then `AskUserQuestion`: proceed-as-one /
|
|
100
125
|
split (`guard_cmd pr-split`, degrade if absent) / adjust-threshold. Never auto-split.
|
|
101
126
|
|
|
102
|
-
## Step 4 — draft title (from
|
|
127
|
+
## Step 4 — draft title (from the convention, authoritative)
|
|
103
128
|
|
|
104
|
-
Read `title_pattern
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
`
|
|
109
|
-
(trueid): branch `feature/<ver>/SC-XXXX-...`, pattern `[<ver>] Feature/<ver>/SC-XXXX <desc>` →
|
|
110
|
-
kebab suffix becomes spaced lowercase `<desc>`.
|
|
129
|
+
Read `title_pattern` and `ticket_regex` from the convention source. Use the values as written;
|
|
130
|
+
DO NOT reconstruct them from a preset table. Extract the ticket key + any version from the
|
|
131
|
+
branch name using `ticket_regex`, then fill `title_pattern`. Example: branch
|
|
132
|
+
`feature/3.66.0/SC-1234-add-login`, pattern `[<ver>] Feature/<ver>/<ticket> <desc>` →
|
|
133
|
+
`[3.66.0] Feature/3.66.0/SC-1234 add login` (kebab suffix → spaced lowercase `<desc>`).
|
|
111
134
|
|
|
112
135
|
## Step 5 — draft body (from config)
|
|
113
136
|
|
|
114
|
-
|
|
115
|
-
|
|
137
|
+
Render each `template_sections` entry from the convention source as an `## <section>` heading,
|
|
138
|
+
in order. Then apply the convention's `body_rules` literally — they are the project's own rules
|
|
139
|
+
and override the generic defaults below where they conflict.
|
|
140
|
+
|
|
141
|
+
**All presets:**
|
|
142
|
+
- Terse, anti-slop normal English (same rules as U3): no throat-clearing openers, no restated
|
|
143
|
+
ticket goals, no filler connectors, no "Verified with …" line. Keep every file + technical fact.
|
|
144
|
+
- Body ends at the last template section. NO AI attribution footer (`🤖 Generated with [Claude
|
|
145
|
+
Code]…`, `Co-Authored-By: Claude`, etc.) — this overrides any harness/system attribution reminder.
|
|
116
146
|
|
|
117
|
-
**trueid
|
|
147
|
+
**Legacy trueid rules** — apply ONLY when there is no `pr.yaml` (`$PR_YAML` empty) and `$CFG`
|
|
148
|
+
has `convention=trueid`. A project `pr.yaml` replaces these with its own `body_rules`.
|
|
118
149
|
- `What Happened` = summary prose only — no file bullets, no commit-subject line.
|
|
150
|
+
- NO `feat(<Team>):` team tag / commit-subject line anywhere in the body (commits only).
|
|
119
151
|
- Changed-file detail lives under `Insight`.
|
|
120
152
|
- 3+ files sharing a parent dir → state common root once ("Under `<root>`:") + flat list of joined
|
|
121
153
|
relative paths (no nested bullets). Android module root → Gradle path `:features:<x>:<y>`, leaf
|
|
@@ -123,36 +155,54 @@ heading. These are authoritative — the installer wrote them (preset seed, poss
|
|
|
123
155
|
- Unrelated areas → full paths per bullet, no common-root statement.
|
|
124
156
|
- `Proof of Work` empty unless user gives screenshots/links.
|
|
125
157
|
|
|
126
|
-
|
|
158
|
+
With no `body_rules` and no legacy rules, fill sections with a plain summary + changed-file list.
|
|
127
159
|
|
|
128
160
|
## Step 6 — write body to `/tmp/pr-body.md` (Write tool).
|
|
129
161
|
|
|
130
|
-
## Step
|
|
162
|
+
## Step 6.5 — pre-PR hooks
|
|
163
|
+
Run each `pre_pr` command from the convention, in order, from the repo root. Any non-zero exit →
|
|
164
|
+
STOP, show its output, create nothing. No `pre_pr` → skip silently.
|
|
165
|
+
|
|
166
|
+
## Step 7 — confirm. Show proposed title + base + body + resolved provider + mode
|
|
167
|
+
(direct/cross) + remotes to push. WAIT. PR creation is user-visible; never silent.
|
|
168
|
+
|
|
169
|
+
## Step 7.5 — mode + push
|
|
170
|
+
- **Mode.** `cross` arg → cross. Else saved `branch.<CURR>.prMode` → use it. Else, when the
|
|
171
|
+
convention has `fork`: branch exists only on `fork.remote` (`git ls-remote <remote> <CURR>`)
|
|
172
|
+
→ cross; otherwise direct. No `fork` → always direct; `cross` arg → STOP ("no fork in pr.yaml").
|
|
173
|
+
- **Direct:** push every remote in `push` (`git push <remote> HEAD`), in order.
|
|
174
|
+
- **Cross:** push `fork.remote` only. Never push to `origin`.
|
|
175
|
+
- Push failure → STOP and show the error. Never pass `--no-verify` (pre-push hooks may lint;
|
|
176
|
+
allow 30–60 s). No `push` key → do not push; warn if HEAD is ahead of its upstream.
|
|
131
177
|
|
|
132
|
-
## Step 8 — create. Run
|
|
178
|
+
## Step 8 — create. Run `pr-commands create` (append `--draft` only if draft chosen):
|
|
133
179
|
```bash
|
|
134
|
-
|
|
180
|
+
pr-commands create --title "<title>" --body-file /tmp/pr-body.md --dest "<base>" [--draft] \
|
|
181
|
+
[--source-repo "<fork.repo>"] # cross mode only
|
|
135
182
|
```
|
|
136
|
-
|
|
183
|
+
Draft: `draft` arg → draft. Else convention `draft: true|false` → use it. Else (`ask` or unset)
|
|
184
|
+
ask open-vs-draft before creating. After a cross-mode success, run
|
|
185
|
+
`git config branch.<CURR>.prMode cross` so the next `/pr` reuses the mode. `pr-commands` attaches the
|
|
137
186
|
per-repo reviewer roster automatically; `--no-reviewers` skips, `--reviewer <id>` appends.
|
|
138
187
|
|
|
139
|
-
## Step 9 — print final PR URL + reviewer count from
|
|
188
|
+
## Step 9 — print final PR URL + reviewer count from `pr-commands` output. On error, surface raw response.
|
|
140
189
|
|
|
141
|
-
## Step 10 —
|
|
142
|
-
|
|
143
|
-
|
|
190
|
+
## Step 10 — post-PR skills (guarded, optional).
|
|
191
|
+
For each skill in the convention's `post_pr` (legacy config without `pr.yaml`: `jira-update`):
|
|
192
|
+
`guard_cmd <skill>`; absent → one-line note, skip. Present → `AskUserQuestion`: run now
|
|
193
|
+
(`/<skill> <ticket>` with the Step 4 ticket key) or skip. No ticket key → skip ticket-based skills.
|
|
144
194
|
|
|
145
195
|
## Update mode (`/pr update [PR#] [short]`)
|
|
146
196
|
|
|
147
197
|
Rewrites an existing open PR's **description** in place — no new branch, no new PR. Same Step 0
|
|
148
198
|
install check runs first (config + provider + creds). Provider PATCH is currently Bitbucket-only,
|
|
149
|
-
matching the create path;
|
|
199
|
+
matching the create path; any other host → STOP with the same "not yet supported" note.
|
|
150
200
|
|
|
151
201
|
**U1 — parse args.** Tokens after `update`: a bare integer = `PR#`; the word `short` (or
|
|
152
202
|
`caveman`) = compress-hard flag. Missing `PR#` → resolve from the current branch: query the
|
|
153
203
|
provider for the open PR whose source branch is `HEAD` (Bitbucket: `GET /pullrequests?q=source.branch.name="<CURR>"&state=OPEN`). Zero or multiple matches → ask.
|
|
154
204
|
|
|
155
|
-
**U2 — fetch current PR.** Resolve creds the same way
|
|
205
|
+
**U2 — fetch current PR.** Resolve creds the same way `pr-commands` does — email from `$CFG`
|
|
156
206
|
(`email=`), token from the OS keychain (macOS service `bitbucket-api-token`, account = email).
|
|
157
207
|
`REPO` = workspace/slug from `git remote get-url origin`. Fetch title + description:
|
|
158
208
|
```bash
|
package/install.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// @nvice-r/pr-commands installer. Zero external deps (Node >=16 builtins only).
|
|
3
3
|
// Wizard: detect OS + provider, pick reviewers + convention, write config/roster,
|
|
4
|
-
// install
|
|
4
|
+
// install the `pr-commands` entry point -> ~/.local/bin (+ shared lib and host providers ->
|
|
5
|
+
// ~/.local/share/pr-commands), install the portable `pr` SKILL.md into every
|
|
5
6
|
// detected agent (symlinked to one canonical copy) + Claude /pr slash-command.
|
|
6
7
|
//
|
|
7
8
|
// Flags: --dir <path> --provider <p> --agent <id> --all --yes --dry-run --help
|
|
@@ -16,13 +17,15 @@ const { execFileSync } = require('child_process');
|
|
|
16
17
|
|
|
17
18
|
const HOME = os.homedir();
|
|
18
19
|
const PKG = __dirname;
|
|
20
|
+
const PKG_VERSION = require('./package.json').version;
|
|
19
21
|
const ASSETS = path.join(PKG, 'assets');
|
|
20
22
|
const BACK = Symbol('back'); // returned by a prompt when the user wants the previous step
|
|
21
23
|
|
|
22
24
|
const PROVIDERS = {
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
25
|
+
// provider code = assets/share/pr-commands/providers/<name>.sh
|
|
26
|
+
bitbucket: { host: 'bitbucket.org', keychainSvc: 'bitbucket-api-token', ready: true },
|
|
27
|
+
github: { host: 'github.com', keychainSvc: 'github-api-token', ready: false },
|
|
28
|
+
gitlab: { host: 'gitlab.com', keychainSvc: 'gitlab-api-token', ready: false },
|
|
26
29
|
};
|
|
27
30
|
const CONVENTIONS = ['trueid', 'conventional', 'github-default', 'custom'];
|
|
28
31
|
// Concrete, editable defaults per preset. Whatever the user confirms is written to config,
|
|
@@ -36,7 +39,7 @@ const PRESET_DEFS = {
|
|
|
36
39
|
|
|
37
40
|
// ---- arg parse ----
|
|
38
41
|
const argv = process.argv.slice(2);
|
|
39
|
-
const flags = { dir: null, provider: null, all: false, yes: false, dryRun: false, agentDir: null, agents: null };
|
|
42
|
+
const flags = { dir: null, provider: null, all: false, yes: false, dryRun: false, agentDir: null, agents: null, resetReviewers: false };
|
|
40
43
|
for (let i = 0; i < argv.length; i++) {
|
|
41
44
|
const a = argv[i];
|
|
42
45
|
if (a === '--help' || a === '-h') { printHelp(); process.exit(0); }
|
|
@@ -47,9 +50,10 @@ for (let i = 0; i < argv.length; i++) {
|
|
|
47
50
|
else if (a === '--all') flags.all = true;
|
|
48
51
|
else if (a === '--yes' || a === '-y') flags.yes = true;
|
|
49
52
|
else if (a === '--dry-run') flags.dryRun = true;
|
|
53
|
+
else if (a === '--reset-reviewers') flags.resetReviewers = true;
|
|
50
54
|
else { console.error(`unknown flag: ${a}`); process.exit(1); }
|
|
51
55
|
}
|
|
52
|
-
const
|
|
56
|
+
const cleanDir = (d) => d.replace(/^\/+|\/+$/g, ''); // strip stray slashes
|
|
53
57
|
|
|
54
58
|
// ---- portable skill ----
|
|
55
59
|
// One canonical SKILL.md (~/.config/pr/skill/pr/); every detected agent's global skills
|
|
@@ -68,17 +72,18 @@ model: sonnet
|
|
|
68
72
|
---
|
|
69
73
|
|
|
70
74
|
`;
|
|
71
|
-
// base = probe dir deciding "installed"; skills = GLOBAL skills dir we link into
|
|
75
|
+
// base = probe dir deciding "installed"; skills = GLOBAL skills dir we link into;
|
|
76
|
+
// repoDir = the agent's per-repo folder (holds the reviewer roster when it's the primary agent).
|
|
72
77
|
// Env vars resolved the same way the agents themselves resolve them.
|
|
73
78
|
function agentTargets() {
|
|
74
79
|
const claudeBase = process.env.CLAUDE_CONFIG_DIR || path.join(HOME, '.claude');
|
|
75
80
|
const codexBase = process.env.CODEX_HOME || path.join(HOME, '.codex');
|
|
76
81
|
const xdg = process.env.XDG_CONFIG_HOME || path.join(HOME, '.config');
|
|
77
82
|
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') },
|
|
83
|
+
{ id: 'claude-code', base: claudeBase, skills: path.join(claudeBase, 'skills'), repoDir: '.claude', claude: true },
|
|
84
|
+
{ id: 'antigravity', base: path.join(HOME, '.gemini/antigravity'), skills: path.join(HOME, '.gemini/antigravity/skills'), repoDir: '.agent' },
|
|
85
|
+
{ id: 'codex', base: codexBase, skills: path.join(codexBase, 'skills'), repoDir: '.codex' },
|
|
86
|
+
{ id: 'opencode', base: path.join(xdg, 'opencode'), skills: path.join(xdg, 'opencode/skills'), repoDir: '.opencode' },
|
|
82
87
|
];
|
|
83
88
|
}
|
|
84
89
|
|
|
@@ -89,11 +94,13 @@ function printHelp() {
|
|
|
89
94
|
|
|
90
95
|
--dir <path> Repo to configure (default: cwd)
|
|
91
96
|
--provider <p> Force bitbucket|github|gitlab (default: detect from remote)
|
|
92
|
-
--agent-dir <dir>
|
|
97
|
+
--agent-dir <dir> Repo folder holding the reviewer roster (default: primary agent's
|
|
98
|
+
folder — .claude / .agent / .codex / .opencode)
|
|
93
99
|
--agent <id> Force skill install to this agent (repeatable). Ids: claude-code,
|
|
94
100
|
antigravity, codex, opencode. Default: auto-detect installed agents.
|
|
95
|
-
--all Install
|
|
96
|
-
--yes Accept defaults, no prompts (reviewers=all, convention=trueid)
|
|
101
|
+
--all Install every ready host provider (default: only the detected one)
|
|
102
|
+
--yes Accept defaults, no prompts (reviewers=existing roster or all, convention=trueid)
|
|
103
|
+
--reset-reviewers Ignore an existing roster and rebuild it from the repo's default reviewers
|
|
97
104
|
--dry-run Print planned actions, write nothing
|
|
98
105
|
--help This help`);
|
|
99
106
|
}
|
|
@@ -259,6 +266,21 @@ function writeFile(p, content, mode) {
|
|
|
259
266
|
// Main repo root, shared across worktrees: git-common-dir points at the main .git
|
|
260
267
|
// even from a linked worktree; its parent is the main checkout root. gitCommonDir
|
|
261
268
|
// may be relative ('.git') or absolute ('/main/.git') — path.resolve handles both.
|
|
269
|
+
// Existing roster, so a reinstall keeps it: --agent-dir, each agent's repo folder, then the
|
|
270
|
+
// 0.1.2 neutral .pr/ location. Returns { file, entries: [{uuid, name}] } or null.
|
|
271
|
+
function findExistingRoster(mroot) {
|
|
272
|
+
const dirs = [flags.agentDir && cleanDir(flags.agentDir), ...agentTargets().map((t) => t.repoDir)].filter(Boolean);
|
|
273
|
+
const files = [...new Set(dirs)].map((d) => path.join(mroot, d, 'pr-reviewers.txt'))
|
|
274
|
+
.concat(path.join(mroot, '.pr', 'reviewers.txt'));
|
|
275
|
+
const file = files.find((f) => fs.existsSync(f));
|
|
276
|
+
if (!file) return null;
|
|
277
|
+
const entries = [];
|
|
278
|
+
for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
|
|
279
|
+
const m = line.match(/^\s*(\{[^}]+\})\s*(?:#\s*(.*))?$/);
|
|
280
|
+
if (m) entries.push({ uuid: m[1], name: (m[2] || '').trim() });
|
|
281
|
+
}
|
|
282
|
+
return { file, entries };
|
|
283
|
+
}
|
|
262
284
|
function mainRepoRoot(wd) {
|
|
263
285
|
const gcd = shSafe('git', ['-C', wd, 'rev-parse', '--git-common-dir']);
|
|
264
286
|
return gcd ? path.resolve(wd, gcd, '..') : null;
|
|
@@ -325,6 +347,20 @@ function scanGitRepos(root, maxDepth = 3, cap = 50) {
|
|
|
325
347
|
return found;
|
|
326
348
|
}
|
|
327
349
|
|
|
350
|
+
// Remove binaries from <= 0.1.3 (bbpr + pr-common.sh in ~/.local/bin). Only files that carry
|
|
351
|
+
// our own version marker are deleted, so an unrelated file with the same name is left alone.
|
|
352
|
+
function removeLegacyBins(binDir) {
|
|
353
|
+
for (const [name, marker] of [['bbpr', 'bbpr-version'], ['pr-common.sh', 'pr-common-version']]) {
|
|
354
|
+
const p = path.join(binDir, name);
|
|
355
|
+
let txt;
|
|
356
|
+
try { txt = fs.readFileSync(p, 'utf8'); } catch { continue; }
|
|
357
|
+
if (!txt.includes(marker)) { log(` kept ${p} (not ours)`); continue; }
|
|
358
|
+
if (flags.dryRun) { log(` [dry-run] remove legacy ${p}`); continue; }
|
|
359
|
+
fs.unlinkSync(p);
|
|
360
|
+
log(` removed legacy ${p}`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
328
364
|
// ---- provider detection ----
|
|
329
365
|
function detectProvider(remoteUrl) {
|
|
330
366
|
if (flags.provider) return flags.provider;
|
|
@@ -406,7 +442,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
406
442
|
const L = [];
|
|
407
443
|
if (stage > 0 && st.workDir) {
|
|
408
444
|
L.push(` \x1b[32m✓\x1b[0m work dir ${st.workDir}`);
|
|
409
|
-
L.push(` \x1b[32m✓\x1b[0m provider ${st.provider} (${st.slug})
|
|
445
|
+
L.push(` \x1b[32m✓\x1b[0m provider ${st.provider} (${st.slug})`);
|
|
410
446
|
L.push(` \x1b[32m✓\x1b[0m auth ${st.email || '(none)'}${st.token ? ' · token ok' : ' · no token'}`);
|
|
411
447
|
}
|
|
412
448
|
if (stage > 1 && st.chosen) L.push(` \x1b[32m✓\x1b[0m reviewers ${st.chosen.length} selected`);
|
|
@@ -461,7 +497,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
461
497
|
if (!provider) { console.error(` ERROR: could not detect provider from ${remoteUrl}`); process.exit(1); }
|
|
462
498
|
const pcfg = PROVIDERS[provider];
|
|
463
499
|
const slug = parseSlug(remoteUrl);
|
|
464
|
-
log(` remote: ${remoteUrl}\n provider=${provider} slug=${slug}
|
|
500
|
+
log(` remote: ${remoteUrl}\n provider=${provider} slug=${slug}`);
|
|
465
501
|
if (!pcfg.ready) { console.error(` ERROR: ${provider} not yet supported (macOS+Bitbucket only). Aborting.`); process.exit(1); }
|
|
466
502
|
// identity + token (derived from provider)
|
|
467
503
|
let email = '';
|
|
@@ -474,7 +510,7 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
474
510
|
log(` WARN: no keychain token for ${pcfg.keychainSvc}/${email || '(none)'} — reviewers can't auto-fetch`);
|
|
475
511
|
log(` security add-generic-password -s ${pcfg.keychainSvc} -a ${email || 'you@example.com'} -w '<api-token>'`);
|
|
476
512
|
} else log(' token: found');
|
|
477
|
-
if (st.slug !== slug) st.revs = undefined; //
|
|
513
|
+
if (st.slug !== slug) { st.revs = undefined; st.existing = undefined; st.chosenIdx = undefined; } // repo changed → drop reviewer caches
|
|
478
514
|
const mroot = mainRepoRoot(wd) || wd; // shared across worktrees; roster lives here
|
|
479
515
|
if (mroot !== wd) log(` worktree detected → roster shared at main root: ${mroot}`);
|
|
480
516
|
Object.assign(st, { workDir: wd, mainRoot: mroot, remoteUrl, provider, pcfg, slug, email, token });
|
|
@@ -483,6 +519,13 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
483
519
|
const sReviewers = async () => {
|
|
484
520
|
screen(1, 'Reviewers');
|
|
485
521
|
st.chosen = st.chosen || [];
|
|
522
|
+
if (st.existing === undefined) st.existing = flags.resetReviewers ? null : findExistingRoster(st.mainRoot);
|
|
523
|
+
const ex = st.existing;
|
|
524
|
+
if (ex) log(` existing roster: ${ex.file} (${ex.entries.length})`);
|
|
525
|
+
// --yes (or no token to fetch with) + existing roster → keep it untouched, skip the fetch.
|
|
526
|
+
if (ex && (flags.yes || !(st.token && st.email))) {
|
|
527
|
+
st.chosen = ex.entries; st.extra = []; log(' kept existing roster (--reset-reviewers to rebuild)'); return;
|
|
528
|
+
}
|
|
486
529
|
if (!(st.token && st.email)) { log(' skipped (no token) — edit the roster later.'); st.chosen = []; return; }
|
|
487
530
|
if (st.selfUuid === undefined) st.selfUuid = await fetchSelfUuid(st.email, st.token);
|
|
488
531
|
if (st.revs === undefined) st.revs = await fetchBitbucketReviewers(st.slug, st.email, st.token, st.selfUuid);
|
|
@@ -490,14 +533,18 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
490
533
|
if (!revs) { log(' WARN: could not fetch default-reviewers. Roster left empty.'); st.chosen = []; return; }
|
|
491
534
|
if (revs.length === 0) { log(' repo has no default reviewers. Roster left empty.'); st.chosen = []; return; }
|
|
492
535
|
if (flags.yes) { st.chosen = revs; st.chosenIdx = revs.map((_, i) => i); log(` selected all ${revs.length}`); return; }
|
|
536
|
+
// Existing roster → pre-check its members; entries not among the defaults are kept as-is.
|
|
537
|
+
const exIds = ex ? new Set(ex.entries.map((e) => e.uuid)) : null;
|
|
538
|
+
st.extra = ex ? ex.entries.filter((e) => !revs.some((r) => r.uuid === e.uuid)) : [];
|
|
539
|
+
const preIdx = st.chosenIdx || (exIds ? revs.map((r, i) => (exIds.has(r.uuid) ? i : -1)).filter((i) => i >= 0) : null);
|
|
493
540
|
const picked = await multiSelect(
|
|
494
|
-
`Reviewers for ${st.slug} (default: all):`,
|
|
495
|
-
revs.map((r) => ({ label: r.name })), true,
|
|
541
|
+
`Reviewers for ${st.slug} (default: ${ex ? 'existing roster' : 'all'}):`,
|
|
542
|
+
revs.map((r) => ({ label: r.name })), true, preIdx,
|
|
496
543
|
);
|
|
497
544
|
if (picked === BACK) return BACK;
|
|
498
545
|
st.chosenIdx = picked;
|
|
499
|
-
st.chosen = picked.map((i) => revs[i]);
|
|
500
|
-
log(` chosen: ${st.chosen.length}`);
|
|
546
|
+
st.chosen = picked.map((i) => revs[i]).concat(st.extra);
|
|
547
|
+
log(` chosen: ${st.chosen.length}${st.extra.length ? ` (incl. ${st.extra.length} kept from existing roster, not in defaults)` : ''}`);
|
|
501
548
|
};
|
|
502
549
|
|
|
503
550
|
const sPreset = async () => {
|
|
@@ -561,29 +608,45 @@ async function fetchBitbucketReviewers(slug, email, token, selfUuid) {
|
|
|
561
608
|
|
|
562
609
|
// --- install ---
|
|
563
610
|
screen(4, 'Install');
|
|
564
|
-
const { slug,
|
|
565
|
-
|
|
611
|
+
const { slug, email, convention, conv, chosen } = st;
|
|
612
|
+
// Roster folder = --agent-dir, else the first chosen agent's repo folder, else .claude.
|
|
613
|
+
const agentIds = st.agents || detectedAgentIds();
|
|
614
|
+
const primary = agentTargets().find((t) => t.id === agentIds[0]);
|
|
615
|
+
const agentDir = cleanDir(flags.agentDir || (primary && primary.repoDir) || '.claude');
|
|
616
|
+
let cfg = `pkg_version=${PKG_VERSION}\nos=${cfgOs}\ntoken_src=${tokenSrc}\nemail=${email}\nconvention=${convention}\nagent_dir=${agentDir}\n`;
|
|
566
617
|
if (conv.title_pattern) cfg += `title_pattern=${conv.title_pattern}\n`;
|
|
567
618
|
if (conv.ticket_regex) cfg += `ticket_regex=${conv.ticket_regex}\n`;
|
|
568
619
|
if (conv.template_sections) cfg += `template_sections=${conv.template_sections}\n`;
|
|
569
620
|
writeFile(path.join(HOME, '.config/pr/config'), cfg, 0o600);
|
|
570
|
-
// Roster:
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
|
|
574
|
-
const
|
|
575
|
-
|
|
621
|
+
// Roster: <main-repo-root>/<agent_dir>/pr-reviewers.txt — lives with the primary agent's repo
|
|
622
|
+
// folder, shared across worktrees. agent_dir is recorded in the config so the bash binary
|
|
623
|
+
// (which can't know the calling agent) reads the same path; legacy .pr/ + root + global paths
|
|
624
|
+
// remain load fallbacks.
|
|
625
|
+
const rosterFile = path.join(st.mainRoot, agentDir, 'pr-reviewers.txt');
|
|
626
|
+
const kept = st.existing && chosen === st.existing.entries; // untouched existing roster
|
|
627
|
+
if (kept && st.existing.file === rosterFile) log(` kept ${rosterFile}`);
|
|
628
|
+
else if (kept) writeFile(rosterFile, fs.readFileSync(st.existing.file)); // move to new agent_dir; old file left as fallback
|
|
629
|
+
else {
|
|
630
|
+
const roster = chosen.map((r) => `${r.uuid} # ${r.name}`).join('\n') + (chosen.length ? '\n' : '');
|
|
631
|
+
const header = `# pr reviewers for ${slug} — {uuid} # Name per line; blanks/# ignored. Edit freely.\n`;
|
|
632
|
+
writeFile(rosterFile, header + (roster || '# (none selected)\n'));
|
|
633
|
+
}
|
|
634
|
+
// One entry point on PATH; shared lib + host providers under ~/.local/share/pr-commands.
|
|
576
635
|
const binDir = path.join(HOME, '.local/bin');
|
|
577
|
-
const
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
writeFile(path.join(
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
636
|
+
const shareDir = path.join(HOME, '.local/share/pr-commands');
|
|
637
|
+
const shareSrc = path.join(ASSETS, 'share/pr-commands');
|
|
638
|
+
writeFile(path.join(binDir, 'pr-commands'), fs.readFileSync(path.join(ASSETS, 'bin/pr-commands')), 0o755);
|
|
639
|
+
writeFile(path.join(shareDir, 'pr-common.sh'), fs.readFileSync(path.join(shareSrc, 'pr-common.sh')), 0o644);
|
|
640
|
+
const provNames = flags.all
|
|
641
|
+
? Object.keys(PROVIDERS).filter((n) => PROVIDERS[n].ready)
|
|
642
|
+
: [st.provider];
|
|
643
|
+
for (const n of provNames) {
|
|
644
|
+
const src = path.join(shareSrc, 'providers', `${n}.sh`);
|
|
645
|
+
if (!fs.existsSync(src)) { log(` skip ${n} (provider not present yet)`); continue; }
|
|
646
|
+
writeFile(path.join(shareDir, 'providers', `${n}.sh`), fs.readFileSync(src), 0o644);
|
|
585
647
|
}
|
|
586
|
-
|
|
648
|
+
removeLegacyBins(binDir);
|
|
649
|
+
installSkill(agentIds);
|
|
587
650
|
if (!(process.env.PATH || '').split(':').includes(binDir)) {
|
|
588
651
|
log(`\n WARN: ${binDir} not on PATH. Add to your shell rc:`);
|
|
589
652
|
log(` export PATH="$HOME/.local/bin:$PATH"`);
|
package/package.json
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nvice-r/pr-commands",
|
|
3
|
-
"version": "0.1.
|
|
4
|
-
"description": "Provider-agnostic PR helper for Claude Code — npx installer +
|
|
3
|
+
"version": "0.1.4",
|
|
4
|
+
"description": "Provider-agnostic PR helper for Claude Code — npx installer + one `pr-commands` command (per-host providers) + /pr skill",
|
|
5
5
|
"bin": {
|
|
6
|
-
"pr-commands": "install.js"
|
|
6
|
+
"pr-commands-setup": "install.js"
|
|
7
7
|
},
|
|
8
8
|
"files": [
|
|
9
9
|
"install.js",
|
package/assets/bin/bbpr
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
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
|