@erclx/canon 5.14.0 → 5.14.1
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/.claude-plugin/plugin.json +1 -1
- package/claude/skills/review-pr/SKILL.md +1 -1
- package/claude/skills/review-pr/references/close-out.md +1 -1
- package/claude/skills/review-pr/references/post.md +1 -1
- package/claude/skills/role-orchestrator/SKILL.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +10 -10
- package/claude/skills/role-orchestrator/scripts/poll.ts +628 -0
- package/claude/skills/role-orchestrator/scripts/repo.ts +69 -0
- package/claude/skills/role-orchestrator/scripts/watch.ts +267 -0
- package/docs/agents/pr-reads.md +1 -1
- package/docs/agents/sandbox.md +3 -3
- package/docs/agents/state-scoped-risk.md +2 -2
- package/package.json +4 -5
- package/scripts/core/check-color-source.sh +11 -1
- package/scripts/core/install-check.sh +13 -4
- package/scripts/lib/sandbox-dispatch.sh +1 -1
- package/scripts/lib/sandbox-fixtures.sh +2 -2
- package/scripts/lib/sandbox-path.sh +1 -1
- package/scripts/manage-sandbox.sh +2 -2
- package/src/commands/sandbox.ts +2 -2
- package/src/context/citations.ts +1 -1
- package/src/sandbox/census.ts +1 -1
- package/src/sandbox/coverage.ts +1 -1
- package/src/sandbox/expect.ts +0 -1
- package/src/shipped/references.ts +8 -10
- package/src/targets/pulls.ts +2 -2
- package/tsconfig.json +1 -1
- package/claude/skills/role-orchestrator/scripts/poll.sh +0 -504
- package/claude/skills/role-orchestrator/scripts/watch.sh +0 -181
|
@@ -1,504 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Reports pull request movement since the last run. Reads only.
|
|
3
|
-
#
|
|
4
|
-
# The shebang is load-bearing. An earlier version ran under the operator's zsh,
|
|
5
|
-
# where an unquoted parameter expansion does not word-split, so `set -- $line`
|
|
6
|
-
# left every field but the first empty and every head compared unequal. The
|
|
7
|
-
# poll reported movement that had not happened, which is the one failure that
|
|
8
|
-
# makes a detection tool worth less than no tool.
|
|
9
|
-
set -e
|
|
10
|
-
set -o pipefail
|
|
11
|
-
|
|
12
|
-
# The baseline is per-machine mutable state, so it stays in gitignored scratch
|
|
13
|
-
# even though the script is tracked. Resolving the main worktree root rather
|
|
14
|
-
# than this file's own folder keeps a poll started from a linked worktree
|
|
15
|
-
# reading the baseline a poll started from main wrote.
|
|
16
|
-
MAIN_ROOT="$(git worktree list --porcelain 2>/dev/null | awk '/^worktree /{print $2; exit}')"
|
|
17
|
-
if [ -z "$MAIN_ROOT" ]; then
|
|
18
|
-
echo "poll: not a git repository, so nothing is classified" >&2
|
|
19
|
-
exit 1
|
|
20
|
-
fi
|
|
21
|
-
STATE_DIR="$MAIN_ROOT/.canon/tmp/pr/poll"
|
|
22
|
-
mkdir -p "$STATE_DIR"
|
|
23
|
-
STATE="$STATE_DIR/baseline.txt"
|
|
24
|
-
touch "$STATE"
|
|
25
|
-
|
|
26
|
-
# The base branch is read rather than assumed, since this ships to projects that
|
|
27
|
-
# do not all call it `main`. A wrong base is not a visible failure: merge-tree
|
|
28
|
-
# reports every pull request as conflicted against a ref that does not resolve.
|
|
29
|
-
BASE_REF="$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null || true)"
|
|
30
|
-
if [ -z "$BASE_REF" ]; then
|
|
31
|
-
BASE_REF="origin/$(gh repo view --json defaultBranchRef --jq .defaultBranchRef.name 2>/dev/null || echo main)"
|
|
32
|
-
fi
|
|
33
|
-
BASE_BRANCH="${BASE_REF#origin/}"
|
|
34
|
-
|
|
35
|
-
# These eight strings are owned elsewhere and pinned here. `review-pr`
|
|
36
|
-
# writes `## Review` and `## Review closed`, and states the full eight-heading
|
|
37
|
-
# set once, beside the threshold it already states once. `review-address`
|
|
38
|
-
# writes `## Review response`, `## Rebase`, and `## Post-review findings`, the
|
|
39
|
-
# last for a finding a worker produces after a close-out rather than in answer
|
|
40
|
-
# to one already on the thread. `canon pr evidence`, run through `git-pr` and
|
|
41
|
-
# `git-followup`, writes `## Evidence`. `review-ui` writes `## UI review` and
|
|
42
|
-
# `## UI review closed`. All these surfaces ship separately, so a heading added
|
|
43
|
-
# in any of them breaks a test here that no check reaches across.
|
|
44
|
-
#
|
|
45
|
-
# All four families match on the first line alone so the tests stay
|
|
46
|
-
# symmetric. The reply family carries `## Rebase` and `## Post-review
|
|
47
|
-
# findings` beside `## Review response` because neither answers a comment
|
|
48
|
-
# already on the thread, which is why both were kept outside the `## Review`
|
|
49
|
-
# family rather than folded into it. `## Evidence` answers no comment either,
|
|
50
|
-
# and it stays out of the reply family rather than joining it: an evidence
|
|
51
|
-
# comment is not a response to a review, so counting it as one would send the
|
|
52
|
-
# poll back for a re-review nothing asked for. It is excluded from the
|
|
53
|
-
# unmatched filter below instead, on its own.
|
|
54
|
-
#
|
|
55
|
-
# The review family reaches this file through `canon pr review-state` rather
|
|
56
|
-
# than through a filter of its own, so the two headings are pinned here only in
|
|
57
|
-
# the fallback that answers for a target whose CLI predates that verb.
|
|
58
|
-
# The count alone answers whether a reply is new to this script, which is not
|
|
59
|
-
# the same question as whether it is newer than the pass it answers. The stamp
|
|
60
|
-
# of the newest reply comes out of the same selection so the recency test costs
|
|
61
|
-
# no second filter, and it is the one this file compares against `submittedAt`.
|
|
62
|
-
JQ_REPLY_STATE='
|
|
63
|
-
[ .comments[]
|
|
64
|
-
| select((.body // "") | split("\n")[0] | rtrimstr("\r")
|
|
65
|
-
| . == "## Review response" or . == "## Rebase"
|
|
66
|
-
or . == "## Post-review findings")
|
|
67
|
-
] as $replies
|
|
68
|
-
| ($replies | length | tostring)
|
|
69
|
-
+ " "
|
|
70
|
-
+ ([ $replies[] | .createdAt // empty | fromdateiso8601 | floor ]
|
|
71
|
-
| max // 0 | tostring)
|
|
72
|
-
'
|
|
73
|
-
# A comment matching neither family above is the gap this filter exists to
|
|
74
|
-
# surface rather than absorb: a worker inventing a sixth heading used to reach
|
|
75
|
-
# this script as silence, indistinguishable from no comment at all. The count
|
|
76
|
-
# is read the same way the reply count is, a rising value against the baseline
|
|
77
|
-
# being new to this script, and the newest heading's text rides along for the
|
|
78
|
-
# report. It carries its spaces intact rather than encoded, since it is the
|
|
79
|
-
# last field on both sides of the pipe: `${unmatched_state#* }` below strips
|
|
80
|
-
# only the first space, and `read -r` further down hands its last named
|
|
81
|
-
# variable the rest of the line whole. Neither reads a heading's own spaces
|
|
82
|
-
# as a field separator, so a heading round-trips verbatim into the report a
|
|
83
|
-
# person reads when deciding whether to answer it by hand.
|
|
84
|
-
JQ_UNMATCHED_STATE='
|
|
85
|
-
[ .comments[]
|
|
86
|
-
| (.body // "") | split("\n")[0] | rtrimstr("\r")
|
|
87
|
-
| select(startswith("## "))
|
|
88
|
-
| select(. != "## Review" and . != "## Review closed"
|
|
89
|
-
and . != "## Review response" and . != "## Rebase"
|
|
90
|
-
and . != "## Post-review findings" and . != "## Evidence")
|
|
91
|
-
] as $unclassified
|
|
92
|
-
| ($unclassified | length | tostring)
|
|
93
|
-
+ " "
|
|
94
|
-
+ (($unclassified | last) // "none")
|
|
95
|
-
'
|
|
96
|
-
|
|
97
|
-
# Four fields describe the last review pass: the commit it covered, whether it
|
|
98
|
-
# left anything owed, how long it has sat, and the instant it read. All four come
|
|
99
|
-
# out of `canon pr review-state`, which is the one place the read-time marker
|
|
100
|
-
# `review-pr` writes into its own body is parsed. Reading `commit.oid` and
|
|
101
|
-
# `submittedAt` off the thread here instead is what this replaces: GitHub stamps
|
|
102
|
-
# both when a review is submitted, so a push landing between a pass's read and
|
|
103
|
-
# its post moves them onto a commit that pass never opened, and `SEEN` below then
|
|
104
|
-
# reports it covered. That is the failure that loses work silently, measured on
|
|
105
|
-
# a pull request in this toolkit on 2026-09-07.
|
|
106
|
-
#
|
|
107
|
-
# `review-pr` posts `## Review` exactly when a dispatch is owed, per the
|
|
108
|
-
# threshold that skill states, so the heading is what separates a thread waiting
|
|
109
|
-
# on a worker from one nothing is owed on. A project editing the two comment
|
|
110
|
-
# filters above for its own headings has nothing to edit here, since the verb
|
|
111
|
-
# owns the review family now.
|
|
112
|
-
#
|
|
113
|
-
# The age is derived from `submittedAt` alone and the pass instant from
|
|
114
|
-
# `readAt // submittedAt`, which is deliberate rather than an oversight. The age
|
|
115
|
-
# measures how long a posted comment has waited on a human, a question about the
|
|
116
|
-
# submission, and the pass instant bounds what that pass had read, a question
|
|
117
|
-
# about the read. jq computes the elapsed seconds itself, since `date -d` is
|
|
118
|
-
# GNU-only and this script runs wherever the plugin is installed. A review
|
|
119
|
-
# carrying no stamp reads as age zero and classifies nothing, which is the same
|
|
120
|
-
# answer the carry-forward path gives a pull request this run could not read, and
|
|
121
|
-
# reads as pass instant zero, where it sends a reply to be reported rather than
|
|
122
|
-
# suppressed. The two readers fail in opposite directions on the same absent
|
|
123
|
-
# field on purpose.
|
|
124
|
-
#
|
|
125
|
-
# `source` is the field this branches on rather than the exit status, since an
|
|
126
|
-
# operator shell profile can wrap canon in a function that flattens every
|
|
127
|
-
# refusal to zero. A refusal record carries `reason` and no `source`, and it
|
|
128
|
-
# yields nothing here, which is what sends the read to the fallback below. A
|
|
129
|
-
# record read as a bare answer instead would report every refusal as a pull
|
|
130
|
-
# request nobody has reviewed.
|
|
131
|
-
JQ_SCOPE_FROM_VERB='
|
|
132
|
-
if (.source // "") == "" then empty else
|
|
133
|
-
(.commit // "none") as $prior
|
|
134
|
-
| (.state // "none") as $heading
|
|
135
|
-
| (if .submittedAt == null then 0
|
|
136
|
-
else (.submittedAt | fromdateiso8601 | floor) end) as $at
|
|
137
|
-
| (if .readAt == null then $at
|
|
138
|
-
else (.readAt | fromdateiso8601 | floor) end) as $read
|
|
139
|
-
| $prior + " " + $heading
|
|
140
|
-
+ " " + (if $at == 0 then "0" else (((now | floor) - $at) | tostring) end)
|
|
141
|
-
+ " " + ($read | tostring)
|
|
142
|
-
end
|
|
143
|
-
'
|
|
144
|
-
# The fallback for a target whose CLI predates the verb, mirroring the `canon pr
|
|
145
|
-
# head` fallback below. It reads the submission stamps and therefore carries the
|
|
146
|
-
# defect the verb closes, which is the behavior this poll already had. It does
|
|
147
|
-
# not parse the marker: a second reader of that format here is the drift the
|
|
148
|
-
# verb exists to prevent, and one that lags the format silently reports a
|
|
149
|
-
# reviewed commit as unreviewed.
|
|
150
|
-
JQ_SCOPE_FALLBACK='
|
|
151
|
-
[ .reviews[]
|
|
152
|
-
| select((.body // "") | split("\n")[0] | rtrimstr("\r")
|
|
153
|
-
| . == "## Review" or . == "## Review closed")
|
|
154
|
-
] | last
|
|
155
|
-
| if . == null then "none none 0 0"
|
|
156
|
-
else (if .submittedAt == null then 0
|
|
157
|
-
else (.submittedAt | fromdateiso8601 | floor)
|
|
158
|
-
end) as $at
|
|
159
|
-
| ((.commit.oid // "none")
|
|
160
|
-
+ " "
|
|
161
|
-
+ (.body | split("\n")[0] | rtrimstr("\r")
|
|
162
|
-
| if . == "## Review" then "open" else "closed" end)
|
|
163
|
-
+ " "
|
|
164
|
-
+ (if $at == 0 then "0" else (((now | floor) - $at) | tostring) end)
|
|
165
|
-
+ " "
|
|
166
|
-
+ ($at | tostring))
|
|
167
|
-
end
|
|
168
|
-
'
|
|
169
|
-
|
|
170
|
-
# The UI family is posted as a pull request review the way the code family is,
|
|
171
|
-
# so it is read off `.reviews` rather than `.comments`. No verb parses it, so
|
|
172
|
-
# the filter here is its only reader. The newest UI heading decides the state,
|
|
173
|
-
# and the head it covered comes from the `review-ui` marker on the body's last
|
|
174
|
-
# line, since the submission stamp moves onto a commit pushed while the pass
|
|
175
|
-
# drove. A body carrying no marker falls back to that stamp.
|
|
176
|
-
JQ_UI_STATE='
|
|
177
|
-
[ .reviews[]
|
|
178
|
-
| select((.body // "") | split("\n")[0] | rtrimstr("\r")
|
|
179
|
-
| . == "## UI review" or . == "## UI review closed")
|
|
180
|
-
] | last
|
|
181
|
-
| if . == null then "none none"
|
|
182
|
-
else (.body | split("\n")[0] | rtrimstr("\r")
|
|
183
|
-
| if . == "## UI review" then "open" else "closed" end) as $state
|
|
184
|
-
| ([ .body | split("\n")[] | rtrimstr("\r") | select(. != "") ]
|
|
185
|
-
| last // ""
|
|
186
|
-
| (capture("^<!-- review-ui: head=(?<sha>[0-9a-f]{7,40}) -->$").sha)
|
|
187
|
-
// null) as $marked
|
|
188
|
-
| $state + " " + ($marked // .commit.oid // "none")
|
|
189
|
-
end
|
|
190
|
-
'
|
|
191
|
-
|
|
192
|
-
# An open pass this old has nobody on it. The cycle it has to clear is a review
|
|
193
|
-
# landing and a worker pushing a follow-up, measured between ten and thirty
|
|
194
|
-
# minutes across a day of runs on 2026-08-14, so two hours sits about four times
|
|
195
|
-
# past the slowest observed and still well inside a working session. Shorter
|
|
196
|
-
# starts catching the slow healthy case this threshold exists to exclude, and
|
|
197
|
-
# longer lets a dead dispatch sit past the point a person would have noticed.
|
|
198
|
-
# A project whose workers run longer than that raises it here.
|
|
199
|
-
STALE_AFTER=7200
|
|
200
|
-
|
|
201
|
-
# A pull request this run could not read keeps the line it had, so the GONE
|
|
202
|
-
# sweep below does not read the gap as a merge and the baseline does not lose
|
|
203
|
-
# the head it already knew. Saying so on stderr is the point: a silent skip is
|
|
204
|
-
# how the failure this script was fixed for went unnoticed for a session.
|
|
205
|
-
carry_forward() {
|
|
206
|
-
local n=$1 reason=$2 old
|
|
207
|
-
old=$(grep "^$n " "$STATE" || true)
|
|
208
|
-
if [ -n "$old" ]; then
|
|
209
|
-
# Every other classification compares a field against itself on a carried
|
|
210
|
-
# line, so it fires nothing. That is also what keeps the two stamps unread
|
|
211
|
-
# here: the baseline never held them, so both arrive empty, and the count
|
|
212
|
-
# test in front of them fails before either reaches a numeric comparison.
|
|
213
|
-
# STALLED reads a heading that is carried too, so
|
|
214
|
-
# the field is blanked to a value no branch matches. Echoing it intact would
|
|
215
|
-
# classify a pull request this run never reached while stderr below says the
|
|
216
|
-
# opposite, and one successful read restores it a run later. An already
|
|
217
|
-
# reported thread keeps its marker, or an unreadable run would let the same
|
|
218
|
-
# thread be reported a second time.
|
|
219
|
-
echo "$old" | awk '{ if ($6 != "reported") $6 = "carried"; print }'
|
|
220
|
-
echo "poll: #$n $reason, so it keeps its last known state and goes unclassified" >&2
|
|
221
|
-
else
|
|
222
|
-
echo "poll: #$n $reason, and it has no last known state, so it goes unclassified" >&2
|
|
223
|
-
fi
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
snapshot() {
|
|
227
|
-
local numbers n payload head prior resp merges scope review_scope review_state
|
|
228
|
-
git fetch -q origin "$BASE_BRANCH" 2>/dev/null || true
|
|
229
|
-
|
|
230
|
-
# A failed list reaches the caller as no open pull requests, and that reports
|
|
231
|
-
# every tracked one as GONE. It is a louder wrong answer than the one this
|
|
232
|
-
# script was fixed for, so the run aborts rather than classify on it.
|
|
233
|
-
if ! numbers=$(gh pr list --state open --json number --jq '.[].number' 2>/dev/null); then
|
|
234
|
-
echo "poll: the open pull request list could not be read" >&2
|
|
235
|
-
return 1
|
|
236
|
-
fi
|
|
237
|
-
|
|
238
|
-
for n in $numbers; do
|
|
239
|
-
# A query that failed and a pull request with genuinely no reviews both
|
|
240
|
-
# arrive as an empty result. Reading the first as the second reported a
|
|
241
|
-
# reviewed pull request as never reviewed, which routes the re-review to
|
|
242
|
-
# the first-pass branch, where it reports and stops, so the movement sits
|
|
243
|
-
# unreviewed until a person notices. One query per pull request gives that
|
|
244
|
-
# failure a single place to surface.
|
|
245
|
-
if ! payload=$(gh pr view "$n" --json headRefOid,reviews,comments 2>/dev/null); then
|
|
246
|
-
carry_forward "$n" "could not be read"
|
|
247
|
-
continue
|
|
248
|
-
fi
|
|
249
|
-
|
|
250
|
-
# The tip is the authority for the head, not the pull request object. That
|
|
251
|
-
# object lags the ref by up to a minute after a push, so MOVED fired late
|
|
252
|
-
# and the merge-tree read below judged a commit the branch had already left
|
|
253
|
-
# behind. Read on the record's own field rather than on the exit, since an
|
|
254
|
-
# operator shell profile can wrap canon in a function that flattens it.
|
|
255
|
-
# The trailing assignment is load-bearing under `set -e` and `set -o
|
|
256
|
-
# pipefail` at the top of this file. Every refusal exits 1, and a refusal
|
|
257
|
-
# here is ordinary rather than exceptional, so an unguarded pipeline would
|
|
258
|
-
# end the whole poll on the first pull request whose branch was deleted.
|
|
259
|
-
head=$(canon pr head "$n" --json 2>/dev/null | jq -r '.tip // empty') || head=""
|
|
260
|
-
|
|
261
|
-
# The object's head is the fallback rather than the source. This script
|
|
262
|
-
# ships with the plugin and the verb ships with the CLI, so a target on an
|
|
263
|
-
# older binary reaches no `pr head` at all, and answering there with the
|
|
264
|
-
# lagging head is the behavior this poll already had.
|
|
265
|
-
if [ -z "$head" ]; then
|
|
266
|
-
head=$(jq -r '.headRefOid // empty' <<<"$payload")
|
|
267
|
-
fi
|
|
268
|
-
|
|
269
|
-
if [ -z "$head" ]; then
|
|
270
|
-
carry_forward "$n" "returned no head"
|
|
271
|
-
continue
|
|
272
|
-
fi
|
|
273
|
-
|
|
274
|
-
# Split here rather than carried whole, because the count keeps the fourth
|
|
275
|
-
# column every baseline written so far already reads, and the stamp goes to
|
|
276
|
-
# the end of the line beside the pass stamp it is compared against.
|
|
277
|
-
reply_state=$(jq -r "$JQ_REPLY_STATE" <<<"$payload")
|
|
278
|
-
resp=${reply_state%% *}
|
|
279
|
-
reply_at=${reply_state##* }
|
|
280
|
-
|
|
281
|
-
# The marker `review-pr` writes is the authority for what a pass covered,
|
|
282
|
-
# and the verb is the only reader of it. The trailing assignment is
|
|
283
|
-
# load-bearing under `set -e` and `set -o pipefail`: every refusal exits 1,
|
|
284
|
-
# and a refusal here is ordinary rather than exceptional, so an unguarded
|
|
285
|
-
# pipeline would end the whole poll on the first pull request it could not
|
|
286
|
-
# answer for.
|
|
287
|
-
scope=$(canon pr review-state "$n" --json 2>/dev/null) || scope=""
|
|
288
|
-
review_scope=$(jq -r "$JQ_SCOPE_FROM_VERB" <<<"$scope" 2>/dev/null) || review_scope=""
|
|
289
|
-
if [ -z "$review_scope" ]; then
|
|
290
|
-
review_scope=$(jq -r "$JQ_SCOPE_FALLBACK" <<<"$payload")
|
|
291
|
-
fi
|
|
292
|
-
# Four space-separated fields. The commit leads, so the line below carries
|
|
293
|
-
# it as its own third the way every baseline already reads, and the other
|
|
294
|
-
# three ride on as the sixth, seventh, and eighth.
|
|
295
|
-
prior=${review_scope%% *}
|
|
296
|
-
review_state=${review_scope#* }
|
|
297
|
-
# Split the same way as the reply state, carried as the line's tenth and
|
|
298
|
-
# eleventh fields.
|
|
299
|
-
unmatched_state=$(jq -r "$JQ_UNMATCHED_STATE" <<<"$payload")
|
|
300
|
-
unmatched_count=${unmatched_state%% *}
|
|
301
|
-
unmatched_heading=${unmatched_state#* }
|
|
302
|
-
|
|
303
|
-
# One token, so the heading above stays the line's last field and keeps its
|
|
304
|
-
# spaces. A marker may name a short sha, so coverage is a prefix match.
|
|
305
|
-
ui_read=$(jq -r "$JQ_UI_STATE" <<<"$payload")
|
|
306
|
-
ui_heading=${ui_read%% *}
|
|
307
|
-
ui_commit=${ui_read#* }
|
|
308
|
-
if [ "$ui_heading" = none ]; then
|
|
309
|
-
ui_state=none
|
|
310
|
-
elif [ "$ui_commit" != none ] && [[ "$head" == "$ui_commit"* ]]; then
|
|
311
|
-
ui_state="$ui_heading-head-${ui_commit:0:7}"
|
|
312
|
-
else
|
|
313
|
-
ui_state="$ui_heading-behind-${ui_commit:0:7}"
|
|
314
|
-
fi
|
|
315
|
-
|
|
316
|
-
# `gh pr view --json mergeable` reports UNKNOWN until GitHub finishes
|
|
317
|
-
# computing it, which is exactly when a poll asks. merge-tree answers
|
|
318
|
-
# locally against the base this machine has, so it never returns UNKNOWN.
|
|
319
|
-
git fetch -q origin "pull/$n/head" 2>/dev/null || true
|
|
320
|
-
if ! git cat-file -e "${head}^{commit}" 2>/dev/null ||
|
|
321
|
-
! git rev-parse --verify -q "$BASE_REF" >/dev/null; then
|
|
322
|
-
# merge-tree exits non-zero on a ref it cannot resolve as well as on a
|
|
323
|
-
# real conflict, and the two are indistinguishable from its status. Both
|
|
324
|
-
# sides are checked, because an unresolvable base reports every pull
|
|
325
|
-
# request as conflicted rather than one, which is the louder half.
|
|
326
|
-
merges=unknown
|
|
327
|
-
elif git merge-tree --write-tree "$BASE_REF" "$head" >/dev/null 2>&1; then
|
|
328
|
-
merges=clean
|
|
329
|
-
else
|
|
330
|
-
merges=conflict
|
|
331
|
-
fi
|
|
332
|
-
|
|
333
|
-
echo "$n $head ${prior:-none} $resp $merges $review_state $reply_at $unmatched_count $ui_state $unmatched_heading"
|
|
334
|
-
done
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
# The reason is printed by whichever branch failed, since this one cannot tell
|
|
338
|
-
# a list query from a parse and naming either would misattribute the other.
|
|
339
|
-
if ! NEW=$(snapshot); then
|
|
340
|
-
echo "poll: nothing is classified this run and the baseline is unchanged" >&2
|
|
341
|
-
exit 1
|
|
342
|
-
fi
|
|
343
|
-
CHANGED=0
|
|
344
|
-
# The baseline is written from here rather than from the snapshot, because the
|
|
345
|
-
# heading field is the one column a classification rewrites. STALLED reports a
|
|
346
|
-
# standing condition rather than a transition, so without a written marker it
|
|
347
|
-
# would fire on every later run and the board would never read "No movement."
|
|
348
|
-
FINAL=""
|
|
349
|
-
|
|
350
|
-
while read -r n head prior resp merges heading age pass_at reply_at unmatched_count ui unmatched_heading; do
|
|
351
|
-
[ -z "$n" ] && continue
|
|
352
|
-
state=$heading
|
|
353
|
-
old=$(grep "^$n " "$STATE" || true)
|
|
354
|
-
if [ -z "$old" ]; then
|
|
355
|
-
# A pull request first seen here may already carry a pass, when it opened
|
|
356
|
-
# and was reviewed between two runs. Reporting it as new invites a first
|
|
357
|
-
# pass the thread already has.
|
|
358
|
-
if [ "$prior" = "$head" ]; then
|
|
359
|
-
echo "SEEN #$n at ${head:0:7}, first sighting, already covered by a pass"
|
|
360
|
-
elif [ "$merges" = unknown ]; then
|
|
361
|
-
# `unknown` means no merge was attempted, so it is left off the line
|
|
362
|
-
# rather than printed where a verdict belongs.
|
|
363
|
-
echo "OPENED #$n at ${head:0:7}"
|
|
364
|
-
else
|
|
365
|
-
echo "OPENED #$n at ${head:0:7}, $merges against $BASE_BRANCH"
|
|
366
|
-
fi
|
|
367
|
-
CHANGED=1
|
|
368
|
-
FINAL+="$n $head $prior $resp $merges $state $unmatched_count $ui"$'\n'
|
|
369
|
-
continue
|
|
370
|
-
fi
|
|
371
|
-
old_head=$(echo "$old" | cut -d' ' -f2)
|
|
372
|
-
old_resp=$(echo "$old" | cut -d' ' -f4)
|
|
373
|
-
old_merges=$(echo "$old" | cut -d' ' -f5)
|
|
374
|
-
# Only the marker is read off this field, so a baseline written before it
|
|
375
|
-
# existed yields empty and reads as not yet reported, which is what it is. The
|
|
376
|
-
# age decides the rest, so the first run after an upgrade needs no history and
|
|
377
|
-
# classifies a thread already past the threshold rather than waiting a run.
|
|
378
|
-
old_heading=$(echo "$old" | cut -d' ' -f6)
|
|
379
|
-
old_unmatched=$(echo "$old" | cut -d' ' -f7)
|
|
380
|
-
# Written from the first run that read a UI pass, so an older baseline yields
|
|
381
|
-
# empty and the first UI state this script sees is reported as new.
|
|
382
|
-
old_ui=$(echo "$old" | cut -d' ' -f8)
|
|
383
|
-
|
|
384
|
-
# A conflict arrives from the base moving, not from the branch, so it is
|
|
385
|
-
# reported on the transition rather than only when the head changes.
|
|
386
|
-
if [ "$merges" = conflict ] && [ "$old_merges" != conflict ]; then
|
|
387
|
-
echo "CONFLICT #$n no longer merges into $BASE_BRANCH"
|
|
388
|
-
CHANGED=1
|
|
389
|
-
fi
|
|
390
|
-
|
|
391
|
-
# A rising count is what is new to this script, the same test RESPONSE
|
|
392
|
-
# below runs against the reply family. It fires on a tracked pull request
|
|
393
|
-
# only: a first sighting reports SEEN or OPENED and takes whatever count
|
|
394
|
-
# already sits on the thread as its starting baseline rather than flagging
|
|
395
|
-
# history retroactively. A carried line supplies neither, since
|
|
396
|
-
# carry_forward re-echoes the shorter baseline shape rather than a full
|
|
397
|
-
# snapshot line, so both sides default to zero the way old_unmatched
|
|
398
|
-
# already does.
|
|
399
|
-
if [ "${unmatched_count:-0}" -gt "${old_unmatched:-0}" ]; then
|
|
400
|
-
echo "UNMATCHED #$n posted under '$unmatched_heading'"
|
|
401
|
-
CHANGED=1
|
|
402
|
-
fi
|
|
403
|
-
|
|
404
|
-
# The UI pass reports on its transition, the way CONFLICT does, since its
|
|
405
|
-
# state moves on a post or on a push and either one is the moment it is owed
|
|
406
|
-
# an action. A carried line supplies no state, so nothing fires on it. A
|
|
407
|
-
# verdict a push left behind reports separately from MOVED above, since it
|
|
408
|
-
# owes a fresh UI pass rather than a code re-review. The state carries the
|
|
409
|
-
# covered commit, so a second UI pass at a new head reads as a transition even
|
|
410
|
-
# when its heading repeats.
|
|
411
|
-
if [ -n "$ui" ] && [ "$ui" != none ] && [ "$ui" != "$old_ui" ]; then
|
|
412
|
-
ui_heading=${ui%%-*}
|
|
413
|
-
ui_commit=${ui##*-}
|
|
414
|
-
case "$ui" in
|
|
415
|
-
open-head-*)
|
|
416
|
-
echo "UI-OPEN #$n open at $ui_commit"
|
|
417
|
-
;;
|
|
418
|
-
closed-head-*)
|
|
419
|
-
echo "UI-CLOSED #$n closed at $ui_commit"
|
|
420
|
-
;;
|
|
421
|
-
*)
|
|
422
|
-
echo "UI-STALE #$n $ui_heading at $ui_commit, behind ${head:0:7}"
|
|
423
|
-
;;
|
|
424
|
-
esac
|
|
425
|
-
CHANGED=1
|
|
426
|
-
fi
|
|
427
|
-
|
|
428
|
-
if [ "$head" != "$old_head" ]; then
|
|
429
|
-
if [ "$prior" = "none" ]; then
|
|
430
|
-
echo "MOVED #$n -> ${head:0:7}, never reviewed"
|
|
431
|
-
elif [ "$prior" = "$head" ]; then
|
|
432
|
-
# An out-of-band pass reviewed this head before the poll saw it move, so
|
|
433
|
-
# the range is empty because it is covered rather than because it broke.
|
|
434
|
-
# Without this the force-push branch below claims a rewrite that never
|
|
435
|
-
# happened, which is the class of false report the shebang note names.
|
|
436
|
-
echo "SEEN #$n -> ${head:0:7}, already covered by the last pass"
|
|
437
|
-
else
|
|
438
|
-
# The range needs both commits local, and a force-push leaves the prior
|
|
439
|
-
# one unreachable. Count what resolves and say nothing when it does not,
|
|
440
|
-
# rather than reporting a zero that reads as no new work.
|
|
441
|
-
git fetch -q origin "pull/$n/head" 2>/dev/null || true
|
|
442
|
-
since=$(git log --oneline "${prior}..${head}" 2>/dev/null | wc -l | tr -d ' ' || true)
|
|
443
|
-
if [ -n "$since" ] && [ "$since" != "0" ]; then
|
|
444
|
-
echo "MOVED #$n -> ${head:0:7}, $since commit(s) since your last pass"
|
|
445
|
-
else
|
|
446
|
-
echo "MOVED #$n -> ${head:0:7}, range unresolved, likely force-pushed"
|
|
447
|
-
fi
|
|
448
|
-
fi
|
|
449
|
-
CHANGED=1
|
|
450
|
-
elif [ "$resp" -gt "$old_resp" ] &&
|
|
451
|
-
{ [ "$pass_at" = 0 ] || [ "$reply_at" -gt "$pass_at" ]; }; then
|
|
452
|
-
# The count says the reply is new to this script and the stamp says it is
|
|
453
|
-
# newer than the pass, and both are needed. A worker answers a finding and
|
|
454
|
-
# the reviewing session closes out seconds later, so the count alone reports
|
|
455
|
-
# an answered thread on the next run and the session spends a turn learning
|
|
456
|
-
# `review-pr` will refuse it. The gaps observed were 96, 24, and 35
|
|
457
|
-
# seconds, which is a reviewing session reading a reply and posting, so this
|
|
458
|
-
# is the ordinary handback rather than a race.
|
|
459
|
-
#
|
|
460
|
-
# A pull request carrying no pass at all reads as stamp zero and reports,
|
|
461
|
-
# which is the case the count was the right test for: a reply with nothing
|
|
462
|
-
# behind it is a worker talking to nobody and worth the turn. The test is
|
|
463
|
-
# strictly greater to match the guard `review-pr` states on the same
|
|
464
|
-
# two fields, which drops a reply landing inside the same second as the
|
|
465
|
-
# pass, a narrower failure than the one it removes.
|
|
466
|
-
#
|
|
467
|
-
# The gate sits in the condition rather than in the body so a suppressed
|
|
468
|
-
# reply falls through to STALLED below, which is a thread this run should
|
|
469
|
-
# still be able to reach.
|
|
470
|
-
echo "RESPONSE #$n answered with no new commit"
|
|
471
|
-
CHANGED=1
|
|
472
|
-
elif [ "$heading" = open ] && [ "$old_heading" != reported ] &&
|
|
473
|
-
[ "$prior" = "$head" ] && [ "$age" -ge "$STALE_AFTER" ]; then
|
|
474
|
-
# Three things at once: the last pass is open, it covers the head so no
|
|
475
|
-
# commit followed it, and the two branches above found no reply either. The
|
|
476
|
-
# open heading is posted exactly when a dispatch is owed, per the threshold
|
|
477
|
-
# `review-pr` states, so a pass owing one left it here at any grade
|
|
478
|
-
# it carried. Any two of these describe an ordinary review waiting on a
|
|
479
|
-
# worker, which is why the age carries the third: without it every
|
|
480
|
-
# dispatched worker is reported minutes into the work it was sent to do,
|
|
481
|
-
# and a signal firing on the healthy path is one an operator learns to
|
|
482
|
-
# skip. The report still asks for a confirmation rather than asserting.
|
|
483
|
-
echo "STALLED #$n open at ${head:0:7}, no commit or reply in $((age / 3600))h"
|
|
484
|
-
state=reported
|
|
485
|
-
CHANGED=1
|
|
486
|
-
elif [ "$heading" = open ] && [ "$old_heading" = reported ]; then
|
|
487
|
-
# Nothing above fired, so the thread has not moved since it was reported.
|
|
488
|
-
# Carry the marker rather than the freshly derived heading, or the state
|
|
489
|
-
# re-enters next run and STALLED oscillates instead of reporting once.
|
|
490
|
-
state=reported
|
|
491
|
-
fi
|
|
492
|
-
FINAL+="$n $head $prior $resp $merges $state $unmatched_count ${ui:-$old_ui}"$'\n'
|
|
493
|
-
done <<<"$NEW"
|
|
494
|
-
|
|
495
|
-
while read -r n _rest; do
|
|
496
|
-
[ -z "$n" ] && continue
|
|
497
|
-
echo "$NEW" | grep -q "^$n " || {
|
|
498
|
-
echo "GONE #$n merged or closed"
|
|
499
|
-
CHANGED=1
|
|
500
|
-
}
|
|
501
|
-
done <"$STATE"
|
|
502
|
-
|
|
503
|
-
[ "$CHANGED" -eq 0 ] && echo "No movement."
|
|
504
|
-
printf '%s' "$FINAL" >"$STATE"
|