-
Notifications
You must be signed in to change notification settings - Fork 0
209 lines (191 loc) · 9.74 KB
/
Copy pathopenspec-sweep.yml
File metadata and controls
209 lines (191 loc) · 9.74 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
name: OpenSpec Sweep
# A scheduled backstop for the case where the push-time check was never
# evaluated: an administrative merge, a workflow disabled during an outage, a
# botched down-merge.
#
# IT IS NOT A SECOND CLASSIFIER. `openspec-rot.yml`, deleted in 8d1f3a1, split
# changes into DONE (all tasks checked) and STALE (unfinished and idle) so it
# could guess whether unfinished work was abandoned or merely slow. That guess
# is what made it noisy. There is no parked state on `main`: a change is
# finished or it has not landed. So this reads age and nothing else, parses no
# `tasks.md`, and treats a half-done change exactly like a finished one.
#
# AGE COMES FROM GIT, NEVER FROM FILE MTIME. `actions/checkout` stamps every
# file's modification time to checkout time, so an mtime-based sweep reports
# every change as fresh regardless of how long it has sat there. The deleted
# workflow got this right and the note is worth keeping.
#
# AGE IS ACTIVITY, WHICH IS WHY THE CLAIM TEST IS NOT NEEDED HERE. A stack still
# draining touches its own change directory on every slice, so its clock keeps
# resetting and it never crosses the window. Only stalled work does.
#
# IT DOES NOT FAIL EITHER. The predecessor failed the scheduled run, which is
# the same channel mistake in a quieter place: a recurring red attached to
# nothing actionable. This escalates the tracking issue that
# `openspec-tracking.yml` owns, so there is one channel and one place to look.
on:
schedule:
# Daily near the start of the Pacific business day. Cron is fixed-UTC and
# does not follow DST, so 15:17 UTC lands at 8:17am PDT and 7:17am PST.
# The :17 is off the top of the hour, which GitHub delays under contention.
- cron: "17 15 * * *"
workflow_dispatch:
permissions:
contents: read
issues: write
env:
STALE_DAYS: "7"
jobs:
sweep:
name: OpenSpec Sweep
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # full history, so `git log` can date each change directory
# The listing and its safe-capture idiom live in the composite action,
# shared with openspec-label.yml and openspec-tracking.yml (#289). It
# never fails: an unreadable `openspec/changes` comes back as `ok=false`
# with the reason in `error`, and the plan step decides what that means
# here. This file once listed with `find ... -printf '%f\n'` inside a
# process substitution: `done < <(...)` hands the loop the
# substitution's own exit status, not find's, so a failed `-printf` on
# BSD find ran the loop zero times and this step silently reported "0
# unarchived changes" and exited 0. That is the exact silent case this
# workflow exists to avoid, and the action's `ok` output is what makes
# the failure visible instead.
- name: List unarchived OpenSpec changes
id: list
uses: ./.github/actions/openspec-list
- name: Plan escalations
id: plan
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
SHA: ${{ github.sha }}
LABEL: Open OpenSpec
LIST_OK: ${{ steps.list.outputs.ok }}
LIST_CHANGES: ${{ steps.list.outputs.changes }}
LIST_ERROR: ${{ steps.list.outputs.error }}
run: |
set -euo pipefail
unarchived='[]'
now=$(date +%s)
if [ "$LIST_OK" != "true" ]; then
# SKIP THE RUN, do not fall through. An empty `unarchived` is not
# "we could not tell", it is "every change is archived": the
# planner's close-as-archived pass closes every open tracking issue
# whose change is absent from the listing. Falling through here
# closed all of them with "Archived. <change> reached
# openspec/changes/archive/", which is false, on nothing worse than
# a transient read error. This mirrors openspec-tracking.yml, which
# gates its apply step the same way.
echo "::warning::Could not list openspec/changes/ ($LIST_ERROR). Reporting zero unarchived changes would close every open tracking issue as archived, so this run is skipped instead."
echo "skipped=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Names and ages go into JSON as jq ARGUMENTS, never joined into a
# line and split back apart. A directory name containing a space
# desynced the split, `tonumber` threw on the wrong field, and under
# `set -euo pipefail` that failed the whole run: the "signal expected
# to be red" failure this workflow exists to avoid. Nothing enforces
# kebab-case directory names, so the encoding must not assume it.
#
# `jq` RUNS IN ASSIGNMENT POSITION, NOT AS `done < <(jq ...)`. A
# process substitution hands the loop its own exit status, so `jq`
# failing on a malformed listing ran the loop zero times and this
# step reported "0 unarchived changes" and exited 0, the same trap
# the `find` note above describes, one line further down. The action
# validated `changes` as a JSON array of strings before writing it,
# so this `jq` cannot fail on the value; the assignment form is kept
# because it is the one where a failure could be seen if it did.
names=$(jq -r '.[]' <<<"$LIST_CHANGES")
while IFS= read -r name; do
[ -z "$name" ] && continue
# The last commit that touched this change directory. A directory
# with no commits at all is treated as brand new rather than
# infinitely old, so a checkout quirk cannot manufacture a report.
last=$(git log -1 --format=%ct -- "openspec/changes/$name" || true)
if [ -z "$last" ]; then
age=0
else
age=$(( (now - last) / 86400 ))
fi
unarchived=$(jq -c --arg name "$name" --argjson age "$age" \
'. + [{name: $name, ageDays: $age}]' <<<"$unarchived")
done <<<"$names"
# Bodies are handed over raw. The marker is parsed in one place, in
# openspec-tracking.cjs, rather than re-implemented as a `jq capture`
# here and in openspec-tracking.yml: `capture` drops a non-matching
# element from the array instead of erroring, so a body that stopped
# matching would read as "no issue exists" and open a duplicate.
#
# `idleDays` throttles every sweep report on an existing issue, both
# an escalation comment and a reopen, to at most one per window.
issues=$(gh issue list --repo "$REPO" --state all --label "$LABEL" --limit 100 \
--json number,state,body,updatedAt \
| jq -c --argjson now "$(date +%s)" \
'[.[] | {
number,
state: (.state | ascii_downcase),
body: (.body // ""),
idleDays: (($now - (.updatedAt | fromdateiso8601)) / 86400 | floor)
}]')
jq -n -c \
--arg sha "$SHA" \
--argjson staleDays "$STALE_DAYS" \
--argjson unarchived "$unarchived" \
--argjson issues "$issues" \
'{mode: "sweep", sha: $sha, staleDays: $staleDays, unarchived: $unarchived, issues: $issues}' \
> /tmp/openspec-sweep-input.json
node .github/scripts/openspec-tracking.cjs \
< /tmp/openspec-sweep-input.json \
> /tmp/openspec-sweep-plan.json
cat /tmp/openspec-sweep-plan.json
echo "skipped=false" >> "$GITHUB_OUTPUT"
- name: Apply the plan
if: steps.plan.outputs.skipped == 'false'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
LABEL: Open OpenSpec
run: |
set -euo pipefail
count=$(jq '.actions | length' /tmp/openspec-sweep-plan.json)
echo "Applying $count action(s)."
for index in $(seq 0 $((count - 1))); do
action=$(jq -c ".actions[$index]" /tmp/openspec-sweep-plan.json)
type=$(jq -r '.type' <<<"$action")
change=$(jq -r '.change' <<<"$action")
number=$(jq -r '.issue // ""' <<<"$action")
case "$type" in
open)
gh issue create --repo "$REPO" --label "$LABEL" \
--title "$(jq -r '.title' <<<"$action")" \
--body "$(jq -r '.body' <<<"$action")" \
|| echo "::notice::Could not open a tracking issue for $change."
;;
reopen)
# The planner emits this when a tracking issue was closed while
# its change is still unarchived. Without the case the action
# fell to the `*` notice below and nothing happened, so the
# sweep kept commenting into a closed thread nobody reads.
gh issue reopen "$number" --repo "$REPO" \
--comment "$(jq -r '.comment' <<<"$action")" \
|| echo "::notice::Could not reopen issue $number for $change."
;;
escalate)
gh issue comment "$number" --repo "$REPO" \
--body "$(jq -r '.comment' <<<"$action")" \
|| echo "::notice::Could not comment on issue $number for $change."
;;
close)
gh issue close "$number" --repo "$REPO" \
--comment "$(jq -r '.comment' <<<"$action")" \
|| echo "::notice::Could not close issue $number for $change."
;;
*)
echo "::notice::Unhandled action '$type' for $change."
;;
esac
done