fleet-agents 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/LICENSE +21 -0
- package/README.md +144 -0
- package/bin/fleet.js +1018 -0
- package/commands/fleet.md +78 -0
- package/package.json +40 -0
- package/prompts/fix.md +46 -0
- package/prompts/research.md +38 -0
- package/prompts/review.md +51 -0
- package/review/README.md +60 -0
- package/review/cli.sh +63 -0
- package/review/coverage.sh +90 -0
- package/review/fix.sh +91 -0
- package/review/lib.sh +258 -0
- package/review/merge.sh +374 -0
- package/review/prompts/fix.md +190 -0
- package/review/prompts/review.md +170 -0
- package/review/review.sh +72 -0
- package/review/sync.sh +15 -0
package/review/lib.sh
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Shared helpers for scripts/shortcuts/review/*.sh
|
|
3
|
+
# Source this file; do not execute directly.
|
|
4
|
+
|
|
5
|
+
set -euo pipefail
|
|
6
|
+
|
|
7
|
+
# Repo that hosts the PR. Override with REVIEW_REPO=owner/name if needed;
|
|
8
|
+
# otherwise we derive it from the `upstream` remote, falling back to `origin`.
|
|
9
|
+
resolve_repo() {
|
|
10
|
+
if [ -n "${REVIEW_REPO:-}" ]; then
|
|
11
|
+
echo "$REVIEW_REPO"
|
|
12
|
+
return
|
|
13
|
+
fi
|
|
14
|
+
local url
|
|
15
|
+
url=$(git remote get-url upstream 2>/dev/null || git remote get-url origin)
|
|
16
|
+
# Accept git@github.com:owner/name(.git) and https://github.com/owner/name(.git)
|
|
17
|
+
echo "$url" \
|
|
18
|
+
| sed -E 's#^git@github\.com:##; s#^https?://github\.com/##; s#\.git$##'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
require() {
|
|
22
|
+
local bin
|
|
23
|
+
for bin in "$@"; do
|
|
24
|
+
command -v "$bin" >/dev/null 2>&1 || {
|
|
25
|
+
echo "[review] missing required tool: $bin" >&2
|
|
26
|
+
exit 1
|
|
27
|
+
}
|
|
28
|
+
done
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
# Run the picked agent CLI on a single positional prompt. Each known agent
|
|
32
|
+
# is launched in its equivalent "yolo" mode so headless / detached runs
|
|
33
|
+
# (CI, background tasks, tmux workers) don't stall on per-tool permission
|
|
34
|
+
# prompts that have no responder. Set REVIEW_AGENT_SAFE=1 to keep the
|
|
35
|
+
# prompts (e.g. an interactive local run where you want to vet each step).
|
|
36
|
+
#
|
|
37
|
+
# Mirrors the precedent in bin/spawn-issue, which already passes
|
|
38
|
+
# --dangerously-skip-permissions to its detached claude workers, and brings
|
|
39
|
+
# the claude path in line with the existing codex / cursor handling.
|
|
40
|
+
agent_exec() {
|
|
41
|
+
local agent="$1"
|
|
42
|
+
local prompt="$2"
|
|
43
|
+
if [ "${REVIEW_AGENT_SAFE:-0}" = "1" ]; then
|
|
44
|
+
case "$agent" in
|
|
45
|
+
codex) exec codex "$prompt" ;;
|
|
46
|
+
claude) exec claude "$prompt" ;;
|
|
47
|
+
*) exec "$agent" "$prompt" ;;
|
|
48
|
+
esac
|
|
49
|
+
return
|
|
50
|
+
fi
|
|
51
|
+
case "$agent" in
|
|
52
|
+
claude)
|
|
53
|
+
exec claude --dangerously-skip-permissions "$prompt"
|
|
54
|
+
;;
|
|
55
|
+
codex)
|
|
56
|
+
exec codex --dangerously-bypass-approvals-and-sandbox "$prompt"
|
|
57
|
+
;;
|
|
58
|
+
cursor|cursor-agent)
|
|
59
|
+
exec cursor-agent --yolo "$prompt"
|
|
60
|
+
;;
|
|
61
|
+
*)
|
|
62
|
+
exec "$agent" "$prompt"
|
|
63
|
+
;;
|
|
64
|
+
esac
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
gh_assign_self_issue() {
|
|
68
|
+
local issue="$1"
|
|
69
|
+
local repo="$2"
|
|
70
|
+
if gh issue edit "$issue" -R "$repo" --add-assignee "@me" >/dev/null 2>&1; then
|
|
71
|
+
info "assigned issue #$issue to @me"
|
|
72
|
+
else
|
|
73
|
+
warn "could not assign issue #$issue to @me; continuing"
|
|
74
|
+
fi
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
gh_assign_self_pr() {
|
|
78
|
+
local pr="$1"
|
|
79
|
+
local repo="$2"
|
|
80
|
+
if gh pr edit "$pr" -R "$repo" --add-assignee "@me" >/dev/null 2>&1; then
|
|
81
|
+
info "assigned PR #$pr to @me"
|
|
82
|
+
else
|
|
83
|
+
warn "could not assign PR #$pr to @me; continuing"
|
|
84
|
+
fi
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
# Summarize free-form text via a local LLM CLI (expects `-p <prompt>`).
|
|
88
|
+
# Usage: summarize_text <tool> <input>
|
|
89
|
+
# Tools used here: gemini (default for summaries), claude, or any CLI that
|
|
90
|
+
# accepts `-p "<prompt>"` and prints the response to stdout.
|
|
91
|
+
# Special value `none` echoes input unchanged.
|
|
92
|
+
summarize_text() {
|
|
93
|
+
local tool="$1"
|
|
94
|
+
local input="$2"
|
|
95
|
+
if [ "$tool" = "none" ] || [ "$tool" = "raw" ]; then
|
|
96
|
+
printf '%s' "$input"
|
|
97
|
+
return
|
|
98
|
+
fi
|
|
99
|
+
require "$tool"
|
|
100
|
+
local prompt
|
|
101
|
+
prompt=$(cat <<'EOF'
|
|
102
|
+
You are writing the body of a squash-merge commit.
|
|
103
|
+
Summarize the PR changes below into 3-6 short bullet points.
|
|
104
|
+
Rules:
|
|
105
|
+
- Start each bullet with "- " and use imperative mood ("Add…", "Fix…", "Rename…").
|
|
106
|
+
- One line per bullet, under ~100 chars.
|
|
107
|
+
- No headers, no code fences, no sign-offs, no Co-authored-by lines.
|
|
108
|
+
- Do not include the PR number or title.
|
|
109
|
+
- Output only the bullets, nothing else.
|
|
110
|
+
|
|
111
|
+
PR content:
|
|
112
|
+
---
|
|
113
|
+
EOF
|
|
114
|
+
)
|
|
115
|
+
"$tool" -p "${prompt}
|
|
116
|
+
${input}
|
|
117
|
+
---"
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
require_pr_number() {
|
|
121
|
+
if [ -z "${1:-}" ]; then
|
|
122
|
+
echo "Usage: $(basename "$0") <pr-number>" >&2
|
|
123
|
+
exit 1
|
|
124
|
+
fi
|
|
125
|
+
case "$1" in
|
|
126
|
+
''|*[!0-9]*)
|
|
127
|
+
echo "[review] pr-number must be numeric, got: $1" >&2
|
|
128
|
+
exit 1
|
|
129
|
+
;;
|
|
130
|
+
esac
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
# ── Coloured output ────────────────────────────────────────────────
|
|
134
|
+
# Disable colour when stdout is not a terminal (piped / CI).
|
|
135
|
+
if [ -t 1 ]; then
|
|
136
|
+
_R=$'\033[0;31m' _G=$'\033[0;32m' _Y=$'\033[0;33m' _B=$'\033[1m' _0=$'\033[0m'
|
|
137
|
+
else
|
|
138
|
+
_R="" _G="" _Y="" _B="" _0=""
|
|
139
|
+
fi
|
|
140
|
+
|
|
141
|
+
pass() { printf '%s[PASS]%s %s\n' "$_G" "$_0" "$*"; }
|
|
142
|
+
fail() { printf '%s[FAIL]%s %s\n' "$_R" "$_0" "$*" >&2; }
|
|
143
|
+
warn() { printf '%s[WARN]%s %s\n' "$_Y" "$_0" "$*" >&2; }
|
|
144
|
+
info() { printf '%s[INFO]%s %s\n' "$_B" "$_0" "$*"; }
|
|
145
|
+
|
|
146
|
+
# Fetch PR head into local branch pr/<num>, merge main in, wire upstream +
|
|
147
|
+
# pushRemote so `git push` lands on the contributor's fork.
|
|
148
|
+
sync_pr() {
|
|
149
|
+
local pr="$1"
|
|
150
|
+
local repo
|
|
151
|
+
repo=$(resolve_repo)
|
|
152
|
+
|
|
153
|
+
local info head_repo head_branch local_branch base_branch
|
|
154
|
+
info=$(gh pr view "$pr" -R "$repo" \
|
|
155
|
+
--json headRefName,headRepository,headRepositoryOwner,baseRefName)
|
|
156
|
+
head_repo=$(echo "$info" | jq -r '.headRepositoryOwner.login + "/" + .headRepository.name')
|
|
157
|
+
head_branch=$(echo "$info" | jq -r '.headRefName')
|
|
158
|
+
# The PR's actual base branch (e.g. develop) is authoritative; don't assume main.
|
|
159
|
+
base_branch=$(echo "$info" | jq -r '.baseRefName')
|
|
160
|
+
[ -z "$base_branch" ] || [ "$base_branch" = "null" ] && base_branch="main"
|
|
161
|
+
local_branch="pr/$pr"
|
|
162
|
+
|
|
163
|
+
echo "[review] PR #$pr -> $head_repo:$head_branch (base: $base_branch, local: $local_branch)"
|
|
164
|
+
|
|
165
|
+
# Fetch the PR head into local branch pr/<n> (refs are shared across worktrees).
|
|
166
|
+
git fetch origin "$base_branch"
|
|
167
|
+
# On a reused review worktree, pr/<n> is already checked out, and fetching
|
|
168
|
+
# directly into a checked-out branch is fatal. Only seed the branch on first
|
|
169
|
+
# creation; reuse refreshes it via the push remote further down.
|
|
170
|
+
if git rev-parse --verify --quiet "refs/heads/${local_branch}" >/dev/null; then
|
|
171
|
+
echo "[review] $local_branch already exists — skipping direct head fetch (refreshed via push remote below)"
|
|
172
|
+
else
|
|
173
|
+
git fetch "https://github.com/${head_repo}.git" \
|
|
174
|
+
"+${head_branch}:${local_branch}"
|
|
175
|
+
fi
|
|
176
|
+
|
|
177
|
+
local merge_ref="$base_branch"
|
|
178
|
+
if [ "${REVIEW_WORKTREE:-0}" = "1" ]; then
|
|
179
|
+
# Isolated review worktree: never touch the primary checkout's branch.
|
|
180
|
+
local wt="${REVIEW_WT_DIR:?[review] REVIEW_WT_DIR required in worktree mode}"
|
|
181
|
+
if [ -d "$wt" ]; then
|
|
182
|
+
echo "[review] reusing review worktree: $wt"
|
|
183
|
+
cd "$wt"
|
|
184
|
+
git checkout "$local_branch"
|
|
185
|
+
else
|
|
186
|
+
echo "[review] creating review worktree: $wt (branch $local_branch)"
|
|
187
|
+
mkdir -p "$(dirname "$wt")"
|
|
188
|
+
git worktree add --force "$wt" "$local_branch"
|
|
189
|
+
cd "$wt"
|
|
190
|
+
fi
|
|
191
|
+
# Bring the base branch in via a ref (it may be checked out elsewhere).
|
|
192
|
+
merge_ref="origin/$base_branch"
|
|
193
|
+
if git remote get-url upstream >/dev/null 2>&1; then
|
|
194
|
+
git fetch upstream
|
|
195
|
+
git rev-parse --verify "upstream/$base_branch" >/dev/null 2>&1 && merge_ref="upstream/$base_branch"
|
|
196
|
+
fi
|
|
197
|
+
else
|
|
198
|
+
echo "[review] syncing $base_branch from upstream..."
|
|
199
|
+
git checkout "$base_branch"
|
|
200
|
+
git pull origin "$base_branch"
|
|
201
|
+
git fetch upstream
|
|
202
|
+
git merge "upstream/$base_branch"
|
|
203
|
+
git checkout "$local_branch"
|
|
204
|
+
fi
|
|
205
|
+
# A stray gitlink without a matching .gitmodules entry (e.g. an accidentally
|
|
206
|
+
# committed .claude/worktrees/* path) makes this fatal under `set -e` and would
|
|
207
|
+
# abort the whole review. Submodules aren't needed to review a diff, so warn and
|
|
208
|
+
# continue rather than die.
|
|
209
|
+
git submodule update --init --recursive \
|
|
210
|
+
|| warn "submodule update failed (continuing) — likely a stale gitlink with no .gitmodules entry"
|
|
211
|
+
|
|
212
|
+
echo "[review] merging $merge_ref into $local_branch (conflicts will not abort)..."
|
|
213
|
+
REVIEW_HAS_CONFLICTS=0
|
|
214
|
+
REVIEW_CONFLICT_FILES=""
|
|
215
|
+
if ! git merge --no-edit "$merge_ref"; then
|
|
216
|
+
REVIEW_CONFLICT_FILES=$(git diff --name-only --diff-filter=U | sort -u)
|
|
217
|
+
if [ -z "$REVIEW_CONFLICT_FILES" ]; then
|
|
218
|
+
fail "git merge $merge_ref failed for a non-conflict reason"
|
|
219
|
+
return 1
|
|
220
|
+
fi
|
|
221
|
+
echo "[review] ! conflicts detected in PR #$pr, continuing."
|
|
222
|
+
REVIEW_HAS_CONFLICTS=1
|
|
223
|
+
fi
|
|
224
|
+
|
|
225
|
+
# Prefer an existing SSH remote pointing at this fork to avoid https auth prompts.
|
|
226
|
+
local remote_name="remote-$pr"
|
|
227
|
+
local existing_ssh
|
|
228
|
+
existing_ssh=$(git remote -v \
|
|
229
|
+
| awk -v repo="$head_repo" '$2 ~ ("[:/]" repo "(\\.git)?$") && $3 == "(fetch)" {print $1; exit}')
|
|
230
|
+
if [ -n "$existing_ssh" ]; then
|
|
231
|
+
remote_name="$existing_ssh"
|
|
232
|
+
echo "[review] reusing remote '$remote_name' -> $(git remote get-url "$remote_name")"
|
|
233
|
+
else
|
|
234
|
+
local remote_url="https://github.com/${head_repo}.git"
|
|
235
|
+
git remote add "$remote_name" "$remote_url" 2>/dev/null \
|
|
236
|
+
|| git remote set-url "$remote_name" "$remote_url"
|
|
237
|
+
fi
|
|
238
|
+
|
|
239
|
+
git fetch "$remote_name" \
|
|
240
|
+
"+refs/heads/${head_branch}:refs/remotes/${remote_name}/${head_branch}"
|
|
241
|
+
|
|
242
|
+
git branch --set-upstream-to="$remote_name/$head_branch" "$local_branch"
|
|
243
|
+
git config "branch.${local_branch}.pushRemote" "$remote_name"
|
|
244
|
+
git config "branch.${local_branch}.merge" "refs/heads/${head_branch}"
|
|
245
|
+
|
|
246
|
+
echo "[review] upstream + pushRemote set to $remote_name/$head_branch"
|
|
247
|
+
|
|
248
|
+
# Export for callers.
|
|
249
|
+
REVIEW_PR="$pr"
|
|
250
|
+
REVIEW_REPO_RESOLVED="$repo"
|
|
251
|
+
REVIEW_LOCAL_BRANCH="$local_branch"
|
|
252
|
+
REVIEW_HEAD_REPO="$head_repo"
|
|
253
|
+
REVIEW_HEAD_BRANCH="$head_branch"
|
|
254
|
+
REVIEW_PUSH_REMOTE="$remote_name"
|
|
255
|
+
export REVIEW_PR REVIEW_REPO_RESOLVED REVIEW_LOCAL_BRANCH \
|
|
256
|
+
REVIEW_HEAD_REPO REVIEW_HEAD_BRANCH REVIEW_PUSH_REMOTE \
|
|
257
|
+
REVIEW_HAS_CONFLICTS REVIEW_CONFLICT_FILES
|
|
258
|
+
}
|
package/review/merge.sh
ADDED
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# merge.sh <pr-number> [--squash|--merge|--rebase] [--dry-run] [--summary-llm <tool>]
|
|
3
|
+
# Merge a PR via gh. Defaults to --squash.
|
|
4
|
+
#
|
|
5
|
+
# For --squash we rewrite the commit body:
|
|
6
|
+
# - summarize the PR body + commit messages with the summary LLM
|
|
7
|
+
# (default: gemini; use `none` to skip and keep the raw PR body)
|
|
8
|
+
# - drop any Co-authored-by lines mentioning copilot / codex / cursor / claude
|
|
9
|
+
# - add the current `git config user.name <user.email>` as a co-author
|
|
10
|
+
# --merge and --rebase keep the original commits as-is.
|
|
11
|
+
#
|
|
12
|
+
# --dry-run prints the squash subject + body that would be used and exits
|
|
13
|
+
# without calling `gh pr merge`. Ignored for --merge / --rebase.
|
|
14
|
+
|
|
15
|
+
set -euo pipefail
|
|
16
|
+
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
17
|
+
# shellcheck source=lib.sh
|
|
18
|
+
source "$here/lib.sh"
|
|
19
|
+
|
|
20
|
+
require git gh jq
|
|
21
|
+
require_pr_number "${1:-}"
|
|
22
|
+
|
|
23
|
+
pr="$1"
|
|
24
|
+
strategy="--squash"
|
|
25
|
+
dry_run=0
|
|
26
|
+
force=0
|
|
27
|
+
admin=0
|
|
28
|
+
auto=0
|
|
29
|
+
summary_llm="gemini"
|
|
30
|
+
shift
|
|
31
|
+
while [ $# -gt 0 ]; do
|
|
32
|
+
case "$1" in
|
|
33
|
+
--squash|--merge|--rebase) strategy="$1"; shift ;;
|
|
34
|
+
--dry-run|-n) dry_run=1; shift ;;
|
|
35
|
+
--force|-f) force=1; shift ;;
|
|
36
|
+
--admin) admin=1; shift ;;
|
|
37
|
+
--auto) auto=1; shift ;;
|
|
38
|
+
--summary-llm) summary_llm="${2:?--summary-llm requires a value}"; shift 2 ;;
|
|
39
|
+
--summary-llm=*) summary_llm="${1#*=}"; shift ;;
|
|
40
|
+
*)
|
|
41
|
+
echo "[review] unknown arg: $1 (expected --squash|--merge|--rebase|--dry-run|--force|--admin|--auto|--summary-llm)" >&2
|
|
42
|
+
exit 1
|
|
43
|
+
;;
|
|
44
|
+
esac
|
|
45
|
+
done
|
|
46
|
+
|
|
47
|
+
if [ "$admin" = "1" ] && [ "$auto" = "1" ]; then
|
|
48
|
+
echo "[review] --admin and --auto are mutually exclusive" >&2
|
|
49
|
+
exit 1
|
|
50
|
+
fi
|
|
51
|
+
|
|
52
|
+
repo=$(resolve_repo)
|
|
53
|
+
|
|
54
|
+
echo "[review] PR #$pr status on $repo:"
|
|
55
|
+
pr_status_json=$(gh pr view "$pr" -R "$repo" \
|
|
56
|
+
--json state,mergeable,mergeStateStatus,reviewDecision,statusCheckRollup,isDraft,body,reviews)
|
|
57
|
+
jq '{state, mergeable, mergeStateStatus, reviewDecision, isDraft,
|
|
58
|
+
checks: [.statusCheckRollup[]? | {name: (.name // .context), status, conclusion}]}' \
|
|
59
|
+
<<<"$pr_status_json"
|
|
60
|
+
|
|
61
|
+
ensure_merge_ready() {
|
|
62
|
+
local failures=0 total=8
|
|
63
|
+
|
|
64
|
+
# ── Check 1: Not a draft ──
|
|
65
|
+
local is_draft
|
|
66
|
+
is_draft=$(jq -r '.isDraft' <<<"$pr_status_json")
|
|
67
|
+
if [ "$is_draft" = "true" ]; then
|
|
68
|
+
fail "PR is still in draft"
|
|
69
|
+
failures=$((failures + 1))
|
|
70
|
+
else
|
|
71
|
+
pass "PR is not a draft"
|
|
72
|
+
fi
|
|
73
|
+
|
|
74
|
+
# ── Check 2: CI passing ──
|
|
75
|
+
local bad_checks
|
|
76
|
+
bad_checks=$(jq -r '
|
|
77
|
+
.statusCheckRollup[]?
|
|
78
|
+
| select(
|
|
79
|
+
(.conclusion // "") as $c
|
|
80
|
+
| (.status // "") as $s
|
|
81
|
+
| ($c | IN("SUCCESS","NEUTRAL","SKIPPED","")) as $okConc
|
|
82
|
+
| ($s | IN("COMPLETED","")) as $okStatus
|
|
83
|
+
| (($okConc and $okStatus) | not)
|
|
84
|
+
)
|
|
85
|
+
| " \((.name // .context)): status=\(.status // "?"), conclusion=\(.conclusion // "?")"
|
|
86
|
+
' <<<"$pr_status_json")
|
|
87
|
+
if [ -n "$bad_checks" ]; then
|
|
88
|
+
fail "CI checks not all green:"
|
|
89
|
+
printf '%s\n' "$bad_checks" >&2
|
|
90
|
+
failures=$((failures + 1))
|
|
91
|
+
else
|
|
92
|
+
pass "All CI checks passing"
|
|
93
|
+
fi
|
|
94
|
+
|
|
95
|
+
# ── Check 3: No merge conflicts ──
|
|
96
|
+
local mergeable
|
|
97
|
+
mergeable=$(jq -r '.mergeable' <<<"$pr_status_json")
|
|
98
|
+
case "$mergeable" in
|
|
99
|
+
MERGEABLE) pass "No merge conflicts" ;;
|
|
100
|
+
CONFLICTING)
|
|
101
|
+
fail "PR has merge conflicts"
|
|
102
|
+
failures=$((failures + 1))
|
|
103
|
+
;;
|
|
104
|
+
*)
|
|
105
|
+
fail "Merge status unknown ($mergeable)"
|
|
106
|
+
failures=$((failures + 1))
|
|
107
|
+
;;
|
|
108
|
+
esac
|
|
109
|
+
|
|
110
|
+
# ── Check 4: All review threads resolved ──
|
|
111
|
+
local unresolved
|
|
112
|
+
unresolved=$(gh api graphql -f query='
|
|
113
|
+
query($owner:String!,$repo:String!,$pr:Int!) {
|
|
114
|
+
repository(owner:$owner,name:$repo) {
|
|
115
|
+
pullRequest(number:$pr) {
|
|
116
|
+
reviewThreads(first:100) {
|
|
117
|
+
nodes { isResolved isOutdated }
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}' -f owner="${repo%%/*}" -f repo="${repo##*/}" -F pr="$pr" \
|
|
122
|
+
--jq '[.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false and .isOutdated == false)] | length')
|
|
123
|
+
if [ "${unresolved:-0}" -gt 0 ]; then
|
|
124
|
+
fail "$unresolved unresolved review thread(s)"
|
|
125
|
+
failures=$((failures + 1))
|
|
126
|
+
else
|
|
127
|
+
pass "All review threads resolved"
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
# ── Check 5: At least one approval ──
|
|
131
|
+
local review_decision
|
|
132
|
+
review_decision=$(jq -r '.reviewDecision // "NONE"' <<<"$pr_status_json")
|
|
133
|
+
if [ "$review_decision" = "APPROVED" ]; then
|
|
134
|
+
pass "Has at least one approval"
|
|
135
|
+
else
|
|
136
|
+
fail "Review decision is $review_decision (need APPROVED)"
|
|
137
|
+
failures=$((failures + 1))
|
|
138
|
+
fi
|
|
139
|
+
|
|
140
|
+
# ── Check 6: No REQUEST_CHANGES pending ──
|
|
141
|
+
# Get the latest review state per author — a later APPROVED supersedes an earlier REQUEST_CHANGES
|
|
142
|
+
local pending_changes
|
|
143
|
+
pending_changes=$(jq -r '
|
|
144
|
+
[.reviews // [] | sort_by(.submittedAt) | group_by(.author.login)[]
|
|
145
|
+
| last | select(.state == "CHANGES_REQUESTED")]
|
|
146
|
+
| length' <<<"$pr_status_json")
|
|
147
|
+
if [ "${pending_changes:-0}" -gt 0 ]; then
|
|
148
|
+
fail "$pending_changes reviewer(s) still requesting changes"
|
|
149
|
+
failures=$((failures + 1))
|
|
150
|
+
else
|
|
151
|
+
pass "No pending change requests"
|
|
152
|
+
fi
|
|
153
|
+
|
|
154
|
+
# ── Check 7: Coverage gate passed ──
|
|
155
|
+
local cov_status
|
|
156
|
+
cov_status=$(jq -r '
|
|
157
|
+
[.statusCheckRollup[]?
|
|
158
|
+
| select((.name // .context) | test("coverage"; "i"))]
|
|
159
|
+
| if length == 0 then "MISSING"
|
|
160
|
+
elif all(.conclusion == "SUCCESS") then "SUCCESS"
|
|
161
|
+
else (map(select(.conclusion != "SUCCESS"))[0].conclusion // "UNKNOWN")
|
|
162
|
+
end' <<<"$pr_status_json")
|
|
163
|
+
case "$cov_status" in
|
|
164
|
+
SUCCESS) pass "Coverage gate passed" ;;
|
|
165
|
+
MISSING)
|
|
166
|
+
warn "Coverage check not found in status checks"
|
|
167
|
+
fail "Coverage gate status unknown"
|
|
168
|
+
failures=$((failures + 1))
|
|
169
|
+
;;
|
|
170
|
+
*)
|
|
171
|
+
fail "Coverage gate: $cov_status"
|
|
172
|
+
failures=$((failures + 1))
|
|
173
|
+
;;
|
|
174
|
+
esac
|
|
175
|
+
|
|
176
|
+
# ── Check 8: PR description has required sections ──
|
|
177
|
+
local body
|
|
178
|
+
body=$(jq -r '.body // ""' <<<"$pr_status_json")
|
|
179
|
+
local missing_sections=()
|
|
180
|
+
for section in "Summary" "Problem" "Solution"; do
|
|
181
|
+
if ! grep -qiE "^##[[:space:]]+${section}" <<<"$body"; then
|
|
182
|
+
missing_sections+=("$section")
|
|
183
|
+
fi
|
|
184
|
+
done
|
|
185
|
+
if [ ${#missing_sections[@]} -gt 0 ]; then
|
|
186
|
+
fail "PR description missing sections: ${missing_sections[*]}"
|
|
187
|
+
failures=$((failures + 1))
|
|
188
|
+
else
|
|
189
|
+
pass "PR description has required sections"
|
|
190
|
+
fi
|
|
191
|
+
|
|
192
|
+
# ── Summary ──
|
|
193
|
+
local passed=$((total - failures))
|
|
194
|
+
echo ""
|
|
195
|
+
if [ "$failures" -gt 0 ]; then
|
|
196
|
+
info "${_B}${passed}/${total} checks passed${_0}"
|
|
197
|
+
if [ "$force" = "1" ]; then
|
|
198
|
+
warn "--force: proceeding despite $failures failure(s)."
|
|
199
|
+
return 0
|
|
200
|
+
fi
|
|
201
|
+
fail "Refusing to merge. Re-run with --force to override."
|
|
202
|
+
exit 1
|
|
203
|
+
else
|
|
204
|
+
info "${_G}${total}/${total} checks passed${_0} — ready to merge"
|
|
205
|
+
fi
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# Substring patterns (case-insensitive) matched against co-author name OR email.
|
|
209
|
+
# Override via REVIEW_BANNED_COAUTHOR_RE env var.
|
|
210
|
+
BANNED_RE="${REVIEW_BANNED_COAUTHOR_RE:-copilot|codex|cursor|claude|anthropic|openai|chatgpt|\[bot\]|noreply@github|users\.noreply\.github\.com}"
|
|
211
|
+
|
|
212
|
+
build_squash_body() {
|
|
213
|
+
local pr="$1" repo="$2" summary_llm="$3" closing_issues="${4:-}"
|
|
214
|
+
local data body title me_name me_email
|
|
215
|
+
data=$(gh pr view "$pr" -R "$repo" --json title,body,commits)
|
|
216
|
+
title=$(jq -r '.title' <<<"$data")
|
|
217
|
+
body=$(jq -r '.body // ""' <<<"$data")
|
|
218
|
+
|
|
219
|
+
me_name=$(git config --get user.name || true)
|
|
220
|
+
me_email=$(git config --get user.email || true)
|
|
221
|
+
if [ -z "$me_name" ] || [ -z "$me_email" ]; then
|
|
222
|
+
echo "[review] git config user.name/user.email not set; cannot add self as co-author" >&2
|
|
223
|
+
exit 1
|
|
224
|
+
fi
|
|
225
|
+
|
|
226
|
+
# Strip any existing Co-authored-by trailers from the PR body.
|
|
227
|
+
local body_clean
|
|
228
|
+
body_clean=$(printf '%s\n' "$body" | grep -viE '^co-authored-by:' || true)
|
|
229
|
+
# Trim trailing blank lines.
|
|
230
|
+
body_clean=$(printf '%s\n' "$body_clean" | awk 'NF {p=1} p {lines[NR]=$0; last=NR} END {for (i=1;i<=last;i++) print lines[i]}')
|
|
231
|
+
|
|
232
|
+
# Build input for the summary LLM: title + PR body + commit list.
|
|
233
|
+
local summary_input
|
|
234
|
+
summary_input=$(jq -r '
|
|
235
|
+
"Title: " + .title + "\n\n" +
|
|
236
|
+
"PR body:\n" + (.body // "(empty)") + "\n\n" +
|
|
237
|
+
"Commits:\n" +
|
|
238
|
+
((.commits // [])
|
|
239
|
+
| map("- " + .messageHeadline
|
|
240
|
+
+ (if (.messageBody // "") != ""
|
|
241
|
+
then "\n " + ((.messageBody) | gsub("\n"; "\n "))
|
|
242
|
+
else "" end))
|
|
243
|
+
| join("\n"))
|
|
244
|
+
' <<<"$data")
|
|
245
|
+
|
|
246
|
+
local summary_body
|
|
247
|
+
if [ "$summary_llm" = "none" ] || [ "$summary_llm" = "raw" ]; then
|
|
248
|
+
summary_body="$body_clean"
|
|
249
|
+
else
|
|
250
|
+
echo "[review] summarizing with ${summary_llm}..." >&2
|
|
251
|
+
summary_body=$(summarize_text "$summary_llm" "$summary_input")
|
|
252
|
+
if [ -z "$summary_body" ]; then
|
|
253
|
+
echo "[review] ! summary LLM returned empty output; falling back to PR body" >&2
|
|
254
|
+
summary_body="$body_clean"
|
|
255
|
+
fi
|
|
256
|
+
fi
|
|
257
|
+
|
|
258
|
+
# Collect co-authors from commit authors + Co-authored-by trailers, then
|
|
259
|
+
# filter. tolower()-based match is portable (BSD awk has no IGNORECASE).
|
|
260
|
+
local coauthors
|
|
261
|
+
coauthors=$(jq -r '
|
|
262
|
+
.commits[]
|
|
263
|
+
| (
|
|
264
|
+
(.authors[]? | "\(.name // "")\t\(.email // "")"),
|
|
265
|
+
(.messageBody // "" | split("\n")[]
|
|
266
|
+
| select(test("^[Cc]o-authored-by:"))
|
|
267
|
+
| sub("^[Cc]o-authored-by:\\s*"; "")
|
|
268
|
+
| capture("^(?<n>.+?)\\s*<(?<e>[^>]+)>\\s*$")?
|
|
269
|
+
| "\(.n)\t\(.e)"
|
|
270
|
+
)
|
|
271
|
+
)
|
|
272
|
+
' <<<"$data" \
|
|
273
|
+
| awk -F'\t' -v me="$me_email" -v banned="$BANNED_RE" '
|
|
274
|
+
NF < 2 { next }
|
|
275
|
+
$1 == "" || $2 == "" { next }
|
|
276
|
+
tolower($2) == tolower(me) { next }
|
|
277
|
+
{
|
|
278
|
+
nl = tolower($1); el = tolower($2);
|
|
279
|
+
if (nl ~ banned || el ~ banned) next;
|
|
280
|
+
key = el;
|
|
281
|
+
if (!(key in seen)) {
|
|
282
|
+
seen[key] = 1
|
|
283
|
+
printf "Co-authored-by: %s <%s>\n", $1, $2
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
')
|
|
287
|
+
|
|
288
|
+
# Strip any stray closing-keyword lines the LLM or PR body may have
|
|
289
|
+
# emitted — we'll append a canonical block below so GitHub sees one
|
|
290
|
+
# `Closes #N` per linked issue (its regex only matches one ref per keyword,
|
|
291
|
+
# so `Closes #1, #2` would only close #1).
|
|
292
|
+
local summary_clean
|
|
293
|
+
summary_clean=$(printf '%s\n' "$summary_body" \
|
|
294
|
+
| grep -viE '^[[:space:]]*(close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]]+(#|[A-Za-z0-9._-]+/[A-Za-z0-9._-]+#)[0-9]+' \
|
|
295
|
+
|| true)
|
|
296
|
+
|
|
297
|
+
local closes_block=""
|
|
298
|
+
if [ -n "$closing_issues" ]; then
|
|
299
|
+
local n
|
|
300
|
+
for n in $closing_issues; do
|
|
301
|
+
closes_block+="Closes #${n}"$'\n'
|
|
302
|
+
done
|
|
303
|
+
fi
|
|
304
|
+
|
|
305
|
+
{
|
|
306
|
+
if [ -n "$summary_clean" ]; then
|
|
307
|
+
printf '%s\n\n' "$summary_clean"
|
|
308
|
+
fi
|
|
309
|
+
if [ -n "$closes_block" ]; then
|
|
310
|
+
printf '%s\n' "$closes_block"
|
|
311
|
+
fi
|
|
312
|
+
if [ -n "$coauthors" ]; then
|
|
313
|
+
printf '%s\n' "$coauthors"
|
|
314
|
+
fi
|
|
315
|
+
printf 'Co-authored-by: %s <%s>\n' "$me_name" "$me_email"
|
|
316
|
+
}
|
|
317
|
+
: "$title" # reserved for future subject overrides
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
# Gate the merge first — do this BEFORE any LLM summarization so we
|
|
321
|
+
# don't burn tokens on PRs that can't actually be merged. --dry-run is
|
|
322
|
+
# the one case where we still want to print the squash preview regardless.
|
|
323
|
+
extra_flags=()
|
|
324
|
+
if [ "$admin" = "1" ]; then
|
|
325
|
+
echo "[review] --admin: bypassing local gate and using branch-protection override"
|
|
326
|
+
extra_flags+=(--admin)
|
|
327
|
+
elif [ "$auto" = "1" ]; then
|
|
328
|
+
echo "[review] --auto: queueing merge once checks/approvals are satisfied"
|
|
329
|
+
extra_flags+=(--auto)
|
|
330
|
+
elif [ "$dry_run" != "1" ]; then
|
|
331
|
+
ensure_merge_ready
|
|
332
|
+
fi
|
|
333
|
+
|
|
334
|
+
if [ "$strategy" = "--squash" ]; then
|
|
335
|
+
title=$(gh pr view "$pr" -R "$repo" --json title -q .title)
|
|
336
|
+
|
|
337
|
+
# Append any linked "Closes #N" issues that aren't already referenced in the
|
|
338
|
+
# title (skip issue numbers already mentioned as #N).
|
|
339
|
+
closing=$(gh pr view "$pr" -R "$repo" \
|
|
340
|
+
--json closingIssuesReferences \
|
|
341
|
+
--jq '.closingIssuesReferences[].number' 2>/dev/null || true)
|
|
342
|
+
missing=()
|
|
343
|
+
for n in $closing; do
|
|
344
|
+
if ! grep -qE "#${n}([^0-9]|$)" <<<"$title"; then
|
|
345
|
+
missing+=("#${n}")
|
|
346
|
+
fi
|
|
347
|
+
done
|
|
348
|
+
if [ ${#missing[@]} -gt 0 ]; then
|
|
349
|
+
joined=$(printf ', %s' "${missing[@]}")
|
|
350
|
+
joined=${joined:2}
|
|
351
|
+
title="${title} (closes ${joined})"
|
|
352
|
+
fi
|
|
353
|
+
|
|
354
|
+
body=$(build_squash_body "$pr" "$repo" "$summary_llm" "$closing")
|
|
355
|
+
echo "[review] squash commit message:"
|
|
356
|
+
printf -- '----\n%s (#%s)\n\n%s\n----\n' "$title" "$pr" "$body"
|
|
357
|
+
if [ "$dry_run" = "1" ]; then
|
|
358
|
+
echo "[review] --dry-run: not merging."
|
|
359
|
+
exit 0
|
|
360
|
+
fi
|
|
361
|
+
echo "[review] merging PR #$pr with --squash..."
|
|
362
|
+
gh pr merge "$pr" -R "$repo" --squash --delete-branch \
|
|
363
|
+
--subject "$title (#$pr)" \
|
|
364
|
+
--body "$body" \
|
|
365
|
+
${extra_flags[@]+"${extra_flags[@]}"}
|
|
366
|
+
else
|
|
367
|
+
if [ "$dry_run" = "1" ]; then
|
|
368
|
+
echo "[review] --dry-run: $strategy does not rewrite the commit message; nothing to preview."
|
|
369
|
+
exit 0
|
|
370
|
+
fi
|
|
371
|
+
echo "[review] merging PR #$pr with $strategy..."
|
|
372
|
+
gh pr merge "$pr" -R "$repo" "$strategy" --delete-branch ${extra_flags[@]+"${extra_flags[@]}"}
|
|
373
|
+
fi
|
|
374
|
+
echo "[review] merged."
|