-
Notifications
You must be signed in to change notification settings - Fork 0
346 lines (334 loc) · 17.4 KB
/
Copy pathqueue_actions.yml
File metadata and controls
346 lines (334 loc) · 17.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
name: Queue actions
# The dashboard's mechanical buttons land here — five per-paper, one
# per-batch. Six of them:
#
# ✅ `queue-read` marks a reading-queue.md line `DONE <date> — <title>`
# (never deleted — the reading history)
# ➕ `queue-add` moves an arxiv-inbox.md line into its reading-queue
# section, unread
# ✖️ `queue-dismiss` drops an arxiv-inbox.md line — an un-acted suggestion
# is not reading history, and git history holds it
# ➕ `interests-add` the same move out of arxiv-interests.md, routed by
# the line's own `[Topic]` rather than to one section
# ✖️ `interests-dismiss` the same drop, out of arxiv-interests.md
# 🧹 `interests-clear` drops a whole day's batch from arxiv-interests.md,
# revealing the next day — the interests list is a
# backlog to walk, not a seven-day timer, so this is
# the button that moves it along
#
# Each commits the file it touched, re-renders the dashboard, and closes the
# issue. 📥 `queue-intake` and 📑 `queue-cite` are NOT handled here — they stay
# open as filing work items and are driven by queue_filing.yml. When one of
# those arrives *without* its label (below), this workflow hands it over by
# dispatching queue_filing.yml on the issue number.
#
# The label is the intent, but it is not trusted to arrive. The dashboard's
# prefilled `issues/new?…&labels=x` URL is a hint to GitHub's issue form, and
# on some submissions (five of the twelve open issues on 2026-09-10 — all of
# them tapped from a phone) the form dropped it, leaving an issue titled
# `queue add: …` with no label at all. A label-only gate then skipped both
# workflows and the issue sat open and silent for a week. So the gate reads
# the *title* prefix the board writes as well (`queue read:`, `queue add:`,
# `queue dismiss:`, `queue intake:`, `queue cite:`, `interests clear:`),
# and the resolve step puts the missing label back for the record, with the
# body's `file:` line telling an inbox add from an interests add.
#
# Two doors: the `opened` event (the dashboard path) and `workflow_dispatch`
# on an issue number (the nightly queue_sweep.yml, which re-dispatches every
# open request nothing has acted on — and a human's `gh workflow run
# queue_actions.yml -f issue=N`).
#
# Only owner/member/collaborator-authored issues act: this is a public repo,
# and a stranger's issue must never mutate the queue (they cannot apply the
# label either, but the gate does not rely on that). The dispatch door
# re-checks the association from the API, since the event carries none.
on:
issues:
types: [opened]
workflow_dispatch:
inputs:
issue:
description: "Issue number to (re)process — the sweep's door"
required: true
type: string
# contents: write → the queue commit; issues: write → label + comment + close;
# actions: write → re-dispatch the Dashboard workflow and hand a label-less
# intake/cite over to queue_filing.yml (token pushes do not retrigger
# `on: push` workflows, and a token-applied label does not fire `labeled`).
permissions:
contents: write
issues: write
actions: write
# Per issue, not one group for the whole surface. GitHub keeps at most one
# *pending* run per concurrency group and cancels the older pending one when a
# newer arrives — so three ➕ taps six seconds apart left the middle one
# `cancelled` (#85, 2026-09-04), and a cancelled run never reaches its
# failure step: the issue stayed open with no comment, indistinguishable
# from "never swept". The push retry below is what serialises the queue
# commits on a moved main; nothing else needs a shared group.
concurrency:
group: queue-actions-${{ github.event.issue.number || inputs.issue }}
cancel-in-progress: false
jobs:
queue-action:
# One job, six labels — and the six title prefixes those labels ride
# behind, for the submissions that lost the label on the way in.
if: >-
github.event_name == 'workflow_dispatch' ||
(contains(fromJSON('["OWNER", "MEMBER", "COLLABORATOR"]'),
github.event.issue.author_association) &&
(contains(github.event.issue.labels.*.name, 'queue-read') ||
contains(github.event.issue.labels.*.name, 'queue-add') ||
contains(github.event.issue.labels.*.name, 'queue-dismiss') ||
contains(github.event.issue.labels.*.name, 'interests-add') ||
contains(github.event.issue.labels.*.name, 'interests-dismiss') ||
contains(github.event.issue.labels.*.name, 'interests-clear') ||
startsWith(github.event.issue.title, 'queue read:') ||
startsWith(github.event.issue.title, 'queue add:') ||
startsWith(github.event.issue.title, 'queue dismiss:') ||
startsWith(github.event.issue.title, 'queue intake:') ||
startsWith(github.event.issue.title, 'queue cite:') ||
startsWith(github.event.issue.title, 'interests clear:')))
runs-on: ubuntu-latest
env:
GH_TOKEN: ${{ github.token }}
ISSUE: ${{ github.event.issue.number || inputs.issue }}
steps:
- uses: actions/checkout@v4
# Read the issue back from the API rather than the event: the dispatch
# door has no event payload, and one code path for both doors is the
# point. Everything downstream keys off `label` — the one the issue
# carries, or the one its title says it should have carried.
- name: Resolve the request
id: resolve
run: |
issue_json=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${ISSUE}")
state=$(jq -r '.state' <<<"$issue_json")
assoc=$(jq -r '.author_association' <<<"$issue_json")
title=$(jq -r '.title' <<<"$issue_json")
labels=$(jq -r '[.labels[].name] | join(",")' <<<"$issue_json")
# The body is untrusted text: to a file, never interpolated.
jq -r '.body // ""' <<<"$issue_json" > /tmp/issue_body.txt
if [ "$state" != "open" ]; then
echo "issue #${ISSUE} is ${state} — nothing to do"
echo "label=" >> "$GITHUB_OUTPUT"; exit 0
fi
case "$assoc" in
OWNER|MEMBER|COLLABORATOR) ;;
*) echo "issue #${ISSUE} author is ${assoc} — not acting"
echo "label=" >> "$GITHUB_OUTPUT"; exit 0 ;;
esac
label=""
derived=no
case ",$labels," in
*,queue-read,*) label=queue-read ;;
*,queue-add,*) label=queue-add ;;
*,queue-dismiss,*) label=queue-dismiss ;;
*,interests-add,*) label=interests-add ;;
*,interests-dismiss,*) label=interests-dismiss ;;
*,interests-clear,*) label=interests-clear ;;
*,queue-intake,*) label=queue-intake ;;
*,queue-cite,*) label=queue-cite ;;
esac
if [ -z "$label" ]; then
# No action label survived the issue form. The title prefix is
# the board's own wording; the body's `file:` line says which
# suggestion tier an add/dismiss came from.
file=$(sed -n 's/^file:[[:space:]]*//p' /tmp/issue_body.txt | head -1)
tier=queue
[ "$file" = "arxiv-interests.md" ] && tier=interests
case "$title" in
"queue read:"*) label=queue-read ;;
"queue add:"*) label=${tier}-add ;;
"queue dismiss:"*) label=${tier}-dismiss ;;
"queue intake:"*) label=queue-intake ;;
"queue cite:"*) label=queue-cite ;;
"interests clear:"*) label=interests-clear ;;
esac
if [ -n "$label" ]; then
derived=yes
# For the record and for the label-filtered views; a
# token-applied label fires no `labeled` event, so the
# filing hand-over below is explicit.
gh issue edit "$ISSUE" --add-label "$label" || true
echo "issue #${ISSUE} carried no label — read '${label}' from its title"
fi
fi
if [ -z "$label" ]; then
echo "issue #${ISSUE} is not a queue request (title: ${title}) — nothing to do"
fi
echo "label=${label}" >> "$GITHUB_OUTPUT"
echo "derived=${derived}" >> "$GITHUB_OUTPUT"
# 📥/📑 are filing work items, not mechanical actions: queue_filing.yml
# owns them. A labelled one already fired that workflow's `labeled`
# trigger on its own; a label-less one (or a sweep re-dispatch) has no
# such event, so this is the hand-over.
- name: Hand a filing request to queue_filing.yml
if: >-
contains(fromJSON('["queue-intake", "queue-cite"]'),
steps.resolve.outputs.label) &&
(steps.resolve.outputs.derived == 'yes' ||
github.event_name == 'workflow_dispatch')
run: |
gh workflow run queue_filing.yml --ref main -f "issue=${ISSUE}"
echo "dispatched queue_filing.yml for #${ISSUE} (${{ steps.resolve.outputs.label }})"
- name: Apply the action
id: mark
if: >-
contains(fromJSON('["queue-read", "queue-add", "queue-dismiss",
"interests-add", "interests-dismiss",
"interests-clear"]'),
steps.resolve.outputs.label)
env:
LABEL: ${{ steps.resolve.outputs.label }}
run: |
# The label picks the script; the push retry below re-runs the same
# command on a moved main, so the mapping is stated once and the
# command travels as a step output.
case "$LABEL" in
queue-add)
action=add
cmd="scripts/inbox_actions.py add" ;;
queue-dismiss)
action=dismiss
cmd="scripts/inbox_actions.py dismiss" ;;
interests-add)
action=interests-add
cmd="scripts/interests_actions.py add" ;;
interests-dismiss)
action=interests-dismiss
cmd="scripts/interests_actions.py dismiss" ;;
interests-clear)
action=interests-clear
cmd="scripts/interests_actions.py clear" ;;
*)
action=read
cmd="scripts/queue_mark_done.py --queue reading-queue.md" ;;
esac
status=$(python $cmd --body-file /tmp/issue_body.txt) || true
# A status the scripts do not define means they died before printing;
# normalise so the close step reports rather than mis-closing.
case "$status" in
marked|already-done|added|already-there|dismissed|already-gone) ;;
already-clear|cleared:*) ;;
*) status="${status:-error}" ;;
esac
echo "action=${action}" >> "$GITHUB_OUTPUT"
echo "cmd=${cmd}" >> "$GITHUB_OUTPUT"
echo "status=${status}" >> "$GITHUB_OUTPUT"
echo "queue action ${action}: ${status}"
# Did anything actually change on disk? Cheaper and more honest than
# inferring it from the status token.
if [ -n "$(git status --porcelain -- reading-queue.md arxiv-inbox.md arxiv-interests.md)" ]; then
echo "changed=yes" >> "$GITHUB_OUTPUT"
else
echo "changed=no" >> "$GITHUB_OUTPUT"
fi
- name: Commit + push (explicit paths only)
id: push
if: steps.mark.outputs.changed == 'yes'
env:
ACTION: ${{ steps.mark.outputs.action }}
CMD: ${{ steps.mark.outputs.cmd }}
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
case "$ACTION" in
add) msg="queue: add inbox paper to the reading queue" ;;
dismiss) msg="queue: dismiss inbox paper" ;;
interests-add) msg="interests: add paper to the reading queue" ;;
interests-dismiss) msg="interests: dismiss paper" ;;
interests-clear) msg="interests: clear the day's batch" ;;
*) msg="queue: mark paper read" ;;
esac
git add reading-queue.md arxiv-inbox.md arxiv-interests.md
git commit -m "${msg} (#${ISSUE}) [skip ci]"
# A rejected push means a sibling run landed a commit first.
# Rebasing the stale commit conflicts on adjacent queue lines and
# used to discard the action entirely (#69) — rebuild instead:
# reset to the moved tip and re-run the action script, which is
# idempotent (already-there / already-gone / already-done).
for attempt in 1 2 3; do
if git push; then exit 0; fi
[ "$attempt" = 3 ] && break
git fetch origin main
git reset --hard origin/main
retry_status=$(python $CMD --body-file /tmp/issue_body.txt) || true
# A clean tree only means "done" if the re-run actually succeeded:
# an error status (no-section, a crash) also leaves no diff, and
# exiting 0 on it would close the issue on the first run's stale
# status — the same silent-loss class this retry exists to fix.
case "$retry_status" in
marked|already-done|added|already-there|dismissed|already-gone|already-clear|cleared:*) ;;
*)
echo "::error::re-apply on the moved main returned '${retry_status:-error}'"
exit 1 ;;
esac
# The close step reports what actually landed, not attempt 1's
# status (last write wins).
echo "status=${retry_status}" >> "$GITHUB_OUTPUT"
if [ -z "$(git status --porcelain -- reading-queue.md arxiv-inbox.md arxiv-interests.md)" ]; then
echo "change already on main after the concurrent commit — nothing left to push"
exit 0
fi
git add reading-queue.md arxiv-inbox.md arxiv-interests.md
git commit -m "${msg} (#${ISSUE}) [skip ci]"
done
echo "::error::could not push the queue commit after 3 attempts"
exit 1
- name: Refresh the dashboard
if: steps.mark.outputs.changed == 'yes'
run: gh workflow run knowledge_board.yml --ref main
- name: Close or report on the issue
if: steps.mark.outcome == 'success'
env:
STATUS: ${{ steps.push.outputs.status || steps.mark.outputs.status }}
ACTION: ${{ steps.mark.outputs.action }}
run: |
# Two suggestion tiers share these statuses, so the comment names
# the one the human actually tapped.
case "$ACTION" in
interests-*) tier="arXiv interests list" ;;
*) tier="arXiv inbox" ;;
esac
case "$STATUS" in
marked)
gh issue close "$ISSUE" --comment \
"Marked read in reading-queue.md — the line stays, DONE-prefixed (the reading history). Dashboard refresh dispatched." ;;
already-done)
gh issue close "$ISSUE" --comment \
"Already marked read in reading-queue.md — nothing to change." ;;
added)
gh issue close "$ISSUE" --comment \
"Added to the reading queue and cleared from the ${tier}. Dashboard refresh dispatched." ;;
already-there)
gh issue close "$ISSUE" --comment \
"Already in the reading queue — cleared the ${tier} line and left the queue alone." ;;
dismissed)
gh issue close "$ISSUE" --comment \
"Dropped from the ${tier}. Git history still holds the line. Dashboard refresh dispatched." ;;
already-gone)
gh issue close "$ISSUE" --comment \
"Not in the arXiv inbox or interests list any more — nothing to change (it may already have lapsed, been cleared, or been moved by an earlier tap)." ;;
cleared:*)
gh issue close "$ISSUE" --comment \
"Cleared ${STATUS#cleared:} paper(s) from the arXiv interests list — the next day's batch is now on the dashboard. Git history still holds the batch. Dashboard refresh dispatched." ;;
already-clear)
gh issue close "$ISSUE" --comment \
"That interests batch is already clear — nothing to change." ;;
no-section)
gh issue comment "$ISSUE" --body \
"Could not process this queue action — neither the named section nor the \`## Interests\` fallback exists in reading-queue.md. Handle manually; leaving the issue open." ;;
*)
gh issue comment "$ISSUE" --body \
"Could not process this queue action (status: ${STATUS:-error}) — the paper's line was not found. Handle manually; leaving the issue open." ;;
esac
# The close step above runs only after a successful apply, so a died
# push used to leave the issue open and completely silent (#69 read as
# "never swept up" rather than "failed"). Same shape as
# queue_filing.yml's failure report.
- name: Report failure on the issue
if: failure()
run: |
gh issue comment "$ISSUE" --body \
"Queue action failed — see the workflow run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}. The issue stays open; handle manually if needed." || true