@ianwremmel/dispatch 0.32.1-bootstrap.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/.claude-plugin/plugin.json +59 -0
- package/.mcp.json +8 -0
- package/LICENSE +21 -0
- package/README.md +93 -0
- package/agents/.gitkeep +0 -0
- package/agents/build-graph.md +99 -0
- package/agents/milestone-reviewer.md +50 -0
- package/agents/pr-worker.md +172 -0
- package/agents/ticket-worker.md +97 -0
- package/bin/dispatch +101 -0
- package/bin/dispatch-mcp +19 -0
- package/bin/pr-status +931 -0
- package/commands/.gitkeep +0 -0
- package/commands/orchestrate.md +6 -0
- package/hooks/.gitkeep +0 -0
- package/hooks/claim-guard.mts +98 -0
- package/hooks/hooks.json +15 -0
- package/package.json +46 -0
- package/skills/.gitkeep +0 -0
- package/skills/land/SKILL.md +238 -0
- package/skills/land/credentials-dedicated.md +33 -0
- package/skills/land/credentials-shared.md +76 -0
- package/skills/land/mode-solo.md +76 -0
- package/skills/land/mode-team.md +103 -0
- package/skills/land/reference.md +152 -0
- package/skills/land/ticket.md +94 -0
- package/skills/orchestrate/SKILL.md +87 -0
- package/skills/tracker-adapter-linear/SKILL.md +142 -0
- package/src/commands/CLAUDE.md +12 -0
- package/src/commands/claim/check.mts +88 -0
- package/src/commands/claim/guard.mts +95 -0
- package/src/commands/claim/status.mts +49 -0
- package/src/commands/edge/add.mts +44 -0
- package/src/commands/edge/rm.mts +44 -0
- package/src/commands/edge/set.mts +56 -0
- package/src/commands/greet.mts +34 -0
- package/src/commands/mcp/ack.mts +43 -0
- package/src/commands/mcp/ping.mts +61 -0
- package/src/commands/mcp/status.mts +89 -0
- package/src/commands/mcp.mts +155 -0
- package/src/commands/milestone/rm.mts +35 -0
- package/src/commands/milestone/set.mts +49 -0
- package/src/commands/outcome/rm.mts +36 -0
- package/src/commands/outcome/set.mts +86 -0
- package/src/commands/pr/rm.mts +33 -0
- package/src/commands/pr/set.mts +110 -0
- package/src/commands/pr/yield.mts +114 -0
- package/src/commands/project/rm.mts +33 -0
- package/src/commands/project/set.mts +50 -0
- package/src/commands/queue.mts +41 -0
- package/src/commands/refresh/done.mts +42 -0
- package/src/commands/refresh/status.mts +40 -0
- package/src/commands/refresh.mts +56 -0
- package/src/commands/review/record.mts +46 -0
- package/src/commands/review/release.mts +49 -0
- package/src/commands/status.mts +85 -0
- package/src/commands/ticket/missing.mts +31 -0
- package/src/commands/ticket/rm.mts +33 -0
- package/src/commands/ticket/set.mts +134 -0
- package/src/commands/worker/rm.mts +46 -0
- package/src/commands/worker/set.mts +63 -0
- package/src/lib/cli/CLAUDE.md +13 -0
- package/src/lib/cli/cli.mts +226 -0
- package/src/lib/cli/index.mts +1 -0
- package/src/lib/command/CLAUDE.md +26 -0
- package/src/lib/command/__fixtures__/bad-export/oops.mts +1 -0
- package/src/lib/command/__fixtures__/bad-name/mismatch.mts +19 -0
- package/src/lib/command/__fixtures__/commands/cli-only.mts +20 -0
- package/src/lib/command/__fixtures__/commands/greet.mts +39 -0
- package/src/lib/command/__fixtures__/commands/math/add.mts +32 -0
- package/src/lib/command/__fixtures__/commands/mcp-only.mts +20 -0
- package/src/lib/command/__fixtures__/commands/needs-token.mts +19 -0
- package/src/lib/command/__fixtures__/commands/store/get.mts +26 -0
- package/src/lib/command/__fixtures__/commands/store.mts +26 -0
- package/src/lib/command/abstract-command.mts +104 -0
- package/src/lib/command/discovery.mts +100 -0
- package/src/lib/command/env.mts +19 -0
- package/src/lib/command/index.mts +6 -0
- package/src/lib/command/parse.mts +64 -0
- package/src/lib/command/test-support.mts +81 -0
- package/src/lib/command/transports.mts +17 -0
- package/src/lib/command/types.mts +53 -0
- package/src/lib/db/CLAUDE.md +13 -0
- package/src/lib/db/database.mts +160 -0
- package/src/lib/db/index.mts +4 -0
- package/src/lib/db/schema.mts +195 -0
- package/src/lib/db/time.mts +24 -0
- package/src/lib/db/with-database.mts +56 -0
- package/src/lib/errors/CLAUDE.md +18 -0
- package/src/lib/errors/command-error.mts +13 -0
- package/src/lib/errors/data-error.mts +12 -0
- package/src/lib/errors/definition-error.mts +6 -0
- package/src/lib/errors/dispatch-error.mts +27 -0
- package/src/lib/errors/ensure.mts +22 -0
- package/src/lib/errors/environment-error.mts +7 -0
- package/src/lib/errors/index.mts +8 -0
- package/src/lib/errors/json-rpc-error.mts +18 -0
- package/src/lib/errors/usage-error.mts +7 -0
- package/src/lib/graph/CLAUDE.md +17 -0
- package/src/lib/graph/anomalies.mts +110 -0
- package/src/lib/graph/derive.mts +96 -0
- package/src/lib/graph/index.mts +26 -0
- package/src/lib/graph/pipeline.mts +410 -0
- package/src/lib/graph/queries.mts +207 -0
- package/src/lib/graph/rows.mts +99 -0
- package/src/lib/graph/types.mts +137 -0
- package/src/lib/liveness/CLAUDE.md +14 -0
- package/src/lib/liveness/index.mts +10 -0
- package/src/lib/liveness/liveness.mts +147 -0
- package/src/lib/liveness/retire.mts +63 -0
- package/src/lib/logger/CLAUDE.md +12 -0
- package/src/lib/logger/index.mts +2 -0
- package/src/lib/logger/logger.mts +58 -0
- package/src/lib/logger/stream-sink.mts +23 -0
- package/src/lib/mcp/CLAUDE.md +21 -0
- package/src/lib/mcp/channel.mts +41 -0
- package/src/lib/mcp/dispatch.mts +60 -0
- package/src/lib/mcp/drain.mts +83 -0
- package/src/lib/mcp/index.mts +5 -0
- package/src/lib/mcp/mcp.mts +267 -0
- package/src/lib/mcp/tools.mts +77 -0
- package/src/lib/model/CLAUDE.md +8 -0
- package/src/lib/model/index.mts +3 -0
- package/src/lib/model/repo-caps.mts +95 -0
- package/src/lib/model/status.mts +91 -0
- package/src/lib/model/types.mts +83 -0
- package/src/lib/refresh/index.mts +2 -0
- package/src/lib/refresh/placeholders.mts +43 -0
- package/src/lib/refresh/refresh-service.mts +203 -0
- package/src/lib/schedule/CLAUDE.md +18 -0
- package/src/lib/schedule/caps.mts +113 -0
- package/src/lib/schedule/correlate.mts +69 -0
- package/src/lib/schedule/index.mts +7 -0
- package/src/lib/schedule/scheduler.mts +355 -0
- package/src/lib/schedule/tick.mts +266 -0
- package/src/lib/stores/CLAUDE.md +24 -0
- package/src/lib/stores/coordination.mts +359 -0
- package/src/lib/stores/cursor.mts +41 -0
- package/src/lib/stores/edge.mts +138 -0
- package/src/lib/stores/fetch-request.mts +346 -0
- package/src/lib/stores/index.mts +19 -0
- package/src/lib/stores/materialize.mts +69 -0
- package/src/lib/stores/milestone.mts +74 -0
- package/src/lib/stores/notice.mts +57 -0
- package/src/lib/stores/policy.mts +48 -0
- package/src/lib/stores/pr-event.mts +94 -0
- package/src/lib/stores/pr.mts +167 -0
- package/src/lib/stores/project.mts +79 -0
- package/src/lib/stores/refresh.mts +197 -0
- package/src/lib/stores/review.mts +113 -0
- package/src/lib/stores/session.mts +170 -0
- package/src/lib/stores/ticket.mts +246 -0
- package/src/lib/stores/watch.mts +360 -0
- package/src/lib/stores/worker.mts +121 -0
- package/src/lib/watch/adopt.mts +151 -0
- package/src/lib/watch/arm.mts +48 -0
- package/src/lib/watch/cadence.mts +45 -0
- package/src/lib/watch/diff.mts +274 -0
- package/src/lib/watch/index.mts +11 -0
- package/src/lib/watch/marker.mts +24 -0
- package/src/lib/watch/payload.mts +56 -0
- package/src/lib/watch/poll.mts +87 -0
- package/src/lib/watch/render.mts +61 -0
- package/src/lib/watch/snapshot.mts +312 -0
- package/src/main.mts +18 -0
package/bin/pr-status
ADDED
|
@@ -0,0 +1,931 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# pr-status — emit PR state XML per §2.2.2 PR Status Protocol.
|
|
3
|
+
#
|
|
4
|
+
# Usage: pr-status <pr>
|
|
5
|
+
#
|
|
6
|
+
# Config comes from the plugin's userConfig. The harness exports
|
|
7
|
+
# CLAUDE_PLUGIN_OPTION_* to hook processes only — commands the model runs via
|
|
8
|
+
# the Bash tool never receive them — so when
|
|
9
|
+
# CLAUDE_PLUGIN_OPTION_OPERATOR_LOGIN is absent from the environment, the
|
|
10
|
+
# script reads the option from Claude Code's own settings files (managed →
|
|
11
|
+
# project local → project → user; first match wins). operator_login is the
|
|
12
|
+
# only required option; the script errors, pointing at the plugin config, if
|
|
13
|
+
# it can't be resolved either way.
|
|
14
|
+
#
|
|
15
|
+
# Requires: gh + jq (hard — the script exits if either is missing). git and
|
|
16
|
+
# claude are optional: content_present (terminal resolution) and summarize
|
|
17
|
+
# degrade gracefully without them. Run from inside the repo's git worktree.
|
|
18
|
+
|
|
19
|
+
set -euo pipefail
|
|
20
|
+
|
|
21
|
+
PR=""
|
|
22
|
+
REPO_ARG=""
|
|
23
|
+
while [[ $# -gt 0 ]]; do
|
|
24
|
+
case "$1" in
|
|
25
|
+
--repo) REPO_ARG="${2:-}"; shift 2 ;;
|
|
26
|
+
--repo=*) REPO_ARG="${1#--repo=}"; shift ;;
|
|
27
|
+
*) PR="$1"; shift ;;
|
|
28
|
+
esac
|
|
29
|
+
done
|
|
30
|
+
[[ -n "$PR" ]] || { echo "usage: pr-status [--repo <owner>/<name>] <pr>" >&2; exit 2; }
|
|
31
|
+
|
|
32
|
+
# The repository the PR belongs to. Without this the script can only read a PR
|
|
33
|
+
# in the repo it is standing in, so a server watching PRs across repos — or
|
|
34
|
+
# running outside a worktree at all — cannot produce a payload. `gh` reads
|
|
35
|
+
# GH_REPO, so resolving it once here reaches every call below.
|
|
36
|
+
#
|
|
37
|
+
# A URL is always decomposed, whether or not --repo was also given: leaving PR
|
|
38
|
+
# as a URL puts the URL in the emitted pr="" attribute, and a --repo that
|
|
39
|
+
# disagrees with it would label the output with one repo while reporting
|
|
40
|
+
# another's data. Disagreement is a caller error, not something to resolve by
|
|
41
|
+
# precedence.
|
|
42
|
+
if [[ "$PR" =~ ^https?://[^/]+/([^/]+)/([^/]+)/pull/([0-9]+) ]]; then
|
|
43
|
+
_url_repo="${BASH_REMATCH[1]}/${BASH_REMATCH[2]}"
|
|
44
|
+
PR="${BASH_REMATCH[3]}"
|
|
45
|
+
if [[ -n "$REPO_ARG" && "$REPO_ARG" != "$_url_repo" ]]; then
|
|
46
|
+
echo "pr-status: --repo $REPO_ARG contradicts the URL, which names $_url_repo" >&2
|
|
47
|
+
exit 2
|
|
48
|
+
fi
|
|
49
|
+
REPO_ARG="$_url_repo"
|
|
50
|
+
fi
|
|
51
|
+
[[ -n "$REPO_ARG" ]] && export GH_REPO="$REPO_ARG"
|
|
52
|
+
|
|
53
|
+
# Required tools: the script cannot emit valid XML without gh + jq. Fail loudly
|
|
54
|
+
# up front rather than partway through with a cryptic error. (git and claude
|
|
55
|
+
# degrade gracefully in content_present/summarize, so they are not hard-required.)
|
|
56
|
+
for _tool in gh jq; do
|
|
57
|
+
command -v "$_tool" >/dev/null 2>&1 \
|
|
58
|
+
|| { echo "pr-status: required tool not found on PATH: $_tool" >&2; exit 3; }
|
|
59
|
+
done
|
|
60
|
+
|
|
61
|
+
# claude is optional — without it items are emitted without a <summary>. Resolve
|
|
62
|
+
# it once here so the absence is reported once, not once per item.
|
|
63
|
+
HAVE_CLAUDE=true
|
|
64
|
+
if ! command -v claude >/dev/null 2>&1; then
|
|
65
|
+
HAVE_CLAUDE=false
|
|
66
|
+
echo "pr-status: claude not found on PATH; items will be emitted without summaries" >&2
|
|
67
|
+
fi
|
|
68
|
+
|
|
69
|
+
# Required plugin option (§2.2.2 Operator identity). The env var wins when set
|
|
70
|
+
# (hook processes get it injected; a caller may also export it). Otherwise read
|
|
71
|
+
# it from the settings files Claude Code itself reads, in Claude Code's
|
|
72
|
+
# precedence order. The plugin key is matched as `dispatch` or `dispatch@<any
|
|
73
|
+
# marketplace>` — the marketplace name is chosen at install time, so it can't
|
|
74
|
+
# be hardcoded. If nothing resolves, the operator hasn't configured the
|
|
75
|
+
# dispatch plugin — surface that (the agent must NOT invent a login or fall
|
|
76
|
+
# back to the ticket assigner; it's the operator's to set).
|
|
77
|
+
resolve_operator_login() {
|
|
78
|
+
if [[ -n "${CLAUDE_PLUGIN_OPTION_OPERATOR_LOGIN:-}" ]]; then
|
|
79
|
+
printf '%s' "$CLAUDE_PLUGIN_OPTION_OPERATOR_LOGIN"
|
|
80
|
+
return 0
|
|
81
|
+
fi
|
|
82
|
+
# CLAUDE_PROJECT_DIR is only injected into hook processes; fall back to the
|
|
83
|
+
# enclosing git worktree, or the current directory outside one (the script
|
|
84
|
+
# requires a worktree anyway and fails later in gh if there isn't one).
|
|
85
|
+
local project_root
|
|
86
|
+
project_root="${CLAUDE_PROJECT_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
|
87
|
+
# ${HOME:-} keeps `set -u` from aborting the whole lookup when HOME is unset;
|
|
88
|
+
# the resulting /.claude/... path simply fails the -f test.
|
|
89
|
+
local -a settings_files=(
|
|
90
|
+
'/etc/claude-code/managed-settings.json'
|
|
91
|
+
'/Library/Application Support/ClaudeCode/managed-settings.json'
|
|
92
|
+
"$project_root/.claude/settings.local.json"
|
|
93
|
+
"$project_root/.claude/settings.json"
|
|
94
|
+
"${HOME:-}/.claude/settings.json"
|
|
95
|
+
)
|
|
96
|
+
local f login
|
|
97
|
+
for f in "${settings_files[@]}"; do
|
|
98
|
+
[[ -f "$f" ]] || continue
|
|
99
|
+
# A file that exists but doesn't parse is warned about and skipped rather
|
|
100
|
+
# than treated as "no login found" silently — otherwise a corrupt
|
|
101
|
+
# higher-precedence file invisibly hands the answer to a lower one.
|
|
102
|
+
if ! login="$(jq -r '
|
|
103
|
+
[ (.pluginConfigs // {})
|
|
104
|
+
| to_entries[]
|
|
105
|
+
| select(.key == "dispatch" or (.key | startswith("dispatch@")))
|
|
106
|
+
| .value.options.operator_login // empty
|
|
107
|
+
| select(. != "")
|
|
108
|
+
] | first // empty
|
|
109
|
+
' "$f" 2>/dev/null)"; then
|
|
110
|
+
echo "pr-status: warning: could not parse $f; skipping it for operator_login resolution" >&2
|
|
111
|
+
continue
|
|
112
|
+
fi
|
|
113
|
+
if [[ -n "$login" ]]; then
|
|
114
|
+
printf '%s' "$login"
|
|
115
|
+
return 0
|
|
116
|
+
fi
|
|
117
|
+
done
|
|
118
|
+
return 1
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
OPERATOR_LOGIN="$(resolve_operator_login)" || {
|
|
122
|
+
echo "pr-status: the dispatch plugin option operator_login is not set; the operator must configure it in the dispatch plugin config (or export CLAUDE_PLUGIN_OPTION_OPERATOR_LOGIN) — pr-status cannot classify reviews without it" >&2
|
|
123
|
+
exit 3
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
OPERATOR_LC="$(printf '%s' "$OPERATOR_LOGIN" | tr '[:upper:]' '[:lower:]')"
|
|
127
|
+
|
|
128
|
+
REPO="${REPO_ARG:-$(gh repo view --json nameWithOwner -q .nameWithOwner)}"
|
|
129
|
+
SLUG="${REPO/\//__}"
|
|
130
|
+
BASE="${DISPATCH_CACHE_DIR:-${XDG_CACHE_HOME:-$HOME/.cache}/dispatch}"
|
|
131
|
+
DIR="$BASE/deliver/$SLUG/$PR"
|
|
132
|
+
mkdir -p "$DIR/comments" "$DIR/threads" "$DIR/annotations" "$DIR/reviews"
|
|
133
|
+
|
|
134
|
+
# A run killed mid-summary leaves its staging files behind. Nothing reads them —
|
|
135
|
+
# only <id>.summary.md is — but they accumulate. A day is far longer than any
|
|
136
|
+
# `claude -p` call, which has no timeout of its own, so sweeping at that age
|
|
137
|
+
# cannot take a staging file a live run still owns.
|
|
138
|
+
find "$DIR" -type f \( -name '*.summary.md.out.*' -o -name '*.summary.md.err.*' \) \
|
|
139
|
+
-mmin +1440 -delete 2>/dev/null || true
|
|
140
|
+
|
|
141
|
+
INFORMATIONAL_RE="${DISPATCH_INFORMATIONAL_CHECKS:-}" # e.g. "^(coverage|codeql)$"
|
|
142
|
+
STUCK_AFTER_SEC="${DISPATCH_STUCK_AFTER_SEC:-3600}"
|
|
143
|
+
|
|
144
|
+
# Per reference.md → Terminal signals: the text tokens must be the *last
|
|
145
|
+
# non-empty line* of the body. Canonical tokens are `Done.`, `Declined.`,
|
|
146
|
+
# `Shipped.`; `✓`/`✅` are accepted as inline reaction-equivalents; the
|
|
147
|
+
# remaining legacy tokens (`acknowledged`, etc.) preserve pre-existing
|
|
148
|
+
# behavior. Match is case-insensitive and tolerant of an optional trailing
|
|
149
|
+
# period.
|
|
150
|
+
TERMINAL_RE='^[[:space:]]*(✓|✅|done\.?|declined\.?|shipped\.?|acknowledged\.?|wontfix\.?|dismissed\.?|resolved\.?)[[:space:]]*$'
|
|
151
|
+
|
|
152
|
+
# gh-authenticated identity — the agent's own login. classify_actionable uses it
|
|
153
|
+
# (author match + marker) to recognize the calling agent's own plan/engagement/
|
|
154
|
+
# terminal-tagged artifacts and suppress them. It's the only identity source, so
|
|
155
|
+
# a failure here is fatal — without it every self-authored item stays actionable.
|
|
156
|
+
CALLER_LOGIN="$(gh api user --jq .login 2>/dev/null || true)"
|
|
157
|
+
[[ -n "$CALLER_LOGIN" ]] || { echo "pr-status: could not resolve the authenticated GitHub user (gh api user failed); run 'gh auth status' — cannot proceed" >&2; exit 3; }
|
|
158
|
+
|
|
159
|
+
xml_attr() { printf '%s' "$1" | sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g' -e 's/"/\"/g'; }
|
|
160
|
+
xml_text() { printf '%s' "$1" | sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g'; }
|
|
161
|
+
|
|
162
|
+
# --- summary helper -----------------------------------------------------------
|
|
163
|
+
# Args: <body-file> <out-file>
|
|
164
|
+
#
|
|
165
|
+
# Writes <out-file> only when the call succeeds and produced a real summary, and
|
|
166
|
+
# returns non-zero otherwise. The emitters read the file's existence as "already
|
|
167
|
+
# summarized", so anything written on failure is never retried and latches for
|
|
168
|
+
# the life of the cache — a missing summary must stay missing. Nested `claude -p`
|
|
169
|
+
# calls intermittently answer with Claude Code's own "Not logged in" banner on a
|
|
170
|
+
# zero exit, so a plausible-looking body is checked too. Diagnostics go to
|
|
171
|
+
# stderr; stdout carries the XML payload.
|
|
172
|
+
#
|
|
173
|
+
# The output is staged in a sibling temp file and moved into place, so a
|
|
174
|
+
# concurrent pr-status over the same PR never reads a half-written summary.
|
|
175
|
+
summarize() {
|
|
176
|
+
local body="$1" out="$2"
|
|
177
|
+
[[ "$HAVE_CLAUDE" == true ]] || return 1
|
|
178
|
+
|
|
179
|
+
# Every caller guards this function with `|| true`, which disables errexit for
|
|
180
|
+
# its whole body — so each step that can fail is checked by hand here.
|
|
181
|
+
local tmp_out tmp_err rc=0
|
|
182
|
+
tmp_out="$(mktemp "$out.out.XXXXXX")" || {
|
|
183
|
+
echo "pr-status: could not create a temp file beside $out; skipping its summary" >&2
|
|
184
|
+
return 1
|
|
185
|
+
}
|
|
186
|
+
tmp_err="$(mktemp "$out.err.XXXXXX")" || {
|
|
187
|
+
echo "pr-status: could not create a temp file beside $out; skipping its summary" >&2
|
|
188
|
+
rm -f "$tmp_out"
|
|
189
|
+
return 1
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
claude -p --max-turns 1 \
|
|
193
|
+
"Summarize the following PR item in 1-3 sentences describing its outcome. Plain prose only.
|
|
194
|
+
|
|
195
|
+
$(cat "$body")" > "$tmp_out" 2>"$tmp_err" </dev/null || rc=$?
|
|
196
|
+
|
|
197
|
+
local text; text="$(cat "$tmp_out")" || text=""
|
|
198
|
+
local first_line; first_line="$(grep -m1 -v '^[[:space:]]*$' <<<"$text")" || first_line=""
|
|
199
|
+
local reason=""
|
|
200
|
+
if (( rc != 0 )); then
|
|
201
|
+
reason="claude exited $rc"
|
|
202
|
+
elif [[ -z "${text//[[:space:]]/}" ]]; then
|
|
203
|
+
reason="claude produced no output"
|
|
204
|
+
elif grep -qiE '^[[:space:]]*(not logged in|please run /login|invalid api key|authentication failed)' \
|
|
205
|
+
<<<"$first_line"; then
|
|
206
|
+
# Claude Code generates these banners locally and exits zero, so the exit
|
|
207
|
+
# code cannot tell them from a summary. Matching only the opening line keeps
|
|
208
|
+
# a recap that discusses an auth bug — which mentions the same words, but
|
|
209
|
+
# further in — from being thrown away, while still catching a banner that
|
|
210
|
+
# runs long or spans lines.
|
|
211
|
+
reason="claude answered with an auth error instead of a summary"
|
|
212
|
+
elif ! mv "$tmp_out" "$out"; then
|
|
213
|
+
reason="the summary could not be moved into place"
|
|
214
|
+
fi
|
|
215
|
+
|
|
216
|
+
if [[ -z "$reason" ]]; then
|
|
217
|
+
rm -f "$tmp_err"
|
|
218
|
+
return 0
|
|
219
|
+
fi
|
|
220
|
+
|
|
221
|
+
echo "pr-status: could not summarize $body ($reason); leaving it unsummarized so a later run retries" >&2
|
|
222
|
+
[[ -s "$tmp_err" ]] && sed 's/^/pr-status: /' "$tmp_err" >&2
|
|
223
|
+
rm -f "$tmp_out" "$tmp_err"
|
|
224
|
+
return 1
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
# Args: <summary-file>
|
|
228
|
+
# A summary file that is empty, or that holds the literal `(summary
|
|
229
|
+
# unavailable)`, records a failure rather than a recap. It still satisfies the
|
|
230
|
+
# existence check that gates regeneration, so evict it and let this run write a
|
|
231
|
+
# real one.
|
|
232
|
+
evict_placeholder_summary() {
|
|
233
|
+
local sum="$1"
|
|
234
|
+
[[ -f "$sum" ]] || return 0
|
|
235
|
+
if [[ ! -s "$sum" ]] || [[ "$(cat "$sum")" == "(summary unavailable)" ]]; then
|
|
236
|
+
# An unremovable file is this item's problem, not the payload's: swallow the
|
|
237
|
+
# failure so the run keeps emitting rather than dying mid-section.
|
|
238
|
+
rm -f "$sum" || true
|
|
239
|
+
fi
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
# Args: <id> <subdir> <body>
|
|
243
|
+
# Writes <id>.md when the body changes. The summary is intentionally NOT deleted
|
|
244
|
+
# on change: a summary, once generated for a settled (non-actionable) item, is a
|
|
245
|
+
# recap the agent reads alongside the new content when the item later flips back
|
|
246
|
+
# to actionable (a reviewer reply) — instead of re-reading the whole thread. It
|
|
247
|
+
# is generated lazily (see the per-section emitters: only when an item is
|
|
248
|
+
# non-actionable and no summary exists yet) and then persists.
|
|
249
|
+
cache_item() {
|
|
250
|
+
local id="$1" sub="$2" body="$3"
|
|
251
|
+
local md="$DIR/$sub/$id.md"
|
|
252
|
+
local hash_file="$DIR/$sub/$id.hash"
|
|
253
|
+
evict_placeholder_summary "$DIR/$sub/$id.summary.md"
|
|
254
|
+
local new_hash; new_hash="$(printf '%s' "$body" | sha256sum | cut -d' ' -f1)"
|
|
255
|
+
local old_hash=""; [[ -f "$hash_file" ]] && old_hash="$(cat "$hash_file")"
|
|
256
|
+
if [[ ! -f "$md" || "$new_hash" != "$old_hash" ]]; then
|
|
257
|
+
printf '%s' "$body" > "$md"
|
|
258
|
+
printf '%s' "$new_hash" > "$hash_file"
|
|
259
|
+
fi
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
# --- fetch --------------------------------------------------------------------
|
|
263
|
+
PR_JSON="$(gh pr view "$PR" --json \
|
|
264
|
+
number,headRefName,headRefOid,baseRefName,state,mergedAt,mergeable,reviewDecision,isDraft,statusCheckRollup)"
|
|
265
|
+
|
|
266
|
+
HEAD="$(jq -r .headRefName <<<"$PR_JSON")"
|
|
267
|
+
|
|
268
|
+
# reviewThreads and top-level comments aren't both exposed with the fields we
|
|
269
|
+
# need by `gh pr view --json` (in particular it omits comment reactions, which
|
|
270
|
+
# Gate 6 needs), so fetch them via GraphQL.
|
|
271
|
+
# Use the numeric PR number from PR_JSON since `$PR` may have been passed as
|
|
272
|
+
# a URL or branch name (both accepted by `gh pr view`).
|
|
273
|
+
#
|
|
274
|
+
# `state` on the thread comments is load-bearing. A review still being drafted
|
|
275
|
+
# has PENDING comments, visible only to their author — so when the agent shares
|
|
276
|
+
# the operator's credentials they come back on this query and would otherwise
|
|
277
|
+
# read as ordinary actionable threads, putting the agent to work on feedback
|
|
278
|
+
# nobody has submitted. They are dropped per comment rather than per thread: a
|
|
279
|
+
# draft reply on an existing thread must hide the reply without also hiding the
|
|
280
|
+
# submitted feedback beneath it. A thread left with no comments disappears.
|
|
281
|
+
PR_NUMBER="$(jq -r .number <<<"$PR_JSON")"
|
|
282
|
+
OWNER="${REPO%/*}"
|
|
283
|
+
REPO_NAME="${REPO#*/}"
|
|
284
|
+
|
|
285
|
+
# Gate 5 is "zero actionable threads", so a thread the query never returns reads
|
|
286
|
+
# as a passing gate: the review-heavy PR that exceeds one 100-node page is
|
|
287
|
+
# exactly the one where dropped feedback matters. `--paginate` walks every page
|
|
288
|
+
# via $endCursor; `--jq …nodes[]` emits one node per line across all pages, which
|
|
289
|
+
# `jq -s` collects back into a single array.
|
|
290
|
+
#
|
|
291
|
+
# The nested comments(last:50) cap stays. Actionability keys on the newest
|
|
292
|
+
# submitted comment, which `last:50` keeps unless all 50 newest are unsubmitted
|
|
293
|
+
# drafts — in that one corner the PENDING filter empties the thread and it drops
|
|
294
|
+
# out of the snapshot, so Gate 5 can pass over real feedback. Otherwise the cap
|
|
295
|
+
# costs only a truncated cached thread body on a >50-comment thread, tracked
|
|
296
|
+
# separately as issue #203. Lifting it needs a per-thread follow-up query rather
|
|
297
|
+
# than a flag, since `--paginate` follows exactly one connection's cursor and
|
|
298
|
+
# this query spends it on reviewThreads.
|
|
299
|
+
THREADS_JSON="$(gh api graphql --paginate \
|
|
300
|
+
-F owner="$OWNER" -F repo="$REPO_NAME" -F pr="$PR_NUMBER" \
|
|
301
|
+
-f query='
|
|
302
|
+
query($owner:String!, $repo:String!, $pr:Int!, $endCursor:String) {
|
|
303
|
+
repository(owner:$owner, name:$repo) {
|
|
304
|
+
pullRequest(number:$pr) {
|
|
305
|
+
reviewThreads(first:100, after:$endCursor) {
|
|
306
|
+
nodes {
|
|
307
|
+
id
|
|
308
|
+
isResolved
|
|
309
|
+
comments(last:50) {
|
|
310
|
+
nodes { body state author { login } }
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
pageInfo { hasNextPage endCursor }
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}' --jq '.data.repository.pullRequest.reviewThreads.nodes[]' \
|
|
318
|
+
| jq -s '
|
|
319
|
+
map(.comments.nodes = ((.comments.nodes // []) | map(select(.state != "PENDING"))))
|
|
320
|
+
| map(select((.comments.nodes | length) > 0))
|
|
321
|
+
')"
|
|
322
|
+
|
|
323
|
+
# §2.2.2 requires EVERY top-level comment to appear, and Gate 6 reads reactions
|
|
324
|
+
# off the (recent) engagement comment, so the comments connection must be fully
|
|
325
|
+
# paginated — a `first:100` single page silently drops the engagement comment
|
|
326
|
+
# (and its approval reaction) on long-running PRs. `--paginate` walks every page
|
|
327
|
+
# via $endCursor; `--jq …nodes[]` emits one node per line across all pages, which
|
|
328
|
+
# `jq -s` collects back into a single array.
|
|
329
|
+
COMMENTS_JSON="$(gh api graphql --paginate \
|
|
330
|
+
-F owner="$OWNER" -F repo="$REPO_NAME" -F pr="$PR_NUMBER" \
|
|
331
|
+
-f query='
|
|
332
|
+
query($owner:String!, $repo:String!, $pr:Int!, $endCursor:String) {
|
|
333
|
+
repository(owner:$owner, name:$repo) {
|
|
334
|
+
pullRequest(number:$pr) {
|
|
335
|
+
comments(first:100, after:$endCursor) {
|
|
336
|
+
nodes {
|
|
337
|
+
id
|
|
338
|
+
databaseId
|
|
339
|
+
body
|
|
340
|
+
author { login }
|
|
341
|
+
reactions(first:100) {
|
|
342
|
+
nodes { content user { login } }
|
|
343
|
+
}
|
|
344
|
+
reactionGroups { content viewerHasReacted }
|
|
345
|
+
}
|
|
346
|
+
pageInfo { hasNextPage endCursor }
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
}' --jq '.data.repository.pullRequest.comments.nodes[]' | jq -s '.')"
|
|
351
|
+
|
|
352
|
+
# Reviews and review requests come from GraphQL, not `gh pr view --json`, because
|
|
353
|
+
# the latter's exporter only marshals User/Team requested reviewers and silently
|
|
354
|
+
# drops Bot-typed ones (e.g. Copilot) — which would break the pending override
|
|
355
|
+
# for a re-requested bot review. GraphQL's RequestedReviewer union lets us pull
|
|
356
|
+
# the login for User/Bot/Mannequin (and name/slug for Team), and the review
|
|
357
|
+
# author's __typename gives an authoritative bot flag.
|
|
358
|
+
#
|
|
359
|
+
# The reviews connection must be fully paginated: every review event is a node,
|
|
360
|
+
# so a long-running PR exceeds a single 100-node page, and a `first:100` cap
|
|
361
|
+
# silently drops the newest reviews — breaking the "most recent submitted review
|
|
362
|
+
# per reviewer" rule (a recent changes_requested, or a bot review that has since
|
|
363
|
+
# landed against an outstanding request, would be missed). `--paginate` walks
|
|
364
|
+
# every page via $endCursor; `--jq …nodes[]` emits one node per line, which
|
|
365
|
+
# `jq -s` collects into a single array. reviewRequests is bounded by the
|
|
366
|
+
# reviewer count (only currently-outstanding requests stand), so its single
|
|
367
|
+
# 100-node page is sufficient and is fetched separately — `--paginate` follows
|
|
368
|
+
# exactly one connection's cursor, so the two can't share a query.
|
|
369
|
+
#
|
|
370
|
+
# `id` and `body` come along because the review's own prose appears nowhere else
|
|
371
|
+
# in the snapshot — inline remarks arrive as review threads, but the body a
|
|
372
|
+
# `changes_requested` was submitted with has no other carrier. `id` keys its
|
|
373
|
+
# cache file and its `.ack`.
|
|
374
|
+
REVIEW_NODES="$(gh api graphql --paginate \
|
|
375
|
+
-F owner="$OWNER" -F repo="$REPO_NAME" -F pr="$PR_NUMBER" \
|
|
376
|
+
-f query='
|
|
377
|
+
query($owner:String!, $repo:String!, $pr:Int!, $endCursor:String) {
|
|
378
|
+
repository(owner:$owner, name:$repo) {
|
|
379
|
+
pullRequest(number:$pr) {
|
|
380
|
+
reviews(first:100, after:$endCursor) {
|
|
381
|
+
nodes { id body author { login __typename } state }
|
|
382
|
+
pageInfo { hasNextPage endCursor }
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
}' --jq '.data.repository.pullRequest.reviews.nodes[]' | jq -s '.')"
|
|
387
|
+
|
|
388
|
+
REVIEW_REQUEST_NODES="$(gh api graphql \
|
|
389
|
+
-F owner="$OWNER" -F repo="$REPO_NAME" -F pr="$PR_NUMBER" \
|
|
390
|
+
-f query='
|
|
391
|
+
query($owner:String!, $repo:String!, $pr:Int!) {
|
|
392
|
+
repository(owner:$owner, name:$repo) {
|
|
393
|
+
pullRequest(number:$pr) {
|
|
394
|
+
reviewRequests(first:100) {
|
|
395
|
+
nodes {
|
|
396
|
+
requestedReviewer {
|
|
397
|
+
__typename
|
|
398
|
+
... on User { login }
|
|
399
|
+
... on Bot { login }
|
|
400
|
+
... on Mannequin { login }
|
|
401
|
+
... on Team { name slug }
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
}' --jq '.data.repository.pullRequest.reviewRequests.nodes // []')"
|
|
408
|
+
|
|
409
|
+
# Re-wrap the two connections back into the {reviews:{nodes:…},
|
|
410
|
+
# reviewRequests:{nodes:…}} pullRequest-node shape reviews_xml's jq reads.
|
|
411
|
+
REVIEWS_JSON="$(jq -n \
|
|
412
|
+
--argjson reviews "$REVIEW_NODES" \
|
|
413
|
+
--argjson reqs "$REVIEW_REQUEST_NODES" \
|
|
414
|
+
'{reviews: {nodes: $reviews}, reviewRequests: {nodes: $reqs}}')"
|
|
415
|
+
|
|
416
|
+
# GraphQL ReactionContent → platform-normalized name per §2.2.2 reactions schema.
|
|
417
|
+
reaction_emoji() {
|
|
418
|
+
case "$1" in
|
|
419
|
+
THUMBS_UP) printf '+1' ;;
|
|
420
|
+
THUMBS_DOWN) printf -- '-1' ;;
|
|
421
|
+
LAUGH) printf 'laugh' ;;
|
|
422
|
+
HOORAY) printf 'hooray' ;;
|
|
423
|
+
CONFUSED) printf 'confused' ;;
|
|
424
|
+
HEART) printf 'heart' ;;
|
|
425
|
+
ROCKET) printf 'rocket' ;;
|
|
426
|
+
EYES) printf 'eyes' ;;
|
|
427
|
+
*) printf '%s' "$(tr '[:upper:]' '[:lower:]' <<<"$1")" ;;
|
|
428
|
+
esac
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
# --- checks -------------------------------------------------------------------
|
|
432
|
+
checks_xml() {
|
|
433
|
+
jq -r --arg info "$INFORMATIONAL_RE" --argjson stuck_after "$STUCK_AFTER_SEC" '
|
|
434
|
+
(now | floor) as $now |
|
|
435
|
+
.statusCheckRollup // [] | map({
|
|
436
|
+
name: (.name // .context // "check"),
|
|
437
|
+
conclusion: (.conclusion // .state // ""),
|
|
438
|
+
status: (.status // ""),
|
|
439
|
+
url: (.detailsUrl // .targetUrl // ""),
|
|
440
|
+
started: (.startedAt // ""),
|
|
441
|
+
informational: ($info != "" and (((.name // .context // "")) | test($info; "i")))
|
|
442
|
+
}) | map(. + {
|
|
443
|
+
pending: (.status == "IN_PROGRESS" or .status == "QUEUED" or .status == "PENDING" or .conclusion == "" and .status != ""),
|
|
444
|
+
failing: ((.conclusion // "") | test("FAILURE|TIMED_OUT|CANCELLED|STARTUP_FAILURE"; "i")),
|
|
445
|
+
stuck: (.status == "IN_PROGRESS" and .started != "" and (($now - (.started | fromdateiso8601? // $now)) > $stuck_after))
|
|
446
|
+
}) as $cs
|
|
447
|
+
| (any($cs[]; .pending and (.stuck | not))) as $any_pending
|
|
448
|
+
| (any($cs[]; .failing and (.informational | not) and (.pending | not))) as $any_failing
|
|
449
|
+
| (if $any_pending then "pending" elif $any_failing then "failing" else "passing" end) as $rollup
|
|
450
|
+
| " <checks state=\"\($rollup)\">",
|
|
451
|
+
($cs[] | " <check name=\"\(.name)\" conclusion=\"\(.conclusion)\" url=\"\(.url)\" informational=\"\(.informational)\" stuck=\"\(.stuck)\"/>"),
|
|
452
|
+
" </checks>"
|
|
453
|
+
' <<<"$PR_JSON"
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
# --- merge conflicts ----------------------------------------------------------
|
|
457
|
+
conflicts_xml() {
|
|
458
|
+
local m; m="$(jq -r '.mergeable // ""' <<<"$PR_JSON")"
|
|
459
|
+
local present=false; [[ "$m" == "CONFLICTING" ]] && present=true
|
|
460
|
+
echo " <merge-conflicts present=\"$present\"/>"
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
# --- reviews ------------------------------------------------------------------
|
|
464
|
+
# One persistent record per reviewer (PR #132 model): a reviewer who was
|
|
465
|
+
# requested OR has reviewed appears exactly once, carrying a status that walks
|
|
466
|
+
# pending -> commented/changes_requested/approved (plus dismissed). An
|
|
467
|
+
# outstanding request OVERRIDES any prior verdict back to "pending" — a fresh
|
|
468
|
+
# request "replaces" the old review until the reviewer re-reviews — so a
|
|
469
|
+
# re-requested Copilot, or an operator re-requested after approving, reads as
|
|
470
|
+
# pending and the agent keeps polling instead of treating the stale verdict as
|
|
471
|
+
# current. `state="pending"` is the in-flight signal: an empty thread set while a
|
|
472
|
+
# pending review stands is NOT convergence. Mode is bot iff GitHub types the
|
|
473
|
+
# account a Bot or the login matches the agent-identity regex; role is operator
|
|
474
|
+
# iff the login is the configured operator, else team (humans only). Input is the
|
|
475
|
+
# GraphQL pullRequest node ($REVIEWS_JSON): `.reviews.nodes` and
|
|
476
|
+
# `.reviewRequests.nodes` (the latter wrapping a RequestedReviewer union).
|
|
477
|
+
#
|
|
478
|
+
# The review body is a first-class work item: it carries `actionable` and, when
|
|
479
|
+
# there is a body to read, `cache`. Actionable unless the review it came from was
|
|
480
|
+
# dismissed or the agent has written the `.ack` sibling; a review with no body (a
|
|
481
|
+
# bare verdict, or a requested reviewer who has not reviewed) is never
|
|
482
|
+
# actionable. Reviews have no reply thread and take no reactions, so `.ack` — the
|
|
483
|
+
# annotation mechanism — is the only settling signal available.
|
|
484
|
+
#
|
|
485
|
+
# Emitted as one compact JSON object per review rather than TSV: a body carries
|
|
486
|
+
# newlines and tabs, which @tsv cannot round-trip through `read`.
|
|
487
|
+
reviews_xml() {
|
|
488
|
+
echo " <reviews>"
|
|
489
|
+
jq -c '
|
|
490
|
+
# Requested reviewers (currently-outstanding requests): login + bot-ness.
|
|
491
|
+
# The GraphQL union nests under .requestedReviewer; User/Bot/Mannequin carry
|
|
492
|
+
# .login, Team carries .slug/.name.
|
|
493
|
+
( [ (.reviewRequests.nodes // [])[]
|
|
494
|
+
| .requestedReviewer
|
|
495
|
+
| { login: ((.login // .slug // .name) // ""), is_bot: ((.__typename // "") == "Bot") }
|
|
496
|
+
| select(.login != "") ] ) as $reqs
|
|
497
|
+
| ( [ $reqs[].login | ascii_downcase ] ) as $reqset
|
|
498
|
+
# Latest SUBMITTED review per author, keyed by lowercased login. PENDING here
|
|
499
|
+
# means an unsubmitted draft (visible only to its author, e.g. the agent
|
|
500
|
+
# itself); it is invisible to the protocol, so drop it — "pending" in the
|
|
501
|
+
# output comes from an outstanding REQUEST, never an unsubmitted review.
|
|
502
|
+
| ( reduce (.reviews.nodes // [])[] as $r ({};
|
|
503
|
+
( ($r.author.login // "") ) as $lg
|
|
504
|
+
| if $lg == "" or ($r.state == "PENDING") then .
|
|
505
|
+
else ($lg | ascii_downcase) as $k
|
|
506
|
+
| (.[$k] // {}) as $prev
|
|
507
|
+
| .[$k] = { login: $lg,
|
|
508
|
+
is_bot: (($r.author.__typename // "") == "Bot"),
|
|
509
|
+
state: ($r.state // "COMMENTED") }
|
|
510
|
+
# The newest review supplies the state; the newest one that
|
|
511
|
+
# actually carries prose supplies the body. Otherwise a bare
|
|
512
|
+
# verdict submitted on top of a substantive review erases the
|
|
513
|
+
# body before the agent ever polls — the exact disappearance
|
|
514
|
+
# this element exists to prevent.
|
|
515
|
+
+ ( if (($r.body // "") | test("\\S"))
|
|
516
|
+
then { id: ($r.id // ""), body: ($r.body // ""),
|
|
517
|
+
body_state: ($r.state // "COMMENTED") }
|
|
518
|
+
else { id: ($prev.id // ""), body: ($prev.body // ""),
|
|
519
|
+
body_state: ($prev.body_state // "") }
|
|
520
|
+
end )
|
|
521
|
+
end
|
|
522
|
+
) ) as $rev
|
|
523
|
+
# Add a pending stub for any requested reviewer that has not reviewed.
|
|
524
|
+
| ( $rev + ( reduce $reqs[] as $q ({};
|
|
525
|
+
( $q.login | ascii_downcase ) as $k
|
|
526
|
+
| if ($rev[$k]) then . else .[$k] = { login: $q.login, is_bot: $q.is_bot, state: "PENDING", id: "", body: "", body_state: "" } end
|
|
527
|
+
) ) )
|
|
528
|
+
# Pending override: an outstanding request forces the status back to pending.
|
|
529
|
+
| to_entries
|
|
530
|
+
| map( .key as $k | .value + { state: ( if ($reqset | index($k)) then "PENDING" else .value.state end ) } )
|
|
531
|
+
| .[]
|
|
532
|
+
' <<<"$REVIEWS_JSON" |
|
|
533
|
+
while read -r r; do
|
|
534
|
+
local author is_bot state rid body body_state
|
|
535
|
+
author="$(jq -r '.login // ""' <<<"$r")"
|
|
536
|
+
[[ -n "$author" ]] || continue
|
|
537
|
+
is_bot="$(jq -r '.is_bot // false' <<<"$r")"
|
|
538
|
+
state="$(jq -r '.state // ""' <<<"$r")"
|
|
539
|
+
rid="$(jq -r '.id // ""' <<<"$r")"
|
|
540
|
+
body="$(jq -r '.body // ""' <<<"$r")"
|
|
541
|
+
# The state of the review the body came from, which is not always the
|
|
542
|
+
# element's state: a later bare verdict moves state without moving body.
|
|
543
|
+
body_state="$(jq -r '.body_state // ""' <<<"$r" | tr '[:upper:]' '[:lower:]')"
|
|
544
|
+
|
|
545
|
+
local mode="human"
|
|
546
|
+
if [[ "$is_bot" == "true" ]] || [[ "${author,,}" =~ (copilot|codex|claude|ai-agent) ]]; then
|
|
547
|
+
mode="bot"
|
|
548
|
+
fi
|
|
549
|
+
local s; s="$(tr '[:upper:]' '[:lower:]' <<<"$state")"
|
|
550
|
+
case "$s" in pending|commented|approved|changes_requested|dismissed) ;; *) s=commented ;; esac
|
|
551
|
+
|
|
552
|
+
local role_attr=""
|
|
553
|
+
if [[ "$mode" == "human" ]]; then
|
|
554
|
+
local role="team"
|
|
555
|
+
[[ "${author,,}" == "$OPERATOR_LC" ]] && role="operator"
|
|
556
|
+
role_attr=" role=\"$role\""
|
|
557
|
+
fi
|
|
558
|
+
|
|
559
|
+
# A body of only whitespace is the same as none: a bare verdict with nothing
|
|
560
|
+
# to read, so it gets no cache file and can never be work.
|
|
561
|
+
local actionable="false" reason_attr=' reason="no-body"' cache_attr="" inner=""
|
|
562
|
+
if [[ -n "$rid" && -n "${body//[[:space:]]/}" ]]; then
|
|
563
|
+
local id="${rid//[^A-Za-z0-9_=-]/_}"
|
|
564
|
+
cache_item "$id" "reviews" "$body"
|
|
565
|
+
local cache_path="$DIR/reviews/$id.md"
|
|
566
|
+
local sum_path="$DIR/reviews/$id.summary.md"
|
|
567
|
+
cache_attr=" cache=\"$(xml_attr "$cache_path")\""
|
|
568
|
+
if [[ -f "$DIR/reviews/$id.ack" ]]; then
|
|
569
|
+
reason_attr=' reason="acked"'
|
|
570
|
+
elif [[ "$body_state" == "dismissed" ]]; then
|
|
571
|
+
reason_attr=' reason="dismissed"'
|
|
572
|
+
else
|
|
573
|
+
actionable="true"; reason_attr=""
|
|
574
|
+
fi
|
|
575
|
+
# Same lazy recap as comments and threads: written once the item settles,
|
|
576
|
+
# then persisted and emitted in either state. A failed summary writes
|
|
577
|
+
# nothing and must not abort the payload — the item is emitted without one.
|
|
578
|
+
if [[ "$actionable" == "false" && ! -f "$sum_path" ]]; then
|
|
579
|
+
summarize "$cache_path" "$sum_path" || true
|
|
580
|
+
fi
|
|
581
|
+
[[ -f "$sum_path" ]] && inner="<summary>$(xml_text "$(cat "$sum_path")")</summary>"
|
|
582
|
+
fi
|
|
583
|
+
|
|
584
|
+
if [[ -z "$inner" ]]; then
|
|
585
|
+
printf ' <review author="%s" mode="%s"%s state="%s" actionable="%s"%s%s/>\n' \
|
|
586
|
+
"$(xml_attr "$author")" "$mode" "$role_attr" "$s" "$actionable" "$reason_attr" "$cache_attr"
|
|
587
|
+
else
|
|
588
|
+
printf ' <review author="%s" mode="%s"%s state="%s" actionable="%s"%s%s>%s</review>\n' \
|
|
589
|
+
"$(xml_attr "$author")" "$mode" "$role_attr" "$s" "$actionable" "$reason_attr" "$cache_attr" "$inner"
|
|
590
|
+
fi
|
|
591
|
+
done
|
|
592
|
+
echo " </reviews>"
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
# Returns 0 iff $1's last non-empty line is a canonical terminal signal.
|
|
596
|
+
# Anchoring to the last non-empty line matches reference.md ("must be the
|
|
597
|
+
# last non-empty line"); inline mentions of "done" in prose stay actionable.
|
|
598
|
+
has_terminal_signal() {
|
|
599
|
+
local last
|
|
600
|
+
last="$(printf '%s\n' "$1" | grep -v '^[[:space:]]*$' | tail -n1)"
|
|
601
|
+
[[ -n "$last" ]] || return 1
|
|
602
|
+
grep -qiE "$TERMINAL_RE" <<<"$last"
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
# --- actionability ------------------------------------------------------------
|
|
606
|
+
# Args: <newest-body> <newest-author-login> <thread-resolved:true|false>
|
|
607
|
+
# Echoes a tab-separated "<actionable>\t<reason>" pair: actionable is "true" or
|
|
608
|
+
# "false"; reason is empty when actionable, else a stable token explaining *why*
|
|
609
|
+
# the item is suppressed (`resolved`, `agent-artifact`, `agent-terminal-reply`).
|
|
610
|
+
# The reason is surfaced as a `reason=` attribute so the agent never has to
|
|
611
|
+
# re-derive suppression from the human-facing <summary> prose (which describes
|
|
612
|
+
# the thread's *content* and will read as if an addressed reviewer point still
|
|
613
|
+
# stands).
|
|
614
|
+
# Full rules: reference.md → Actionability. Summary: non-actionable iff
|
|
615
|
+
# thread-resolved, or the body is the calling agent's plan comment or
|
|
616
|
+
# engagement comment (line-anchored agent-plan/agent-engagement sentinel +
|
|
617
|
+
# author identity, so a human quoting the marker stays actionable), or the body
|
|
618
|
+
# is the calling agent's terminal-tagged reply (any agent-reply marker + author
|
|
619
|
+
# identity + terminal signal on the last non-empty line), or the calling agent
|
|
620
|
+
# reacted to the item with a terminal reaction (+1/-1/rocket). The author checks
|
|
621
|
+
# key on $CALLER_LOGIN (guaranteed set — the script exits earlier if it can't be
|
|
622
|
+
# resolved).
|
|
623
|
+
classify_actionable() {
|
|
624
|
+
local body="$1" author="$2" resolved="$3" reaction_groups="${4:-[]}"
|
|
625
|
+
[[ "$resolved" == "true" ]] && { printf 'false\tresolved\n'; return; }
|
|
626
|
+
|
|
627
|
+
# Plan or engagement comment by the calling agent. Both are agent artifacts,
|
|
628
|
+
# not reviewer items: the engagement comment in particular anchors operator
|
|
629
|
+
# approval (Gate 6) and would otherwise stay actionable forever — blocking
|
|
630
|
+
# Gate 4 and thus the draft-clear/merge transitions — since the agent never
|
|
631
|
+
# "addresses" its own request.
|
|
632
|
+
if [[ "$author" == "$CALLER_LOGIN" ]] \
|
|
633
|
+
&& grep -qE '^<!-- agent-(plan|engagement):[^ ]+ -->$' <<<"$body"; then
|
|
634
|
+
printf 'false\tagent-artifact\n'; return
|
|
635
|
+
fi
|
|
636
|
+
|
|
637
|
+
# Terminal-tagged reply by the calling agent: own login + an agent-reply marker
|
|
638
|
+
# + a terminal signal on the last non-empty line.
|
|
639
|
+
if has_terminal_signal "$body" \
|
|
640
|
+
&& [[ "$author" == "$CALLER_LOGIN" ]] \
|
|
641
|
+
&& grep -qF '<!-- agent-reply:' <<<"$body"; then
|
|
642
|
+
printf 'false\tagent-terminal-reply\n'; return
|
|
643
|
+
fi
|
|
644
|
+
|
|
645
|
+
# A terminal reaction (+1/-1/rocket, reference.md → Terminal signals) by the
|
|
646
|
+
# calling agent. Top-level comments have no reply threading, so this is the
|
|
647
|
+
# only signal that can ever settle a comment someone else authored — without
|
|
648
|
+
# it an operator note stays actionable forever and Gate 4 never passes.
|
|
649
|
+
# Keys on reactionGroups' viewerHasReacted rather than scanning reaction
|
|
650
|
+
# nodes: the GraphQL viewer is the same identity as $CALLER_LOGIN (one gh
|
|
651
|
+
# token), and viewerHasReacted is set regardless of how many reactions the
|
|
652
|
+
# comment has — a nodes scan is capped at the first page and can miss the
|
|
653
|
+
# caller's reaction on a heavily-reacted comment.
|
|
654
|
+
if jq -e 'any(.[]; .viewerHasReacted == true
|
|
655
|
+
and (.content == "THUMBS_UP" or .content == "THUMBS_DOWN"
|
|
656
|
+
or .content == "ROCKET"))' \
|
|
657
|
+
<<<"$reaction_groups" >/dev/null 2>&1; then
|
|
658
|
+
printf 'false\tagent-terminal-reply\n'; return
|
|
659
|
+
fi
|
|
660
|
+
printf 'true\t\n'
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
# Parse a classify_actionable result into the globals $CA_ACTIONABLE and
|
|
664
|
+
# $CA_REASON_ATTR (the latter ready to splice into a start tag, empty unless
|
|
665
|
+
# non-actionable with a reason). Keeps the tab-splitting in one place.
|
|
666
|
+
parse_actionable() {
|
|
667
|
+
CA_ACTIONABLE="${1%%$'\t'*}"
|
|
668
|
+
local reason="${1#*$'\t'}"
|
|
669
|
+
if [[ "$CA_ACTIONABLE" == "false" && -n "$reason" ]]; then
|
|
670
|
+
CA_REASON_ATTR=" reason=\"$(xml_attr "$reason")\""
|
|
671
|
+
else
|
|
672
|
+
CA_REASON_ATTR=""
|
|
673
|
+
fi
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
# Emit <reactions>…</reactions> for a reactions-node JSON array, or nothing if
|
|
677
|
+
# the array is empty. Used by comments_xml.
|
|
678
|
+
reactions_xml_for() {
|
|
679
|
+
local rj="$1"
|
|
680
|
+
if [[ -z "$rj" || "$rj" == "[]" || "$rj" == "null" ]]; then
|
|
681
|
+
return 0
|
|
682
|
+
fi
|
|
683
|
+
echo "<reactions>"
|
|
684
|
+
jq -r '.[] | [(.user.login // ""), (.content // "")] | @tsv' <<<"$rj" |
|
|
685
|
+
while IFS=$'\t' read -r user content; do
|
|
686
|
+
[[ -n "$user" && -n "$content" ]] || continue
|
|
687
|
+
local emoji; emoji="$(reaction_emoji "$content")"
|
|
688
|
+
printf ' <reaction author="%s" emoji="%s"/>\n' \
|
|
689
|
+
"$(xml_attr "$user")" "$(xml_attr "$emoji")"
|
|
690
|
+
done
|
|
691
|
+
echo " </reactions>"
|
|
692
|
+
return 0
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
# --- comments (top-level PR comments) -----------------------------------------
|
|
696
|
+
comments_xml() {
|
|
697
|
+
echo " <comments>"
|
|
698
|
+
jq -c '.[]' <<<"$COMMENTS_JSON" | while read -r c; do
|
|
699
|
+
local raw_id id author body reactions reaction_groups
|
|
700
|
+
raw_id="$(jq -r '.id // .databaseId // ""' <<<"$c")"
|
|
701
|
+
author="$(jq -r '.author.login // ""' <<<"$c")"
|
|
702
|
+
body="$(jq -r '.body // ""' <<<"$c")"
|
|
703
|
+
reactions="$(jq -c '.reactions.nodes // []' <<<"$c")"
|
|
704
|
+
reaction_groups="$(jq -c '.reactionGroups // []' <<<"$c")"
|
|
705
|
+
[[ -n "$raw_id" ]] || continue
|
|
706
|
+
id="${raw_id//[^A-Za-z0-9_=-]/_}"
|
|
707
|
+
cache_item "$id" "comments" "$body"
|
|
708
|
+
parse_actionable "$(classify_actionable "$body" "$author" "false" "$reaction_groups")"
|
|
709
|
+
local actionable="$CA_ACTIONABLE" reason_attr="$CA_REASON_ATTR"
|
|
710
|
+
local cache_path="$DIR/comments/$id.md"
|
|
711
|
+
local sum_path="$DIR/comments/$id.summary.md"
|
|
712
|
+
# Generate the recap lazily, only for a settled (non-actionable) item with no
|
|
713
|
+
# summary yet; once written it persists and is emitted in either state so the
|
|
714
|
+
# agent can read it when the item later flips back to actionable.
|
|
715
|
+
if [[ "$actionable" == "false" && ! -f "$sum_path" ]]; then
|
|
716
|
+
summarize "$cache_path" "$sum_path" || true
|
|
717
|
+
fi
|
|
718
|
+
local inner=""
|
|
719
|
+
[[ -f "$sum_path" ]] && inner+="<summary>$(xml_text "$(cat "$sum_path")")</summary>"
|
|
720
|
+
inner+="$(reactions_xml_for "$reactions")"
|
|
721
|
+
if [[ -z "$inner" ]]; then
|
|
722
|
+
printf ' <comment id="%s" actionable="%s"%s cache="%s"/>\n' \
|
|
723
|
+
"$(xml_attr "$id")" "$actionable" "$reason_attr" "$(xml_attr "$cache_path")"
|
|
724
|
+
else
|
|
725
|
+
printf ' <comment id="%s" actionable="%s"%s cache="%s">%s</comment>\n' \
|
|
726
|
+
"$(xml_attr "$id")" "$actionable" "$reason_attr" "$(xml_attr "$cache_path")" "$inner"
|
|
727
|
+
fi
|
|
728
|
+
done
|
|
729
|
+
echo " </comments>"
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
# --- review threads -----------------------------------------------------------
|
|
733
|
+
threads_xml() {
|
|
734
|
+
echo " <threads>"
|
|
735
|
+
jq -c '.[]' <<<"$THREADS_JSON" | while read -r t; do
|
|
736
|
+
local id resolved newest_body newest_author body
|
|
737
|
+
id="$(jq -r '.id // ""' <<<"$t")"
|
|
738
|
+
resolved="$(jq -r '.isResolved // false' <<<"$t")"
|
|
739
|
+
body="$(jq -r '[.comments.nodes[]? | "[" + (.author.login // "?") + "] " + (.body // "")] | join("\n\n---\n\n")' <<<"$t")"
|
|
740
|
+
newest_body="$(jq -r '.comments.nodes // [] | last.body // ""' <<<"$t")"
|
|
741
|
+
newest_author="$(jq -r '.comments.nodes // [] | last.author.login // ""' <<<"$t")"
|
|
742
|
+
[[ -n "$id" ]] || continue
|
|
743
|
+
id="${id//[^A-Za-z0-9_=-]/_}"
|
|
744
|
+
cache_item "$id" "threads" "$body"
|
|
745
|
+
parse_actionable "$(classify_actionable "$newest_body" "$newest_author" "$resolved")"
|
|
746
|
+
local actionable="$CA_ACTIONABLE" reason_attr="$CA_REASON_ATTR"
|
|
747
|
+
local cache_path="$DIR/threads/$id.md"
|
|
748
|
+
local sum_path="$DIR/threads/$id.summary.md"
|
|
749
|
+
# Generate the recap lazily for a settled (non-actionable) thread with none
|
|
750
|
+
# yet; it persists and is emitted in either state. When the thread flips back
|
|
751
|
+
# to actionable (a reviewer reply), the agent reads this recap plus the new
|
|
752
|
+
# content from the cache file instead of re-reading the whole thread.
|
|
753
|
+
if [[ "$actionable" == "false" && ! -f "$sum_path" ]]; then
|
|
754
|
+
summarize "$cache_path" "$sum_path" || true
|
|
755
|
+
fi
|
|
756
|
+
if [[ -f "$sum_path" ]]; then
|
|
757
|
+
printf ' <thread id="%s" actionable="%s"%s cache="%s"><summary>%s</summary></thread>\n' \
|
|
758
|
+
"$(xml_attr "$id")" "$actionable" "$reason_attr" "$(xml_attr "$cache_path")" "$(xml_text "$(cat "$sum_path")")"
|
|
759
|
+
else
|
|
760
|
+
printf ' <thread id="%s" actionable="%s"%s cache="%s"/>\n' \
|
|
761
|
+
"$(xml_attr "$id")" "$actionable" "$reason_attr" "$(xml_attr "$cache_path")"
|
|
762
|
+
fi
|
|
763
|
+
done
|
|
764
|
+
echo " </threads>"
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
# --- annotations (code scanning / check annotations) --------------------------
|
|
768
|
+
annotations_xml() {
|
|
769
|
+
echo " <annotations>"
|
|
770
|
+
local owner="${REPO%/*}" repo="${REPO#*/}"
|
|
771
|
+
local sha; sha="$(jq -r .headRefOid <<<"$PR_JSON")"
|
|
772
|
+
local runs
|
|
773
|
+
runs="$(gh api "repos/$owner/$repo/commits/$sha/check-runs" --jq '.check_runs // []' 2>/dev/null || echo '[]')"
|
|
774
|
+
jq -c '.[]' <<<"$runs" | while read -r run; do
|
|
775
|
+
local run_id; run_id="$(jq -r '.id' <<<"$run")"
|
|
776
|
+
gh api "repos/$owner/$repo/check-runs/$run_id/annotations" 2>/dev/null \
|
|
777
|
+
| jq -c '.[]?' | while read -r a; do
|
|
778
|
+
local path line msg id body
|
|
779
|
+
path="$(jq -r '.path // ""' <<<"$a")"
|
|
780
|
+
line="$(jq -r '.start_line // 0' <<<"$a")"
|
|
781
|
+
msg="$(jq -r '.message // ""' <<<"$a")"
|
|
782
|
+
body="[$path:$line] $msg"
|
|
783
|
+
id="$(printf '%s' "$body" | sha256sum | cut -c1-16)"
|
|
784
|
+
cache_item "$id" "annotations" "$body"
|
|
785
|
+
local ack="$DIR/annotations/$id.ack"
|
|
786
|
+
local cache_path="$DIR/annotations/$id.md"
|
|
787
|
+
local sum_path="$DIR/annotations/$id.summary.md"
|
|
788
|
+
if [[ -f "$ack" ]]; then
|
|
789
|
+
[[ -f "$sum_path" ]] || summarize "$cache_path" "$sum_path" || true
|
|
790
|
+
# The summary is best-effort: an acked annotation is still emitted, and
|
|
791
|
+
# still non-actionable, when the summary could not be generated.
|
|
792
|
+
if [[ -f "$sum_path" ]]; then
|
|
793
|
+
printf ' <annotation id="%s" actionable="false" reason="acked" cache="%s"><summary>%s</summary></annotation>\n' \
|
|
794
|
+
"$(xml_attr "$id")" "$(xml_attr "$cache_path")" "$(xml_text "$(cat "$sum_path")")"
|
|
795
|
+
else
|
|
796
|
+
printf ' <annotation id="%s" actionable="false" reason="acked" cache="%s"/>\n' \
|
|
797
|
+
"$(xml_attr "$id")" "$(xml_attr "$cache_path")"
|
|
798
|
+
fi
|
|
799
|
+
else
|
|
800
|
+
printf ' <annotation id="%s" actionable="true" cache="%s"/>\n' \
|
|
801
|
+
"$(xml_attr "$id")" "$(xml_attr "$cache_path")"
|
|
802
|
+
fi
|
|
803
|
+
done
|
|
804
|
+
done
|
|
805
|
+
echo " </annotations>"
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
# --- terminal resolution ------------------------------------------------------
|
|
809
|
+
# Returns 0 if the PR's net change is present in the base tip (shipped), 1 if
|
|
810
|
+
# not (abandoned), 2 if the check could not run (no repo / git or fetch failure).
|
|
811
|
+
# Squash/rebase-safe: builds the PR's combined net patch and reverse-applies it
|
|
812
|
+
# against a temp index seeded from the base tip, so an n→1 squash or a rebase
|
|
813
|
+
# rewrite still matches by content (per-commit patch-ids, which `git cherry`
|
|
814
|
+
# uses, break under squash). Side-effect-free — never touches the caller's
|
|
815
|
+
# worktree, index, or HEAD. An empty net patch (no-op PR) reverse-applies
|
|
816
|
+
# trivially → present → shipped, which is intended.
|
|
817
|
+
content_present() {
|
|
818
|
+
local base_ref="$1"
|
|
819
|
+
command -v git >/dev/null 2>&1 || return 2
|
|
820
|
+
git rev-parse --git-dir >/dev/null 2>&1 || return 2
|
|
821
|
+
|
|
822
|
+
# Fetch the head commit by SHA via refs/pull/<n>/head — the head branch may
|
|
823
|
+
# have been deleted on close, but GitHub keeps the SHA reachable here.
|
|
824
|
+
git fetch --quiet origin "refs/pull/$PR_NUMBER/head" 2>/dev/null || return 2
|
|
825
|
+
local head_sha; head_sha="$(git rev-parse --verify --quiet FETCH_HEAD)" || return 2
|
|
826
|
+
[[ -n "$head_sha" ]] || return 2
|
|
827
|
+
|
|
828
|
+
# Fetch the base tip (FETCH_HEAD gets overwritten, so head_sha is captured first).
|
|
829
|
+
git fetch --quiet origin "$base_ref" 2>/dev/null || return 2
|
|
830
|
+
local base_sha; base_sha="$(git rev-parse --verify --quiet FETCH_HEAD)" || return 2
|
|
831
|
+
[[ -n "$base_sha" ]] || return 2
|
|
832
|
+
|
|
833
|
+
local mb; mb="$(git merge-base "$base_sha" "$head_sha" 2>/dev/null)" || return 2
|
|
834
|
+
[[ -n "$mb" ]] || return 2
|
|
835
|
+
|
|
836
|
+
# No-op PR: an empty net patch is trivially present in base → shipped. Must be
|
|
837
|
+
# short-circuited because `git apply --check` rejects empty input ("No valid
|
|
838
|
+
# patches in input"), which would otherwise misclassify a no-op PR as abandoned.
|
|
839
|
+
if git diff --quiet "$mb" "$head_sha" 2>/dev/null; then
|
|
840
|
+
return 0
|
|
841
|
+
fi
|
|
842
|
+
|
|
843
|
+
# Reverse-apply the net patch against a temp index seeded from the base tip.
|
|
844
|
+
# `rm -f` the mktemp file so the index path is free: some git versions read an
|
|
845
|
+
# existing empty GIT_INDEX_FILE as a corrupt index, failing read-tree. Clean up
|
|
846
|
+
# inline rather than via a RETURN trap — a RETURN trap would re-fire on the
|
|
847
|
+
# *caller's* return and, under `set -u`, abort the rest of the XML emission.
|
|
848
|
+
local tmp_index; tmp_index="$(mktemp)" || return 2
|
|
849
|
+
rm -f "$tmp_index"
|
|
850
|
+
if ! GIT_INDEX_FILE="$tmp_index" git read-tree "$base_sha" 2>/dev/null; then
|
|
851
|
+
rm -f "$tmp_index"
|
|
852
|
+
return 2
|
|
853
|
+
fi
|
|
854
|
+
# --binary emits an applyable full binary patch; without it git diff writes a
|
|
855
|
+
# "Binary files differ" placeholder that git apply rejects, misclassifying any
|
|
856
|
+
# PR touching binary files as abandoned.
|
|
857
|
+
local rc=1
|
|
858
|
+
if git diff --binary "$mb" "$head_sha" \
|
|
859
|
+
| GIT_INDEX_FILE="$tmp_index" git apply --reverse --cached --check - 2>/dev/null; then
|
|
860
|
+
rc=0
|
|
861
|
+
fi
|
|
862
|
+
rm -f "$tmp_index"
|
|
863
|
+
return "$rc"
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
# Resolve the PR's terminal end-to-end and emit <terminal>. Binary at closure
|
|
867
|
+
# (shipped|abandoned); non-terminal while the PR is live (open|draft). Cheapest
|
|
868
|
+
# signals first; git is shelled only on the CLOSED-but-not-merged + ahead_by>0
|
|
869
|
+
# branch, never on the hot poll loop. Carries the raw signals it used.
|
|
870
|
+
terminal_xml() {
|
|
871
|
+
local state merged_at base_ref head_oid is_draft
|
|
872
|
+
state="$(jq -r '.state // ""' <<<"$PR_JSON")"
|
|
873
|
+
merged_at="$(jq -r '.mergedAt // ""' <<<"$PR_JSON")"
|
|
874
|
+
base_ref="$(jq -r '.baseRefName // ""' <<<"$PR_JSON")"
|
|
875
|
+
head_oid="$(jq -r '.headRefOid // ""' <<<"$PR_JSON")"
|
|
876
|
+
is_draft="$(jq -r '.isDraft // false' <<<"$PR_JSON")"
|
|
877
|
+
|
|
878
|
+
local gh_merged=false
|
|
879
|
+
[[ "$state" == "MERGED" || ( -n "$merged_at" && "$merged_at" != "null" ) ]] && gh_merged=true
|
|
880
|
+
|
|
881
|
+
# Non-terminal: PR still open.
|
|
882
|
+
if [[ "$state" == "OPEN" ]]; then
|
|
883
|
+
local s=open; [[ "$is_draft" == "true" ]] && s=draft
|
|
884
|
+
printf ' <terminal state="%s" gh-merged="%s" ahead-by="-"/>\n' "$s" "$gh_merged"
|
|
885
|
+
return
|
|
886
|
+
fi
|
|
887
|
+
|
|
888
|
+
# Step 1: GitHub says merged → shipped. API only.
|
|
889
|
+
if [[ "$gh_merged" == "true" ]]; then
|
|
890
|
+
printf ' <terminal state="shipped" gh-merged="true" ahead-by="-"/>\n'
|
|
891
|
+
return
|
|
892
|
+
fi
|
|
893
|
+
|
|
894
|
+
# CLOSED without `merged`. Step 2: one three-dot compare call, read ahead_by.
|
|
895
|
+
# ahead_by == 0 → every head commit is already in base (plain merge /
|
|
896
|
+
# fast-forward / merge-queue close where GitHub never set merged) → shipped,
|
|
897
|
+
# no git. Works even if the head branch was deleted — compare accepts the SHA.
|
|
898
|
+
local ahead_by
|
|
899
|
+
ahead_by="$(gh api "repos/$OWNER/$REPO_NAME/compare/$base_ref...$head_oid" \
|
|
900
|
+
--jq '.ahead_by' 2>/dev/null || echo "")"
|
|
901
|
+
|
|
902
|
+
if [[ "$ahead_by" == "0" ]]; then
|
|
903
|
+
printf ' <terminal state="shipped" gh-merged="false" ahead-by="0"/>\n'
|
|
904
|
+
return
|
|
905
|
+
fi
|
|
906
|
+
|
|
907
|
+
# ahead_by > 0 (or compare failed): could be squash/rebase-landed or genuinely
|
|
908
|
+
# abandoned; the API can't tell. Step 3: content check via git (the only path
|
|
909
|
+
# that shells git). On no-repo / fetch failure we do NOT guess — emit abandoned
|
|
910
|
+
# with an error breadcrumb so delivery is never falsely claimed.
|
|
911
|
+
local ab_attr="${ahead_by:--}"
|
|
912
|
+
local rc=0
|
|
913
|
+
if content_present "$base_ref"; then rc=0; else rc=$?; fi
|
|
914
|
+
case "$rc" in
|
|
915
|
+
0) printf ' <terminal state="shipped" gh-merged="false" ahead-by="%s"/>\n' "$ab_attr" ;;
|
|
916
|
+
1) printf ' <terminal state="abandoned" gh-merged="false" ahead-by="%s"/>\n' "$ab_attr" ;;
|
|
917
|
+
*) printf ' <terminal state="abandoned" gh-merged="false" ahead-by="%s" error="content-check-unavailable"/>\n' "$ab_attr" ;;
|
|
918
|
+
esac
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
# --- emit ---------------------------------------------------------------------
|
|
922
|
+
printf '<pr-status repo="%s" pr="%s" head="%s">\n' \
|
|
923
|
+
"$(xml_attr "$REPO")" "$(xml_attr "$PR")" "$(xml_attr "$HEAD")"
|
|
924
|
+
terminal_xml
|
|
925
|
+
checks_xml
|
|
926
|
+
conflicts_xml
|
|
927
|
+
reviews_xml
|
|
928
|
+
comments_xml
|
|
929
|
+
threads_xml
|
|
930
|
+
annotations_xml
|
|
931
|
+
echo '</pr-status>'
|