autonomous-sdlc-harness 0.4.2 → 0.6.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/dist/commands/init.js +113 -16
- package/dist/commands/init.js.map +1 -1
- package/dist/config/check.js +28 -6
- package/dist/config/check.js.map +1 -1
- package/dist/config/model.js +64 -5
- package/dist/config/model.js.map +1 -1
- package/dist/core/pluginIdentity.js +2 -0
- package/dist/core/pluginIdentity.js.map +1 -1
- package/dist/core/writer.js +10 -5
- package/dist/core/writer.js.map +1 -1
- package/dist/core/yamlScalar.js +14 -0
- package/dist/core/yamlScalar.js.map +1 -0
- package/dist/doctor/checks.js +454 -23
- package/dist/doctor/checks.js.map +1 -1
- package/dist/generators/githubWorkflows.js +125 -20
- package/dist/generators/githubWorkflows.js.map +1 -1
- package/dist/generators/repoRoot.js +17 -8
- package/dist/generators/repoRoot.js.map +1 -1
- package/dist/remote/githubActions.js +110 -7
- package/dist/remote/githubActions.js.map +1 -1
- package/dist/retrieval/pythonBackend.js +114 -0
- package/dist/retrieval/pythonBackend.js.map +1 -0
- package/dist/retrieval/setup.js +8 -0
- package/dist/retrieval/setup.js.map +1 -1
- package/package.json +1 -1
- package/templates/README.md +1 -1
- package/templates/github/workflows/harness-control.yml +184 -0
- package/templates/github/workflows/harness-resume.yml +9 -0
- package/templates/github/workflows/harness-run.yml +127 -12
- package/templates/github/workflows/harness-trigger.yml +144 -0
- package/templates/repo/gitignore +5 -0
- package/templates/scripts/README.md +1 -1
- package/templates/scripts/autonomous-watcher.sh +121 -249
- package/templates/scripts/create-worktree.sh +52 -10
- package/templates/scripts/docs-search-server.sh +88 -17
- package/templates/scripts/lib/harness-run-lib.sh +614 -14
- package/templates/scripts/remote-run.sh +3684 -144
- package/templates/scripts/scratch-run.sh +54 -73
- package/templates/state-dir/README-root.md +1 -1
- package/templates/state-dir/scratch/README.md +4 -2
- package/templates/state-dir/user_reviews/README.md +2 -2
|
@@ -4,52 +4,513 @@
|
|
|
4
4
|
# workflow's input contract has exactly one producer on the shell side.
|
|
5
5
|
#
|
|
6
6
|
# THE VERBS AND THE EXIT MAP, stated once for every consumer (the watcher's
|
|
7
|
-
# inbox
|
|
8
|
-
#
|
|
7
|
+
# inbox pass, the job-side `continue` / `poll`, the guard's deny entry and the
|
|
8
|
+
# plugin commands that name this file):
|
|
9
9
|
#
|
|
10
10
|
# remote-run.sh dispatch <branch> --engine <kind> [--resume none|answer|pause]
|
|
11
11
|
# [--answers-from <clar_dir> --indexes "<n> <n>..."]
|
|
12
12
|
# [--park-loop-clear] [--chain <n>] [--repo <root>]
|
|
13
13
|
# remote-run.sh pause <branch> [--repo <root>]
|
|
14
14
|
# remote-run.sh warm [--repo <root>]
|
|
15
|
-
# remote-run.sh stop <branch> [--repo <root>]
|
|
15
|
+
# remote-run.sh stop <branch> [--actor <login>] [--repo <root>]
|
|
16
16
|
# remote-run.sh status <branch> [--repo <root>]
|
|
17
17
|
# remote-run.sh sync <branch> [--repo <root>]
|
|
18
|
+
# remote-run.sh fetch <branch> <out_dir> [--repo <root>]
|
|
18
19
|
# remote-run.sh restore <branch> --resume none|answer|pause [--repo <root>]
|
|
19
20
|
# remote-run.sh save <branch> <out_dir> [--repo <root>]
|
|
20
21
|
# remote-run.sh continue <branch> <bundle_dir> [--repo <root>]
|
|
21
22
|
# remote-run.sh poll [--repo <root>]
|
|
22
23
|
# remote-run.sh pause-requested <branch> <since_epoch> [--repo <root>]
|
|
23
24
|
# remote-run.sh run-created-at <run_id> [--repo <root>]
|
|
25
|
+
# remote-run.sh start <branch> --prompt-file <file> [--repo <root>]
|
|
26
|
+
# remote-run.sh review <branch> --review-file <file> [--allow-no-run]
|
|
27
|
+
# [--actor <login>] [--reviewers <login,login,...>]
|
|
28
|
+
# [--source <https-url>] [--repo <root>]
|
|
29
|
+
# remote-run.sh trigger [--repo <root>] (its own exit map: its paragraph)
|
|
30
|
+
# remote-run.sh list [--repo <root>]
|
|
31
|
+
# remote-run.sh discard <dir> [--repo <root>]
|
|
32
|
+
# remote-run.sh report <event> <branch> [--note <text>] [--repo <root>]
|
|
33
|
+
# (always 0, 1 only on a usage error: its paragraph)
|
|
34
|
+
# remote-run.sh deliver <branch> <bundle_dir> [--repo <root>]
|
|
35
|
+
# (always 0, 1 only on a usage error: its paragraph)
|
|
36
|
+
# remote-run.sh collect <branch> [--pr <n>] [--repo <root>]
|
|
37
|
+
# (always 0, 1 only on a usage error: its paragraph)
|
|
38
|
+
# remote-run.sh control [--repo <root>] (its own exit map: its paragraph)
|
|
24
39
|
# 0 sent (for stop: the action=stop marker was dispatched, and every
|
|
25
40
|
# queued, waiting or in-progress `harness run` run of that branch was
|
|
26
|
-
# asked to cancel, or there was none); for status
|
|
41
|
+
# asked to cancel, or there was none); for status and fetch: printed
|
|
42
|
+
# (for fetch, `state: none` included); for sync: the record is
|
|
27
43
|
# current (including "no run listed yet", which writes nothing); for
|
|
28
|
-
# restore: restored, or no previous bundle
|
|
29
|
-
# `::warning::` line) under
|
|
44
|
+
# restore: restored, or no previous bundle of the branch's current
|
|
45
|
+
# lineage (or an expired one, with a `::warning::` line) under
|
|
46
|
+
# --resume none|pause;
|
|
30
47
|
# for save: ALWAYS, whatever happened; for continue: whatever it
|
|
31
48
|
# decided — every outcome a person must act on is a notification; for
|
|
32
49
|
# poll: the tick finished; for pause-requested: such a run exists; for
|
|
33
|
-
# run-created-at: printed
|
|
50
|
+
# run-created-at: printed; for review: placed, pushed and dispatched;
|
|
51
|
+
# for list: printed; for discard: <dir> removed, or it did not exist
|
|
34
52
|
# 1 usage error, or the library or the configuration could not be
|
|
35
|
-
# resolved; for
|
|
53
|
+
# resolved; for fetch, <out_dir> is not an existing, empty directory;
|
|
54
|
+
# for discard, <dir>'s parent does not resolve or the removal failed;
|
|
55
|
+
# for sync and restore, a local copy or write failed; for
|
|
36
56
|
# pause-requested, also NO such run — a caller that reads 1 as "no
|
|
37
57
|
# pause" passes arguments it has already validated
|
|
38
58
|
# 2 refused, nothing sent or written: execution.target is not
|
|
39
|
-
# github-actions (sending verbs
|
|
40
|
-
#
|
|
41
|
-
#
|
|
42
|
-
# (
|
|
43
|
-
#
|
|
44
|
-
#
|
|
59
|
+
# github-actions (sending verbs, fetch, review and list); for
|
|
60
|
+
# review, a protected branch, a review file that is not a readable
|
|
61
|
+
# regular file, or a branch its settledness test reads as not settled
|
|
62
|
+
# (its paragraph); for sync, the
|
|
63
|
+
# branch's local record does not carry `execution: github-actions`; for
|
|
64
|
+
# status, a local record that does not carry `execution:
|
|
65
|
+
# github-actions`, or no record and `execution.target` not
|
|
66
|
+
# `github-actions`; the record's mirror working copy is missing, or a
|
|
67
|
+
# downloaded bundle is unrecognised (sync, restore, and status with no
|
|
68
|
+
# local record); the inputs payload is over the limit; a named answer
|
|
69
|
+
# file is missing (a relative --answers-from resolves against the
|
|
70
|
+
# caller's directory). For restore under --resume answer, "nothing more":
|
|
71
|
+
# no previous bundle of the branch's current lineage, the previous
|
|
72
|
+
# bundle expired (the message names
|
|
45
73
|
# its expiry and the resume command), `HARNESS_INPUT_ANSWERS` not an object of
|
|
46
74
|
# positive-integer keys to strings, or an answer whose `question_<n>.md`
|
|
47
75
|
# is not at the top level of the previous bundle — nothing is restored
|
|
48
|
-
# and no answer is written
|
|
76
|
+
# and no answer is written. For discard, <dir> does not resolve
|
|
77
|
+
# strictly inside `<state_dir>/scratch/`, is a symlink, or exists and
|
|
78
|
+
# is not a directory; nothing removed
|
|
49
79
|
# 3 gh failed: not found, or a non-zero exit — the first line of gh's
|
|
50
80
|
# stderr is named. For poll: the listing or the disable failed. For
|
|
51
81
|
# pause-requested and run-created-at, also an answer that is not the
|
|
52
|
-
# expected JSON; a caller never pauses on a failed read
|
|
82
|
+
# expected JSON; a caller never pauses on a failed read. For start, the
|
|
83
|
+
# dispatch failed AFTER the branch and its task prompt were pushed; for
|
|
84
|
+
# review, the listing failed, or the dispatch failed AFTER the review
|
|
85
|
+
# was pushed. For
|
|
86
|
+
# list, the listing or `git ls-remote` failed; nothing written
|
|
87
|
+
# 4 start: placement failed — the branch cut, the copy, the commit or the
|
|
88
|
+
# push — and nothing was dispatched; the working copy and the local
|
|
89
|
+
# branch the cut created were removed. review: placement failed — the
|
|
90
|
+
# copy (the branch checked out in another working copy included), the
|
|
91
|
+
# fast-forward, the commit or the push — and nothing was dispatched; a
|
|
92
|
+
# copy it cut was removed
|
|
93
|
+
#
|
|
94
|
+
# `start` IS THE ADAPTERS' ONE ENTRY: every trigger (an issue event, a forge
|
|
95
|
+
# dispatch, anything later) reduces to a branch and a task text and ends here.
|
|
96
|
+
# In order, stopping at the first failure: refuse a protected branch (2); refuse
|
|
97
|
+
# a prompt file that is not a readable regular file (2); cut the branch from
|
|
98
|
+
# `origin/<defaultBranch>` with `create-worktree.sh --no-bootstrap`; place the
|
|
99
|
+
# file at `<state_dir>/task_prompts/<branch>_task_prompt.md` in that working
|
|
100
|
+
# copy (the state directory resolved there, never in the main checkout), commit
|
|
101
|
+
# it as `chore: add task prompt for <branch>` and confirm `origin/<branch>`
|
|
102
|
+
# equals `HEAD` (each failure 4); then remove the working copy and the local
|
|
103
|
+
# branch it created — on every exit after the cut, success included and
|
|
104
|
+
# wherever `start` runs, because nothing reads the copy once the push has landed
|
|
105
|
+
# and a leftover branch makes the next cut of that name refuse; a copy or branch
|
|
106
|
+
# that existed before the cut is never removed — and `dispatch --engine task
|
|
107
|
+
# --resume none --chain 0`, composed by `verb_dispatch` itself. The placement is the library's
|
|
108
|
+
# (`hr_task_prompt_rel`, `hr_place_artifact`, `hr_commit_placed`,
|
|
109
|
+
# `hr_push_landed`), the same calls the watcher's inbox pass makes, so nothing
|
|
110
|
+
# downstream can tell where a task came from. It writes no registry record: a
|
|
111
|
+
# trigger job has no registry, and such a run needs no local record: the local
|
|
112
|
+
# commands act on it through GitHub.
|
|
113
|
+
#
|
|
114
|
+
# `fetch` IS THE COMMANDS' READ OF ONE BRANCH ON GITHUB, needing no local
|
|
115
|
+
# record. Gated like a sending verb. <out_dir> must be an existing, empty
|
|
116
|
+
# directory. It reads the newest `harness run <branch>` run through the same
|
|
117
|
+
# derivation `sync` makes (`remote_state`), downloads that run's state bundle
|
|
118
|
+
# into <out_dir> when one applies, and prints these lines, each always present
|
|
119
|
+
# and empty when unknown — the key names are a wire the commands parse:
|
|
120
|
+
# run_id: run_url: run_status: the newest `harness run <branch>` run
|
|
121
|
+
# state: `none` when no such run is listed, else the derivation's
|
|
122
|
+
# pause_reason: engine: detail: from the derivation
|
|
123
|
+
# open_questions: space-separated <n> of every top-level
|
|
124
|
+
# `<out_dir>/clarifications/<branch>/question_<n>.md` with
|
|
125
|
+
# no `answer_<n>.md` beside it, ascending
|
|
126
|
+
# bundle_dir: <out_dir> when a bundle was downloaded
|
|
127
|
+
#
|
|
128
|
+
# `discard` REMOVES THE DIRECTORY A COMMAND FETCHED INTO, so the command needs
|
|
129
|
+
# no recursive `rm` of its own. It removes <dir> only when the library's
|
|
130
|
+
# `hr_scratch_path_var` accepts it: strictly inside the checkout's
|
|
131
|
+
# `<state_dir>/scratch/`, not a symlink, and a directory when it exists
|
|
132
|
+
# (2 otherwise). A relative <dir>
|
|
133
|
+
# resolves against the caller's directory; the root is `--repo`, or else
|
|
134
|
+
# `hr_repo_root` of the working directory, as for `restore`. No `gh` call and
|
|
135
|
+
# no `execution.target` gate. A <dir> that does not exist is exit 0. It
|
|
136
|
+
# creates nothing and writes nothing else.
|
|
137
|
+
#
|
|
138
|
+
# `review` PLACES A USER REVIEW ROUND ON THE BRANCH TIP AND DISPATCHES IT, for
|
|
139
|
+
# `/autonomous-sdlc-harness:branch-user-review` on a run that executes on
|
|
140
|
+
# GitHub. In order, stopping at the first failure: refuse a protected branch
|
|
141
|
+
# and a review file that is not a readable regular file (2; a relative
|
|
142
|
+
# --review-file resolves against the caller's directory); refuse a branch that
|
|
143
|
+
# `branch_settled_var`, the settledness test `control` and `collect` share, reads as not
|
|
144
|
+
# settled (2). Its newest `harness run <branch>` run decides: none listed is
|
|
145
|
+
# settled only under --allow-no-run; a `completed` one is read by
|
|
146
|
+
# `remote_state`; any other is in flight as `running` until its `RUN_JOB_NAME`
|
|
147
|
+
# job has completed, and then read by `remote_state` as a finished run. Settled
|
|
148
|
+
# is `completed` or `failed`; `running`, `parked`, `park_loop` and `paused` are
|
|
149
|
+
# refused, an expired bundle naming its expiry. The refusal stays for this
|
|
150
|
+
# verb because a local round's file exists only on the caller's machine, so no
|
|
151
|
+
# later collection could pick it up. The bundle it reads is
|
|
152
|
+
# downloaded to `sync`'s directory, the one write a refusal makes. The copy: the main
|
|
153
|
+
# checkout's remote record's mirror when its `worktree` exists and is on the
|
|
154
|
+
# branch, never removed; otherwise `create-worktree.sh --existing
|
|
155
|
+
# --no-bootstrap` into `hr_worktree_dir`, removed with the local branch it
|
|
156
|
+
# DWIM-created on every exit, as `start` removes its cut. Either copy is
|
|
157
|
+
# fast-forwarded to `origin/<branch>`. No bootstrap runs: the copy holds one
|
|
158
|
+
# placed file. The round comes from `git ls-tree` of the copy's `HEAD` under
|
|
159
|
+
# `<state_dir>/user_reviews/` — the engine's own round source: each basename
|
|
160
|
+
# matching `^(.+)_review(_[0-9]+)?\.md$` whose captured branch EQUALS <branch>,
|
|
161
|
+
# the unsuffixed file being round 1; next is `<branch>_review.md` when none
|
|
162
|
+
# matched, else `<branch>_review_<max+1>.md`. It is placed, committed as
|
|
163
|
+
# `hr_user_review_subject`'s `chore: add user review for <branch>` and pushed
|
|
164
|
+
# (each failure 4); the cut copy is removed; then `dispatch --engine
|
|
165
|
+
# user_review --resume none --chain 0`, after which it holds until a `harness run
|
|
166
|
+
# <branch>` run whose `headSha` is the pushed commit is listed — `run_by_sha_var`,
|
|
167
|
+
# `trigger`'s bounded lookup — so a review job serialized behind it never reads
|
|
168
|
+
# the branch as settled before GitHub lists the new run; a lookup that runs out
|
|
169
|
+
# is one `::warning::` line and still exit 0. A remote record, when one exists, is
|
|
170
|
+
# set `running` / `user_review` in one write after the dispatch. Then the round
|
|
171
|
+
# is reported as `report round`. Under --reviewers (comma-separated logins) the
|
|
172
|
+
# note is `Round <round> from pull request #<pr> by @<a>, @<b>`, <pr> read from
|
|
173
|
+
# --source's `/pull/<n>` or else the branch's open pull request, with ` (<source>)`
|
|
174
|
+
# after it under --source. Otherwise it is `Round <round>`, plus ` from <source>`
|
|
175
|
+
# under --source, and ` by @<actor>` under --actor (a login, as for `stop`),
|
|
176
|
+
# else ` from a local session`.
|
|
177
|
+
# --allow-no-run EXISTS FOR A LOCALLY EXECUTED BRANCH REVIEWED ON GITHUB: such
|
|
178
|
+
# a branch has no `harness run <branch>` run, and its round runs through
|
|
179
|
+
# `WORKFLOW_RUN_FILE` because a GitHub-started round always does. Its local
|
|
180
|
+
# record carries no `execution: github-actions`, so it is neither read as the
|
|
181
|
+
# copy nor written; the round runs remotely, and the branch's local working
|
|
182
|
+
# copy falls behind `origin/<branch>` until the maintainer fast-forwards it.
|
|
183
|
+
# The flag widens only the none-listed refusal.
|
|
184
|
+
#
|
|
185
|
+
# `trigger` IS THE GITHUB EVENT ADAPTER, the one step of the trigger
|
|
186
|
+
# workflow's job: event -> (branch, task text) -> `start`. It handles
|
|
187
|
+
# `GITHUB_EVENT_NAME` `issues` and `repository_dispatch`; any other name, or an
|
|
188
|
+
# event file it cannot read, exits 1. Like `restore` it acts on `hr_repo_root` of the working
|
|
189
|
+
# directory, and it takes no part in the sending-verb gate: it gates itself, so
|
|
190
|
+
# a refusal can still be commented. It reads, only from the environment:
|
|
191
|
+
# GITHUB_EVENT_NAME, GITHUB_EVENT_PATH the event; each field is read by `jq`
|
|
192
|
+
# into a variable and is only ever an argument or file
|
|
193
|
+
# bytes, never shell source
|
|
194
|
+
# GITHUB_REPOSITORY, GITHUB_SERVER_URL, GITHUB_RUN_ID the `gh` target and
|
|
195
|
+
# the URLs its comments name
|
|
196
|
+
# HARNESS_REMOTE_STOP non-empty: every start is refused
|
|
197
|
+
# HARNESS_TRIGGER_LABEL the trigger label; when empty, `DEFAULT_TRIGGER_LABEL`
|
|
198
|
+
# or `LEGACY_TRIGGER_LABEL`, per the paragraph below
|
|
199
|
+
# HARNESS_TRIGGER_ALLOWED_BOTS comma-separated bot logins allowed to start
|
|
200
|
+
# HARNESS_TRIGGER_LOOKUP_SECS seconds between run lookups; `5` when empty.
|
|
201
|
+
# A test seam
|
|
202
|
+
# RUNNER_TEMP where the prompt snapshot is written; a `mktemp -d`
|
|
203
|
+
# directory when empty
|
|
204
|
+
# THE LEGACY LABEL. The workflow `init` now writes always passes a non-empty
|
|
205
|
+
# `HARNESS_TRIGGER_LABEL`; only the previous release's workflow, which
|
|
206
|
+
# `init --upgrade-workflows` never re-renders, passes it empty, and its `if:`
|
|
207
|
+
# already ran the job for `LEGACY_TRIGGER_LABEL`. So when it is empty either
|
|
208
|
+
# `DEFAULT_TRIGGER_LABEL` or `LEGACY_TRIGGER_LABEL` is accepted, and the comment
|
|
209
|
+
# and the label removal name the one applied; a new workflow starts on
|
|
210
|
+
# `LEGACY_TRIGGER_LABEL` only when the variable names it.
|
|
211
|
+
# An `action` other than `labeled`, or another label, is one line and exit 0
|
|
212
|
+
# with no `gh` call. Otherwise refused, in this order, each refusal one issue
|
|
213
|
+
# comment naming the reason and the way on:
|
|
214
|
+
# 1. `HARNESS_REMOTE_STOP` is set
|
|
215
|
+
# 2. `hr_forge` is not `github` or `hr_execution_target` is not
|
|
216
|
+
# `github-actions` — before any authorisation, so a disabled trigger asks
|
|
217
|
+
# GitHub nothing about the labeller
|
|
218
|
+
# 3. the issue is not `open`
|
|
219
|
+
# 4. `sender.login` is `ghost` (GitHub's placeholder for a deleted account),
|
|
220
|
+
# empty, or not a login shape (`^[A-Za-z0-9][A-Za-z0-9-]*$`, plus `[bot]`
|
|
221
|
+
# for a `Bot`)
|
|
222
|
+
# 5. `sender.type` is not `User` and the login is not an exact entry of
|
|
223
|
+
# `HARNESS_TRIGGER_ALLOWED_BOTS` — checked by the listing alone, with no
|
|
224
|
+
# permission call, because the permission API answers `none` or 404 for a bot
|
|
225
|
+
# 6. a `User` whose `collaborators/<login>/permission` is not `admin` or
|
|
226
|
+
# `write` — `maintain` reads as `write` and `triage` as `read` there; a
|
|
227
|
+
# failed call is "could not confirm write access", never a pass
|
|
228
|
+
# Refusals 4 to 6 are `authorise_actor`, the one actor check `control` reuses.
|
|
229
|
+
# Then it fetches `origin <defaultBranch>` (a failure tolerated), derives the
|
|
230
|
+
# branch with `hr_derive_branch <title> issue_<number>`, passing `gh` so a name
|
|
231
|
+
# with run-workflow history counts as taken (2 or 3 refused), writes
|
|
232
|
+
# the snapshot — `# <title>`, the body's bytes, `---` and a provenance sentence
|
|
233
|
+
# naming the issue, the labeller, the label and the time — and runs `start` as a
|
|
234
|
+
# child. After a start it looks up the `harness run <branch>` run whose
|
|
235
|
+
# `headSha` is the `origin/<branch>` commit `start` pushed, at most
|
|
236
|
+
# `TRIGGER_RUN_LOOKUP_TRIES` times, falling back to the branch's filtered run
|
|
237
|
+
# list, and comments the branch and that URL; the comment never names an older
|
|
238
|
+
# run of the branch. Every comment ends with the marker
|
|
239
|
+
# `<!-- sdlc-harness event=started branch=<branch> -->` on a start and
|
|
240
|
+
# `event=refused` otherwise (`branch=` empty before one is derived), and is
|
|
241
|
+
# followed by removing the label, so re-applying it is deliberate; then a start
|
|
242
|
+
# sets the state label `sdlc-harness: running`. That comment, that removal and
|
|
243
|
+
# that one label are the trigger's only writes to the issue; a refusal sets no
|
|
244
|
+
# label. A removal or a label set that fails is one `::warning::` line.
|
|
245
|
+
# A `repository_dispatch` reads `.action` (where GitHub puts the `event_type`)
|
|
246
|
+
# and `client_payload`'s `title`, `body` and `source`, the contract being
|
|
247
|
+
# {"event_type": TRIGGER_DISPATCH_EVENT_TYPE, "client_payload": {"title": …,
|
|
248
|
+
# "body": …, "source": …}}
|
|
249
|
+
# within GitHub's `client_payload` limits: at most 10 top-level properties and
|
|
250
|
+
# under 64 KB, so a longer task text does not fit. Another `.action` is one line
|
|
251
|
+
# and exit 0 with no `gh` call; an empty or missing `title` is refused. It has
|
|
252
|
+
# no labeller: GitHub sends one only for a fine-grained token with Contents
|
|
253
|
+
# write or a classic token with `repo`, so the token holder is the authority,
|
|
254
|
+
# and refusals 3 to 6 do not apply. The fallback name is `task_<GITHUB_RUN_ID>`
|
|
255
|
+
# and the snapshot's provenance sentence names the event type, `source` when
|
|
256
|
+
# set, and the time. There is no issue, so no comment and no label: every
|
|
257
|
+
# outcome is printed to stdout and appended as a Markdown block to
|
|
258
|
+
# `GITHUB_STEP_SUMMARY` when that is set, an append failure one `::warning::`.
|
|
259
|
+
# 0 started (commented), or ignored
|
|
260
|
+
# 1 not an `issues` or `repository_dispatch` event, or the event could not
|
|
261
|
+
# be read
|
|
262
|
+
# 2 refused (commented)
|
|
263
|
+
# 3 a `gh` step after the decision failed: the comment could not be posted
|
|
264
|
+
# (an `::error::` line; issues only), or `start` pushed the branch but
|
|
265
|
+
# its dispatch failed (commented with the manual Run-workflow way on)
|
|
266
|
+
# 4 `start` refused or failed its placement (commented)
|
|
267
|
+
#
|
|
268
|
+
# `report` TURNS A LIFECYCLE EVENT INTO ONE COMMENT AND ONE STATE LABEL, through
|
|
269
|
+
# the forge surface (its section states the functions). Job-side for its root,
|
|
270
|
+
# as `trigger` is, and outside the sending-verb gate: it does nothing, with one
|
|
271
|
+
# line, unless `hr_forge` is `github` and `hr_execution_target` is
|
|
272
|
+
# `github-actions`. The comment goes to the open same-repository pull request
|
|
273
|
+
# whose head is <branch> when origin's <branch> carries
|
|
274
|
+
# `<state_dir>/flow_progress/<branch>_progress.md`, else to the issue named by
|
|
275
|
+
# the last `Started from <server>/<repo>/issues/<n> by @` line of its committed
|
|
276
|
+
# task prompt, else nowhere. The label `STATE_LABEL_PREFIX<state>` replaces any
|
|
277
|
+
# other state label on that issue and that pull request, each when known; the
|
|
278
|
+
# label is a view, and the run list stays the authority. The state map:
|
|
279
|
+
# `parked` and `park_loop` -> parked, `paused` -> paused, `resumed` -> running,
|
|
280
|
+
# `failed` -> failed, `stopped` -> stopped, `round` (review's) -> running.
|
|
281
|
+
# `failed` posts nothing when
|
|
282
|
+
# `remote_branch_stopped` finds the branch stopped, so a cancelled job never
|
|
283
|
+
# overwrites `stopped`. `completed` (deliver's) and `launched` (the trigger's
|
|
284
|
+
# own comment) are one line each, as is any other event. The comment names the
|
|
285
|
+
# next GitHub action — never a slash command — then <note> byte for byte, then
|
|
286
|
+
# this run's URL when `GITHUB_RUN_ID` is set, then the marker line
|
|
287
|
+
# `<!-- sdlc-harness event=<event> branch=<branch> -->`. The pause reason and
|
|
288
|
+
# reset come from the registry record, read only when the registry file exists.
|
|
289
|
+
# `parked` instead posts one comment per open question — `open_questions_in`
|
|
290
|
+
# over `$root`'s state directory, ascending — carrying `question_<n>.md` whole,
|
|
291
|
+
# cut at its last whole line within `QUESTION_COMMENT_MAX_BYTES` and then naming
|
|
292
|
+
# the file in the `STATE_ARTIFACT_NAME` artifact; then the answer form, a
|
|
293
|
+
# comment whose first line is `COMMAND_HANDLE answer <n>` (`<n>` optional when
|
|
294
|
+
# one question is open) and whose following lines are the answer; the marker
|
|
295
|
+
# adds `question=<n>`. With no question open it posts the one notice. The label
|
|
296
|
+
# is set once per target, not per question. On a public repository a question
|
|
297
|
+
# comment and its answer are public, as the artifact already is
|
|
298
|
+
# (`docs/remote-execution.md` -> `## 11. Security`, *What a reader of the
|
|
299
|
+
# repository's Actions runs can see*).
|
|
300
|
+
# It never fails its caller: every problem is one line and exit 0.
|
|
301
|
+
#
|
|
302
|
+
# `deliver` HANDS A COMPLETED RUN TO REVIEW: the run workflow's step after the
|
|
303
|
+
# job's own `push-branch.sh`, which opens no pull request. Job-side for its
|
|
304
|
+
# root and self-gated, as `report` is (`forge_on`, one line when off). Anything
|
|
305
|
+
# but `status: completed` in <bundle_dir>/status.json, or no status.json, is
|
|
306
|
+
# one line and nothing sent. Otherwise, by the forge surface: an open
|
|
307
|
+
# same-repository pull request whose head is <branch> is reused and no second
|
|
308
|
+
# one is opened; else a draft is created against `defaultBranch`, titled from
|
|
309
|
+
# the task prompt's `# ` first line (cut to `PR_TITLE_MAX_CHARS`, else
|
|
310
|
+
# <branch>), whose body names the issue as `Started from #<n>.` — a plain
|
|
311
|
+
# mention, never a closing keyword — states what a review requesting changes
|
|
312
|
+
# and the `COMMAND_HANDLE` commands do, and ends with the `pull-request`
|
|
313
|
+
# marker. The create runs with `HARNESS_PR_TOKEN` as `GH_TOKEN` when that is
|
|
314
|
+
# set, so the adopter's CI runs without an approval click; every other call
|
|
315
|
+
# uses the job's token. A pull request so opened is authored by that token's
|
|
316
|
+
# owner, who therefore cannot request changes on it: use a machine account's
|
|
317
|
+
# token, another reviewer, or a local `branch-user-review` round. A create
|
|
318
|
+
# refused with `PR_CREATE_FORBIDDEN` is not retried, and the comment names the
|
|
319
|
+
# Actions setting and `HARNESS_GIT_TOKEN`; any other failure is retried once
|
|
320
|
+
# without `--draft`, and a second failure is named in the comment with the
|
|
321
|
+
# branch's compare URL. A pull-request lookup that fails opens nothing. Then
|
|
322
|
+
# one `completed` comment: on the issue naming the new pull request's URL; on
|
|
323
|
+
# the pull request when there is no issue, or when it existed before this run
|
|
324
|
+
# (saying the round finished); on the issue alone when none could be opened;
|
|
325
|
+
# nowhere when neither is known. It names reviewing and requesting changes as
|
|
326
|
+
# the next action, and with `phases.qa` true the local `branch-qa-test` still
|
|
327
|
+
# owed. Then `sdlc-harness: done` on the issue and the pull request, each when
|
|
328
|
+
# known. It writes at most one pull request, one comment and those labels, and
|
|
329
|
+
# never pushes. It never fails its caller: every problem is one line and exit 0.
|
|
330
|
+
#
|
|
331
|
+
# `collect` STARTS THE NEXT ROUND FROM THE REVIEWS COLLECTED DURING A RUN: the
|
|
332
|
+
# one step of the run workflow's `collect` job, which follows its `run` job, so
|
|
333
|
+
# a review `control` answered "collected" waits at most for the run in flight.
|
|
334
|
+
# Job-side for its root and self-gated, as `report` and `deliver` are
|
|
335
|
+
# (`forge_on`, one line when off). In order, each stop one line and exit 0:
|
|
336
|
+
# `HARNESS_REMOTE_STOP` set; `remote_branch_stopped` finding the branch stopped,
|
|
337
|
+
# or failing; no pull request, from --pr or else `forge_pr_var` (a failed lookup
|
|
338
|
+
# included); `branch_settled_var`, read as `control` reads it, finding the
|
|
339
|
+
# branch in flight — a newer run listed, or this run ending `parked`,
|
|
340
|
+
# `park_loop` or `paused` (a budget chain's included), whose own end collects
|
|
341
|
+
# next — or failing; `round_collect` finding no review requesting changes
|
|
342
|
+
# pending (inline comments alone start no round, as *Comment* starts none; they
|
|
343
|
+
# ride along in the next), or failing, a `::warning::` line. Otherwise `review
|
|
344
|
+
# <branch> --review-file <file> --allow-no-run --reviewers <logins> --source
|
|
345
|
+
# <pull request url>` runs as a child, as `control` runs it, placing,
|
|
346
|
+
# dispatching and reporting the round. A child that exits non-zero gets exactly
|
|
347
|
+
# one comment on the pull request, with the `reply` marker, naming its last
|
|
348
|
+
# stderr line and saying the reviews stay there and that submitting a review
|
|
349
|
+
# requesting changes retries; there is no automatic retry. It never fails its
|
|
350
|
+
# caller: every outcome but a usage error is exit 0.
|
|
351
|
+
#
|
|
352
|
+
# `control` IS THE COMMENT AND REVIEW ADAPTER, the twin of `trigger` and the one
|
|
353
|
+
# step of the `WORKFLOW_CONTROL_FILE` job: one GitHub event -> one action on
|
|
354
|
+
# exactly one branch, carried out by this script's own verbs run as children,
|
|
355
|
+
# so it composes no dispatch itself. Job-side for its root, as `trigger` is,
|
|
356
|
+
# and outside the sending-verb gate: it gates itself after reading the event,
|
|
357
|
+
# so a refusal can still be replied to. The workflow's `if:` only saves a
|
|
358
|
+
# runner; every rule below holds without it. It handles `GITHUB_EVENT_NAME`
|
|
359
|
+
# `issue_comment` and `pull_request_review` (THE REVIEW, below); any other
|
|
360
|
+
# name, or an event file it cannot read, exits 1.
|
|
361
|
+
# It reads `.action`, `.comment.body`, `.issue.number`,
|
|
362
|
+
# `.issue.pull_request.url`, `.sender.login` and `.sender.type`, each by `jq`
|
|
363
|
+
# into a variable (data, never shell source), plus `trigger`'s environment.
|
|
364
|
+
# Ignored, with one line and no `gh` call: an action other than `created`; a
|
|
365
|
+
# body carrying `COMMENT_MARKER` anywhere (the harness's own comment, whoever
|
|
366
|
+
# posted it); and a body whose first line — a trailing CR stripped, leading
|
|
367
|
+
# spaces and tabs skipped — does not open with a word equal to
|
|
368
|
+
# `COMMAND_HANDLE`, compared lowercase. So `pause`, `Let's @sdlc-harness
|
|
369
|
+
# pause` and `> @sdlc-harness pause` start nothing. The verb is the next word,
|
|
370
|
+
# lowercased, and `CONTROL_ARGS` the rest of that line. Then refused, in this
|
|
371
|
+
# order, each a reply and exit 2:
|
|
372
|
+
# 1. `HARNESS_REMOTE_STOP` is set
|
|
373
|
+
# 2. `forge_on` fails — before any authorisation, so a disabled coupling asks
|
|
374
|
+
# GitHub nothing about the commenter
|
|
375
|
+
# 3. `authorise_actor` fails: `AUTH_WHY`, and who may command a run
|
|
376
|
+
# 4. the verb is empty, not a `COMMAND_VERBS` word, or one no arm carries out
|
|
377
|
+
# yet: the reply lists every command and names `docs/github-run-control.md`
|
|
378
|
+
# THE BRANCH. On a pull request (`.issue.pull_request.url` set), its head, by
|
|
379
|
+
# `pr view`: a fork's pull request is refused, because this event carries the
|
|
380
|
+
# repository's secrets, and nothing from its head is checked out or run; one
|
|
381
|
+
# not `OPEN` is refused. On an issue, the branch of the LAST genuine start
|
|
382
|
+
# comment, read by a paginated comment listing: its author is
|
|
383
|
+
# `github-actions[bot]`, its first line opens with the trigger's sentence
|
|
384
|
+
# `Started a harness run on the branch ` and a backticked <b>, and its last
|
|
385
|
+
# non-empty line is byte for byte what `forge_marker started <b>` prints. A
|
|
386
|
+
# marker quoted mid-body, in a comment not opening with that sentence, or by
|
|
387
|
+
# anyone else, is never trusted; none is a refusal. Both paths then pass
|
|
388
|
+
# `control_check_branch`: a branch `hr_branch_is_protected` does not answer 1
|
|
389
|
+
# for is refused, then it is fetched, and one whose origin tip carries no
|
|
390
|
+
# flow-progress ledger (`forge_recognised`) is not a harness branch.
|
|
391
|
+
# THE ARMS. `pause`: the state by a `fetch` child (`control_state_var`); only
|
|
392
|
+
# `running` sends `pause <branch>` as a child and replies that the run yields at
|
|
393
|
+
# its next clean checkpoint; any other state is a refusal naming it. `stop`:
|
|
394
|
+
# state `none` is a refusal; otherwise `stop <branch> --actor <login>` as a
|
|
395
|
+
# child, which posts its own `stopped` comment to the run's target; on 0 a reply
|
|
396
|
+
# is ALWAYS posted where the command was typed too, so a command on the issue of
|
|
397
|
+
# a branch with a pull request is answered there; on 3 the reply says the stop
|
|
398
|
+
# was partial and to comment `stop` again. `resume` accepts only `paused`, any
|
|
399
|
+
# `pause_reason` (`expired` and `killed` included); `park_loop` is refused
|
|
400
|
+
# pointing at `clear`, `parked` pointing at `answer <n>` with the open indexes,
|
|
401
|
+
# `running`, `completed`, `failed` and `none` each naming the state. `clear`
|
|
402
|
+
# accepts only `park_loop`, the GitHub form of `branch-resume`'s confirmation;
|
|
403
|
+
# any other state is a refusal naming it. Each sends the local relay's dispatch,
|
|
404
|
+
# `dispatch <branch> --engine <the state's engine> --resume pause --chain 0`,
|
|
405
|
+
# `clear` with `park_loop_clear` set, and no other command sets it. An empty
|
|
406
|
+
# engine is refused, never guessed (the `engine` input defaults to `task`),
|
|
407
|
+
# naming the Run workflow form. On 0 a reply, then `running` on the run's issue
|
|
408
|
+
# and pull request; on 2 a refusal and exit 2. `answer`: the first line is
|
|
409
|
+
# `answer <n>` and the answer every line below it, a trailing CR stripped from
|
|
410
|
+
# each line and its bytes otherwise unchanged; text after <n> on the first line
|
|
411
|
+
# is the answer when nothing follows below, and with no positive-integer <n>
|
|
412
|
+
# all of that line's text is. <n> may be left out only when exactly one
|
|
413
|
+
# question is open — issue comments have no threads. Refused, each a reply and
|
|
414
|
+
# exit 2: an empty answer; `park_loop` (pointing at `clear`); `paused` /
|
|
415
|
+
# `expired`, quoting its detail and pointing at `resume`, never treated as no
|
|
416
|
+
# park; `running`, naming the run, so a second answer never queues behind a job
|
|
417
|
+
# that a newer pending run in the per-branch `concurrency` group could cancel;
|
|
418
|
+
# any state but `parked`; no open question; several open and no <n>; an <n> not
|
|
419
|
+
# open, listing the open set; an empty engine, naming the Run workflow form.
|
|
420
|
+
# Otherwise the answer is written by `printf` (data, never shell source) to
|
|
421
|
+
# `answer_<n>.md` in a fresh directory under `RUNNER_TEMP`, and sent as
|
|
422
|
+
# `dispatch <branch> --engine <engine> --resume answer --answers-from <dir>
|
|
423
|
+
# --indexes <n> --chain 0`: one answer, one dispatch with one entry. That is
|
|
424
|
+
# safe because an `answer` job whose park is not fully answered stops parked
|
|
425
|
+
# before any session (`autonomous-watcher.sh` -> `run_job`), its bundle then
|
|
426
|
+
# carrying the `answer_<n>.md` restore wrote, so the next answer's job finds the
|
|
427
|
+
# set complete. The payload limit is `dispatch`'s alone: its refusal is quoted,
|
|
428
|
+
# with shortening the answer or committing it to a file on the branch as the
|
|
429
|
+
# way on. On 0 with no other question open, a reply that the run resumes and
|
|
430
|
+
# `running` on its issue and pull request; with others open, a reply naming
|
|
431
|
+
# them, and the label stays `parked`. An answer becomes a comment on the item,
|
|
432
|
+
# public on a public repository, as the question already is.
|
|
433
|
+
# THE REVIEW. A `pull_request_review` event reads `.action`, `.review.state`,
|
|
434
|
+
# `.review.body`, `.review.id`, `.review.html_url`, `.review.submitted_at`,
|
|
435
|
+
# `.pull_request.number`, `.pull_request.head.ref`,
|
|
436
|
+
# `.pull_request.head.repo.full_name`, `.sender.login` and `.sender.type`.
|
|
437
|
+
# Ignored, with one line and no `gh` call: an action other than `submitted`; a
|
|
438
|
+
# state other than `REVIEW_ROUND_STATE`, compared lowercase (the REST API
|
|
439
|
+
# reports it uppercase); a body carrying `COMMENT_MARKER`; and a head
|
|
440
|
+
# repository other than `GITHUB_REPOSITORY`, not even replied to, because a
|
|
441
|
+
# fork's review job holds a read-only token. So *Comment* and *Approve* start
|
|
442
|
+
# nothing, and draft status plays no part. Then gates 1-3 above, in order, each
|
|
443
|
+
# a reply on the pull request, and `control_check_branch` on the head; then a
|
|
444
|
+
# head whose origin tip carries no `<state>/story_plans/<head>_story_plan.md`
|
|
445
|
+
# is refused, because the round reads its story index. A REVIEW IS NEVER
|
|
446
|
+
# REFUSED FOR A RUN IN FLIGHT. The head is read by `branch_settled_var`, no run
|
|
447
|
+
# counting as settled, in a command substitution so a failed read is a reply
|
|
448
|
+
# (exit 3) rather than an exit with none. Not settled: a reply and exit 0,
|
|
449
|
+
# nothing pushed or dispatched — `your review is part of round <n>, which is
|
|
450
|
+
# <state>` when a marker on origin's tip records the event's review id, else
|
|
451
|
+
# `your review was collected`, naming the state and saying the next round
|
|
452
|
+
# starts by itself when that run finishes, plus the state's way on: `answer
|
|
453
|
+
# <n>` for `parked`, `clear` for `park_loop`, `resume` for any `paused` but
|
|
454
|
+
# `usage`, and for `usage` that it resumes after the reset. The review stays on
|
|
455
|
+
# the pull request, and a later round collects it. Settled: a round is CUMULATIVE,
|
|
456
|
+
# built by `round_collect` in a fresh file under `RUNNER_TEMP` from ONE
|
|
457
|
+
# paginated `pulls/<n>/reviews` and ONE paginated `pulls/<n>/comments` listing
|
|
458
|
+
# — never the per-review endpoint, which carries no `line` — the event's own
|
|
459
|
+
# review merged when the listing lacks it. What earlier rounds consumed is the
|
|
460
|
+
# union of the review and comment ids their marker lines record, read from
|
|
461
|
+
# origin's tip; the time boundary is the highest-numbered marked round's
|
|
462
|
+
# `collected_at` less `ROUND_OVERLAP_SECS`, or, when no round is marked, the
|
|
463
|
+
# committer time of the branch's newest `user_reviews/<head>_review[_<n>].md`
|
|
464
|
+
# (none when there is no round). Pending: a submitted review (never
|
|
465
|
+
# `PENDING`) whose id is unrecorded, whose body carries no `COMMENT_MARKER`,
|
|
466
|
+
# whose `submitted_at` is at or after the boundary, and which either has state
|
|
467
|
+
# `REVIEW_ROUND_STATE` or a non-blank body; and an
|
|
468
|
+
# inline comment, by any author and whatever its review's state, whose id is
|
|
469
|
+
# unrecorded, whose body carries no `COMMENT_MARKER`, and whose `created_at` is
|
|
470
|
+
# at or after the boundary or whose review is pending. Every distinct author
|
|
471
|
+
# of a pending item passes `authorise_actor`; a refused author's items are
|
|
472
|
+
# dropped with one line naming the login, `AUTH_WHY` and the count, and a
|
|
473
|
+
# failed permission call fails the collection. A round is placed only when at
|
|
474
|
+
# least one kept review has state `REVIEW_ROUND_STATE`; a *Comment* or
|
|
475
|
+
# *Approve* review rides along in the next round one requesting changes starts.
|
|
476
|
+
# The file: one `## Review by @<login>` section per pending review, oldest
|
|
477
|
+
# `submitted_at` first, holding the body verbatim (or `(The review carries no
|
|
478
|
+
# summary.)`) and a provenance line naming its state — `Requested changes on
|
|
479
|
+
# pull request #<n> (<url>) at <submitted_at>.`, `Commented on pull request
|
|
480
|
+
# …`, `Approved pull request …`, `Reviewed pull request … at <submitted_at>;
|
|
481
|
+
# the review has since been dismissed.`, or for any other state `Reviewed
|
|
482
|
+
# pull request … at <submitted_at> (state <state>).`; then, when any is
|
|
483
|
+
# kept, `## Inline comments`, oldest `created_at` first; then the marker line
|
|
484
|
+
# `<!-- sdlc-harness round collected_at=<utc> reviews=<id,…> comments=<id,…> -->`
|
|
485
|
+
# listing exactly the ids written. Each comment is a `### `<path>`, line
|
|
486
|
+
# <n>` heading (`original line <n> (outdated)` when `line` is null), `Made on
|
|
487
|
+
# commit `<original_commit_id or commit_id>`.`, `By @<login>: <url>`, its body
|
|
488
|
+
# verbatim, and its `diff_hunk` in a `diff` fence one backtick longer than the
|
|
489
|
+
# hunk's longest backtick run, at least three — the commit and hunk let the fix plan re-locate
|
|
490
|
+
# a line the fixes moved. With no review pending, nothing is placed: a reply
|
|
491
|
+
# names the round whose marker records the event's review, else says nothing
|
|
492
|
+
# is pending, and exit 0. Then `review <head> --review-file <file>
|
|
493
|
+
# --allow-no-run --reviewers <logins> --source <pull request url>` runs as a
|
|
494
|
+
# child, which fast-forwards, commits `chore: add user review for <head>`, pushes,
|
|
495
|
+
# dispatches `engine: user_review`, waits for its run to be listed and reports
|
|
496
|
+
# the round itself. Its 0 is exit 0 with nothing more posted; 2 means the branch
|
|
497
|
+
# became unsettled between the two reads, answered with the in-flight reply
|
|
498
|
+
# above (the state read again) and exit 0; 3 a reply quoting the re-send line,
|
|
499
|
+
# exit 3; 4 a reply that placement failed and nothing was dispatched, exit 4.
|
|
500
|
+
# A refusal reads `@<login>: `review` was not run: …`, and no reply to a review
|
|
501
|
+
# asks for it to be submitted again to be kept: a reply that started nothing
|
|
502
|
+
# says the reviews stay on the pull request for the next round, and that
|
|
503
|
+
# submitting a review requesting changes retries now.
|
|
504
|
+
# A child's failure is a reply
|
|
505
|
+
# naming its last stderr line, and exit 3. Every reply goes to the item the comment was
|
|
506
|
+
# typed on, opens `@<login>`, and carries the `reply` marker; a refusal reads
|
|
507
|
+
# `@<login>: `<verb>` was not run: <reason>. <way on>`.
|
|
508
|
+
# 0 handled (replied), or ignored
|
|
509
|
+
# 1 neither an `issue_comment` nor a `pull_request_review` event, or the event could not be read
|
|
510
|
+
# 2 refused (replied)
|
|
511
|
+
# 3 a `gh` step failed: the reply could not be posted (an `::error::`
|
|
512
|
+
# line), or the action failed and was replied to
|
|
513
|
+
# 4 a review's round could not be placed; nothing was dispatched (replied)
|
|
53
514
|
#
|
|
54
515
|
# `restore` AND `save` ARE THE JOB-SIDE VERBS: the run workflow calls them in
|
|
55
516
|
# its job, before and (under `always()`) after the harness step. Without
|
|
@@ -84,7 +545,21 @@
|
|
|
84
545
|
# with the exact bytes, after checking every entry first; and with
|
|
85
546
|
# `HARNESS_INPUT_PARK_LOOP_CLEAR` exactly `true` it sets `park_loop_cycles` to
|
|
86
547
|
# "0" in the restored `autonomous_logs/remote_status.json`. No bundle in any
|
|
87
|
-
# run is an ordinary first job (exit 0, one line) except under
|
|
548
|
+
# candidate run is an ordinary first job (exit 0, one line) except under
|
|
549
|
+
# --resume answer, whose refusal names the current lineage.
|
|
550
|
+
#
|
|
551
|
+
# THE CANDIDATES ARE BOUNDED TO THE BRANCH'S CURRENT LINEAGE, so a branch
|
|
552
|
+
# recreated under a reused name never restores an earlier, unrelated run's
|
|
553
|
+
# bundle. The listing reads each run's `headSha`, and `lineage_commits_var`
|
|
554
|
+
# lists `git rev-list refs/remotes/origin/<defaultBranch>..HEAD` in the job's
|
|
555
|
+
# checkout; a run whose `headSha` is not among those commits, or that carries
|
|
556
|
+
# none, is dropped before the walk, so the expired-bundle stop applies to
|
|
557
|
+
# lineage runs only. When any was dropped it prints `skipped <n> finished
|
|
558
|
+
# run(s) of <branch> from before its current lineage`. When the lineage cannot
|
|
559
|
+
# be listed — the configuration unreadable, `origin/<defaultBranch>` not
|
|
560
|
+
# present, or HEAD carrying no commit beyond it — every finished run is a
|
|
561
|
+
# candidate, as before the bound, and it prints `the lineage of <branch> is
|
|
562
|
+
# not bounded (<reason>); every finished run of it is a candidate`.
|
|
88
563
|
#
|
|
89
564
|
# `save` WRAPS `hr_remote_bundle_write` into <out_dir>, and with
|
|
90
565
|
# `GITHUB_STEP_SUMMARY` set appends a Markdown table of the bundle's `status`,
|
|
@@ -116,7 +591,8 @@
|
|
|
116
591
|
# so it names the repository rather than a runner path
|
|
117
592
|
# GITHUB_RUN_ID, GITHUB_SERVER_URL, GITHUB_REPOSITORY the run URL
|
|
118
593
|
# Notifications go through the sibling `autonomous-notify.sh`, as `paused` or
|
|
119
|
-
# `failed
|
|
594
|
+
# `failed`, and each is then reported as `report` reports that event, with a
|
|
595
|
+
# note of its own that names no slash command and no shell command. A re-dispatch is `dispatch <branch> --engine <status.json engine>
|
|
120
596
|
# --resume pause --chain <chain + 1>`, composed by `dispatch` itself.
|
|
121
597
|
#
|
|
122
598
|
# `chain` HAS ONE SOURCE: the bundle's `status.json`, whose `chain` is the
|
|
@@ -204,11 +680,28 @@
|
|
|
204
680
|
# `gh run view <run_id> --json createdAt` as an epoch second.
|
|
205
681
|
#
|
|
206
682
|
# `status` AND `sync` READ THE RECORD, NOT THE KEY. A run keeps the execution
|
|
207
|
-
# it started with, so they test
|
|
208
|
-
# `execution.target`; the configuration is still read for `stateDir`.
|
|
683
|
+
# it started with, so where a record exists they test its `execution` field and
|
|
684
|
+
# never `execution.target`; the configuration is still read for `stateDir`.
|
|
685
|
+
# Only `status` with no record reads the key.
|
|
686
|
+
#
|
|
687
|
+
# `status` WITH NO LOCAL RECORD (no registry file, or no record of the branch)
|
|
688
|
+
# is gated like a sending verb and answers from GitHub alone: the same runs
|
|
689
|
+
# listing, then `remote_state` with the bundle downloaded into a `mktemp -d`
|
|
690
|
+
# directory removed on exit, printing the state, pause reason, detail, engine
|
|
691
|
+
# and run URL, each open question (the rule `fetch` states) with its `## Q<k>`
|
|
692
|
+
# heading lines; an expired bundle's expired line is its `detail`. No `harness run
|
|
693
|
+
# <branch>` run listed prints one line and exits 0. There is no sync to compare
|
|
694
|
+
# against, so no finished-since line.
|
|
695
|
+
#
|
|
696
|
+
# `list` IS `branch-status`'s DIGEST: one listing (`list_all_runs`) and one
|
|
697
|
+
# `git ls-remote --heads origin`, never a bundle. It prints `on GitHub, no
|
|
698
|
+
# local record: <branch> <url>` for each branch `unrecorded_runs` keeps — its
|
|
699
|
+
# newest run titled exactly `harness run <branch>`, no registry record,
|
|
700
|
+
# unprotected and a live head on origin — or `no run on GitHub without a local
|
|
701
|
+
# record`.
|
|
209
702
|
#
|
|
210
|
-
# `status` WRITES NOTHING AT ALL — no registry (it does not even
|
|
211
|
-
# absent one), no download, no file. It prints the branch's newest runs titled
|
|
703
|
+
# `status` WITH A RECORD WRITES NOTHING AT ALL — no registry (it does not even
|
|
704
|
+
# create an absent one), no download, no file. It prints the branch's newest runs titled
|
|
212
705
|
# `harness run <branch>` or `harness pause <branch>` (bounded), the record's
|
|
213
706
|
# `status`, `pause_reason`, `remote_run_url` and `remote_synced_at`, whether
|
|
214
707
|
# a `harness run` finished after the last sync, and — reading the newest
|
|
@@ -234,7 +727,8 @@
|
|
|
234
727
|
# `mirror` mode into the record's `worktree`, `run.log` copied to the main
|
|
235
728
|
# checkout's `autonomous_logs/<branch>.remote.log`, and `status`,
|
|
236
729
|
# `pause_reason`, `usage_resume_at`, `park_loop_cycles`, `remote_run_id`,
|
|
237
|
-
# `remote_run_url`, `remote_detail` and `remote_synced_at` written
|
|
730
|
+
# `remote_run_url`, `remote_detail` and `remote_synced_at` written, and
|
|
731
|
+
# `engine` when the bundle names `task`, `user_review` or `docs`. A
|
|
238
732
|
# `mirror` restore places no planning draft
|
|
239
733
|
# 4. no artifact, while some bundle exists (`remote_run_id` is set, or an
|
|
240
734
|
# older finished run carries one): a job that died before its upload.
|
|
@@ -270,7 +764,7 @@
|
|
|
270
764
|
# is a wire: `pause` and `stop` send the branch as their `branch` input for
|
|
271
765
|
# exactly that reason.
|
|
272
766
|
#
|
|
273
|
-
# `stop` DOES
|
|
767
|
+
# `stop` DOES FOUR THINGS, IN THIS ORDER. (1) It ALWAYS dispatches action=stop
|
|
274
768
|
# on the branch — a jobless run titled `harness stop <branch>` that GitHub keeps
|
|
275
769
|
# as the stop marker `continue` and `poll` read. It is first because it is the
|
|
276
770
|
# only part that reaches a usage-paused run waiting on the resume poller, which
|
|
@@ -282,7 +776,12 @@
|
|
|
282
776
|
# after a failure. (3) Only when (1) and (2) all succeeded, and only when a
|
|
283
777
|
# local registry record exists, it writes `remote_stopped_at` and sets `status`
|
|
284
778
|
# to `failed` in one `hr_registry_set` call; a partial stop leaves the record alone
|
|
285
|
-
# and exits 3, so running `stop` again is the remedy.
|
|
779
|
+
# and exits 3, so running `stop` again is the remedy. (4) A complete stop is then
|
|
780
|
+
# reported as `report stopped` with the note `Stopped by @<actor>.` under
|
|
781
|
+
# `--actor` (a login, the trigger's shape plus an optional `[bot]`; anything else
|
|
782
|
+
# is a usage error), else one naming a local stop; a partial stop reports
|
|
783
|
+
# nothing. The cancelled job's own `failed` is then posted nowhere, because
|
|
784
|
+
# `report` finds the branch stopped.
|
|
286
785
|
#
|
|
287
786
|
# `warm` dispatches action=warm on GitHub's OWN default branch (`gh repo view
|
|
288
787
|
# --json defaultBranchRef`), which may differ from the configured
|
|
@@ -294,7 +793,28 @@
|
|
|
294
793
|
# root's `harness.config.json`.
|
|
295
794
|
#
|
|
296
795
|
# WHAT IT NEVER DOES. It never launches a local session, never writes the
|
|
297
|
-
# inbox,
|
|
796
|
+
# inbox, and never watches a run it sent beyond the bounded lookup of its
|
|
797
|
+
# listing that `trigger` and `review` make. Only `start` and `review` push, and
|
|
798
|
+
# only through `create-worktree.sh` and `push-branch.sh`; `start`'s writes are
|
|
799
|
+
# the prompt committed on `origin/<branch>`, through a working copy and a local
|
|
800
|
+
# branch it removes before it returns; `review`'s are the round committed on
|
|
801
|
+
# `origin/<branch>`, through the record's mirror or a copy and a local branch
|
|
802
|
+
# it removes, the record's `status` / `engine`, the bundle download
|
|
803
|
+
# directory `sync` uses, and `report`'s writes for the round. A user's chain-0 `dispatch --resume answer|pause`
|
|
804
|
+
# writes the record's `status`, `resumed_at` and `resume_kind` in one write
|
|
805
|
+
# when the main checkout's registry file exists and holds a record with
|
|
806
|
+
# `execution: github-actions`; any other `dispatch` writes nothing. `trigger` writes its snapshot and comment
|
|
807
|
+
# files under `RUNNER_TEMP`, one comment on the issue and the label removal, or
|
|
808
|
+
# for a dispatch event a block in the step summary. `report` writes its comment
|
|
809
|
+
# file under `RUNNER_TEMP` (removed), one comment and the state labels on the
|
|
810
|
+
# issue and the pull request, and nothing local. `deliver` writes its body and
|
|
811
|
+
# comment files under `RUNNER_TEMP` (removed), at most one pull request, one
|
|
812
|
+
# comment and the state labels. `control` writes its reply file and its
|
|
813
|
+
# `fetch` directory under `RUNNER_TEMP` (removed) and one reply comment, plus
|
|
814
|
+
# what the child verb it runs writes. `collect` writes its round file and its
|
|
815
|
+
# settledness directory under `RUNNER_TEMP` (removed), at most one comment on
|
|
816
|
+
# the pull request, plus what its `review` child writes. `stop`, `continue` and `poll` also make
|
|
817
|
+
# `report`'s writes for each event they report. Every other verb's only writes are the
|
|
298
818
|
# registry record (`stop`, `sync`) and, for `sync`, the download directory
|
|
299
819
|
# `<state_dir>/autonomous_logs/remote_download/<branch>/<id>/` and
|
|
300
820
|
# `<branch>.remote.log` in the main checkout, plus the mirror restore
|
|
@@ -304,7 +824,10 @@
|
|
|
304
824
|
# `park_loop_cycles` rewrite of `remote_status.json`, all in the job's
|
|
305
825
|
# checkout; for `save`, <out_dir> and the step summary; for `poll`, its
|
|
306
826
|
# download directories and `<state_dir>/autonomous_logs/poll_state/previous/`
|
|
307
|
-
# and `current/`. `
|
|
827
|
+
# and `current/`. For `fetch`, <out_dir> only. For `discard`, the removal of
|
|
828
|
+
# <dir> only. `pause-requested`,
|
|
829
|
+
# `run-created-at`, `list` and `status` write nothing; a no-record `status`
|
|
830
|
+
# downloads into a temporary directory it removes on exit.
|
|
308
831
|
#
|
|
309
832
|
# MIRRORS OF `cli/src/remote/githubActions.ts`, which owns these names; a
|
|
310
833
|
# rename there is an edit here, byte for byte:
|
|
@@ -313,6 +836,20 @@
|
|
|
313
836
|
# STATE_ARTIFACT_NAME mirrors STATE_ARTIFACT_NAME
|
|
314
837
|
# POLL_STATE_ARTIFACT_NAME mirrors POLL_STATE_ARTIFACT_NAME
|
|
315
838
|
# HARNESS_GH_CLI mirrors GH_CLI_VARIABLE (the binary run as `gh`)
|
|
839
|
+
# HARNESS_TRIGGER_LABEL mirrors TRIGGER_LABEL_VARIABLE
|
|
840
|
+
# DEFAULT_TRIGGER_LABEL mirrors DEFAULT_TRIGGER_LABEL
|
|
841
|
+
# LEGACY_TRIGGER_LABEL mirrors LEGACY_TRIGGER_LABEL
|
|
842
|
+
# HARNESS_TRIGGER_ALLOWED_BOTS mirrors TRIGGER_ALLOWED_BOTS_VARIABLE
|
|
843
|
+
# TRIGGER_DISPATCH_EVENT_TYPE mirrors TRIGGER_DISPATCH_EVENT_TYPE ('harness-task')
|
|
844
|
+
# WORKFLOW_CONTROL_FILE mirrors WORKFLOW_CONTROL_FILE
|
|
845
|
+
# COMMAND_HANDLE mirrors COMMAND_HANDLE
|
|
846
|
+
# COMMAND_VERBS mirrors COMMAND_VERBS, space-separated
|
|
847
|
+
# COMMENT_MARKER mirrors COMMENT_MARKER
|
|
848
|
+
# REVIEW_ROUND_STATE mirrors REVIEW_ROUND_STATE
|
|
849
|
+
# STATE_LABEL_PREFIX mirrors STATE_LABEL_PREFIX
|
|
850
|
+
# RUN_STATES mirrors RUN_STATES, space-separated, same order
|
|
851
|
+
# PR_CREATE_SETTING mirrors PR_CREATE_SETTING
|
|
852
|
+
# PR_CREATE_SETTING_PATH mirrors PR_CREATE_SETTING_PATH
|
|
316
853
|
#
|
|
317
854
|
# `set -u` WITHOUT `-e`: every refusal is reported with its own exit code rather
|
|
318
855
|
# than aborting mid-decision.
|
|
@@ -345,6 +882,50 @@
|
|
|
345
882
|
# bash scripts/remote-run.sh pause feat_x -> 3, names `boom`
|
|
346
883
|
# gh absent HARNESS_GH_CLI=/nonexistent bash scripts/remote-run.sh warm -> 3
|
|
347
884
|
#
|
|
885
|
+
# start needs the adopted tree on origin's default branch (commit and push
|
|
886
|
+
# it first) and a prompt file outside the checkout, say /tmp/p.md:
|
|
887
|
+
# start bash scripts/remote-run.sh start feat_x --prompt-file /tmp/p.md
|
|
888
|
+
# -> 0; origin/feat_x gains `chore: add task prompt for feat_x`,
|
|
889
|
+
# then "$s.log" gains the same `workflow run` line as dispatch
|
|
890
|
+
# protected bash scripts/remote-run.sh start main --prompt-file /tmp/p.md
|
|
891
|
+
# -> 2, log unchanged, nothing pushed
|
|
892
|
+
# no prompt bash scripts/remote-run.sh start feat_y --prompt-file /nonexistent
|
|
893
|
+
# -> 2, log unchanged, nothing pushed
|
|
894
|
+
# review after that start, a `run list` answer whose newest `harness run
|
|
895
|
+
# feat_x` run is `completed` with a bundle saying `completed`:
|
|
896
|
+
# bash scripts/remote-run.sh review feat_x --review-file /tmp/r.md
|
|
897
|
+
# -> 0; origin/feat_x gains `chore: add user review for feat_x`
|
|
898
|
+
# placing feat_x_review.md, then one `-f engine=user_review`
|
|
899
|
+
# dispatch; no copy or local feat_x is left; the stub lists no
|
|
900
|
+
# run of the pushed `headSha`, so one `::warning::` line follows
|
|
901
|
+
# (export HARNESS_TRIGGER_LOOKUP_SECS=0 to skip the waits)
|
|
902
|
+
# in flight the newest run `in_progress`, its jobs listing no completed
|
|
903
|
+
# `run` job -> 2, nothing pushed or sent
|
|
904
|
+
# no run no `harness run feat_x` run listed -> 2, nothing pushed; with
|
|
905
|
+
# --allow-no-run -> 0, placed and dispatched as `review` above
|
|
906
|
+
# reported report's setup, then review ... --actor alice --source
|
|
907
|
+
# https://github.com/o/r/pull/12#pullrequestreview-1 -> 0; one
|
|
908
|
+
# comment on 7 naming `Round <n>`, the source and `@alice`, then
|
|
909
|
+
# `sdlc-harness: running` on 7
|
|
910
|
+
# fetch t=$(mktemp -d); bash scripts/remote-run.sh fetch feat_x "$t"
|
|
911
|
+
# -> 0; prints `state: running` (or, with no run listed,
|
|
912
|
+
# `state: none`), every other key present
|
|
913
|
+
# discard mkdir -p sdlc-harness/scratch/branch-pause-feat_x, then
|
|
914
|
+
# bash scripts/remote-run.sh discard sdlc-harness/scratch/branch-pause-feat_x
|
|
915
|
+
# -> 0, the directory gone; discard sdlc-harness/autonomous_logs
|
|
916
|
+
# -> 2, nothing removed; no gh call either way
|
|
917
|
+
#
|
|
918
|
+
# no record with no registry, the "a bundle" stub below with question_1.md:
|
|
919
|
+
# bash scripts/remote-run.sh status feat_x -> 0; prints `state:
|
|
920
|
+
# parked` and `open question question_1.md`; nothing written
|
|
921
|
+
# list start's setup, a feat_x pushed to origin, no registry, and a
|
|
922
|
+
# `run list` answer carrying `headBranch` feat_x, `displayTitle`
|
|
923
|
+
# `harness run feat_x` and a `url`: bash scripts/remote-run.sh
|
|
924
|
+
# list -> 0; prints `on GitHub, no local record: feat_x <url>`;
|
|
925
|
+
# no registry is created
|
|
926
|
+
# adopt bash scripts/remote-run.sh adopt -> 1, an unknown verb; no gh
|
|
927
|
+
# call, no registry
|
|
928
|
+
#
|
|
348
929
|
# status and sync need a remote record, and a `run list` answer whose runs
|
|
349
930
|
# carry `displayTitle` `harness run feat_x` and a `url`:
|
|
350
931
|
# r=sdlc-harness/autonomous_logs/registry.json; mkdir -p "${r%/*}"
|
|
@@ -380,6 +961,13 @@
|
|
|
380
961
|
# first job a `run list` answer with no finished run: --resume none -> 0;
|
|
381
962
|
# --resume answer -> 2
|
|
382
963
|
# own run GITHUB_RUN_ID=<the bundle run's id> -> that run is skipped
|
|
964
|
+
# lineage the checkout on feat_x one commit beyond origin/main, and a `run
|
|
965
|
+
# list` answer holding only an older completed run whose `headSha`
|
|
966
|
+
# is another commit: --resume none -> 0, prints `skipped 1 finished
|
|
967
|
+
# run(s) of feat_x from before its current lineage` and `this is
|
|
968
|
+
# its first job`, no `run download`
|
|
969
|
+
# own lineage that same branch plus a newer completed run whose `headSha`
|
|
970
|
+
# is that commit -> that run's bundle is restored
|
|
383
971
|
# expired the artifact list answering {"artifacts":[{"name":"harness-state",
|
|
384
972
|
# "expired":true,"expires_at":"2026-01-02T00:00:00Z"}]}: --resume
|
|
385
973
|
# pause -> 0, a `::warning::` line, no `run download`, no older
|
|
@@ -432,6 +1020,68 @@
|
|
|
432
1020
|
# run-created-at a stub answering `run view 42 --json createdAt` with
|
|
433
1021
|
# {"createdAt":"2026-01-01T00:00:10Z"}: bash scripts/remote-run.sh
|
|
434
1022
|
# run-created-at 42 -> prints 1767225610, 0; a failing stub -> 3
|
|
1023
|
+
#
|
|
1024
|
+
# trigger needs start's setup plus `"forge": "github"`, an event file e.json
|
|
1025
|
+
# {"action":"labeled","label":{"name":"sdlc-harness"},"sender":{"login":"alice",
|
|
1026
|
+
# "type":"User"},"issue":{"number":7,"title":"Add comments","body":"x",
|
|
1027
|
+
# "html_url":"https://github.com/o/r/issues/7","state":"open"}}, and a stub
|
|
1028
|
+
# answering `api repos/o/r/collaborators/alice/permission` with
|
|
1029
|
+
# {"permission":"write"}; export GITHUB_EVENT_NAME=issues GITHUB_EVENT_PATH=e.json
|
|
1030
|
+
# GITHUB_REPOSITORY=o/r HARNESS_TRIGGER_LOOKUP_SECS=0:
|
|
1031
|
+
# trigger bash scripts/remote-run.sh trigger -> 0; origin/add_comments gains
|
|
1032
|
+
# the prompt commit, "$s.log" gains `workflow run harness-run.yml
|
|
1033
|
+
# --ref add_comments ...`, `issue comment 7 ...` naming the branch,
|
|
1034
|
+
# then `issue edit 7 ... --remove-label sdlc-harness`
|
|
1035
|
+
# read the permission answer {"permission":"read"} -> 2, no `workflow
|
|
1036
|
+
# run`, one comment naming write access, the label removed
|
|
1037
|
+
# ignored e.json's label name `bug` -> 0, one line, "$s.log" unchanged
|
|
1038
|
+
# dispatch GITHUB_EVENT_NAME=repository_dispatch GITHUB_RUN_ID=9
|
|
1039
|
+
# GITHUB_STEP_SUMMARY=/tmp/s, e.json {"action":<TRIGGER_DISPATCH_EVENT_TYPE>,
|
|
1040
|
+
# "client_payload":{"title":"Add tags","body":"x","source":"jira"}}
|
|
1041
|
+
# -> 0; `workflow run ... --ref add_tags ...`, /tmp/s names
|
|
1042
|
+
# add_tags, no `issue` call; without "title" -> 2, no `workflow run`
|
|
1043
|
+
#
|
|
1044
|
+
# report needs trigger's setup, a branch feat_x pushed carrying its task
|
|
1045
|
+
# prompt (ending in a `Started from https://github.com/o/r/issues/7 by @alice`
|
|
1046
|
+
# line) and `flow_progress/feat_x_progress.md`, and a stub answering `pr
|
|
1047
|
+
# list` and `api repos/o/r/issues/7/labels` with []; GITHUB_REPOSITORY=o/r:
|
|
1048
|
+
# paused bash scripts/remote-run.sh report paused feat_x -> 0; "$s.log"
|
|
1049
|
+
# gains `api --method POST repos/o/r/issues/7/comments -F body=@…`
|
|
1050
|
+
# naming `@sdlc-harness resume`, then `api --method POST
|
|
1051
|
+
# repos/o/r/issues/7/labels -f labels[]=sdlc-harness: paused`
|
|
1052
|
+
# forge off `"forge": "none"`, then the same -> 0, one line, "$s.log" unchanged
|
|
1053
|
+
#
|
|
1054
|
+
# deliver needs report's setup, a stub answering the create with
|
|
1055
|
+
# https://github.com/o/r/pull/12, and <b> a bundle directory whose status.json
|
|
1056
|
+
# carries schema "1" and status completed:
|
|
1057
|
+
# deliver bash scripts/remote-run.sh deliver feat_x <b> -> 0; "$s.log" gains
|
|
1058
|
+
# a `--draft` create with `--base main --head feat_x`, then one
|
|
1059
|
+
# comment on issue 7 naming /pull/12, then `sdlc-harness: done` on
|
|
1060
|
+
# 7 and 12
|
|
1061
|
+
# not done status parked, or an empty <b> -> 0, one line, "$s.log" unchanged
|
|
1062
|
+
#
|
|
1063
|
+
# collect needs deliver's setup, a story index on feat_x, a stub answering
|
|
1064
|
+
# `pr list` with [{"number":12,"isCrossRepository":false}], `run list` with a
|
|
1065
|
+
# `completed` `harness run feat_x` run whose bundle says `completed`, `api
|
|
1066
|
+
# --paginate repos/o/r/pulls/12/reviews` with one `CHANGES_REQUESTED` review
|
|
1067
|
+
# by alice, and the permission call with {"permission":"write"}:
|
|
1068
|
+
# collect bash scripts/remote-run.sh collect feat_x -> 0; origin/feat_x
|
|
1069
|
+
# gains `chore: add user review for feat_x`, then one `-f
|
|
1070
|
+
# engine=user_review` dispatch; with no review listed -> 0, one
|
|
1071
|
+
# line, nothing pushed or dispatched
|
|
1072
|
+
#
|
|
1073
|
+
# control needs report's setup, a stub answering `pr view 12 ...` with
|
|
1074
|
+
# {"headRefName":"feat_x","isCrossRepository":false,"state":"OPEN"}, the
|
|
1075
|
+
# permission call with {"permission":"write"}, and `run list` with an
|
|
1076
|
+
# `in_progress` `harness run feat_x` run; an event file c.json
|
|
1077
|
+
# {"action":"created","comment":{"body":"@sdlc-harness pause"},"issue":{"number":12,
|
|
1078
|
+
# "pull_request":{"url":"x"}},"sender":{"login":"alice","type":"User"}};
|
|
1079
|
+
# export GITHUB_EVENT_NAME=issue_comment GITHUB_EVENT_PATH=c.json GITHUB_REPOSITORY=o/r:
|
|
1080
|
+
# pause bash scripts/remote-run.sh control -> 0; "$s.log" gains `workflow
|
|
1081
|
+
# run harness-run.yml --ref feat_x -f action=pause -f branch=feat_x`,
|
|
1082
|
+
# then one comment on 12 naming @alice
|
|
1083
|
+
# ignored c.json's body `Let's @sdlc-harness pause` -> 0, one line,
|
|
1084
|
+
# "$s.log" unchanged
|
|
435
1085
|
|
|
436
1086
|
set -u
|
|
437
1087
|
|
|
@@ -448,6 +1098,23 @@ WORKFLOW_RUN_FILE='harness-run.yml'
|
|
|
448
1098
|
WORKFLOW_RESUME_FILE='harness-resume.yml'
|
|
449
1099
|
STATE_ARTIFACT_NAME='harness-state'
|
|
450
1100
|
POLL_STATE_ARTIFACT_NAME='harness-poll-state'
|
|
1101
|
+
DEFAULT_TRIGGER_LABEL='sdlc-harness'
|
|
1102
|
+
LEGACY_TRIGGER_LABEL='harness'
|
|
1103
|
+
TRIGGER_DISPATCH_EVENT_TYPE='harness-task'
|
|
1104
|
+
WORKFLOW_CONTROL_FILE='harness-control.yml'
|
|
1105
|
+
COMMAND_HANDLE='@sdlc-harness'
|
|
1106
|
+
COMMAND_VERBS='answer pause resume stop clear'
|
|
1107
|
+
COMMENT_MARKER='<!-- sdlc-harness'
|
|
1108
|
+
REVIEW_ROUND_STATE='changes_requested'
|
|
1109
|
+
STATE_LABEL_PREFIX='sdlc-harness: '
|
|
1110
|
+
RUN_STATES='running parked paused done failed stopped'
|
|
1111
|
+
PR_CREATE_SETTING='Allow GitHub Actions to create and approve pull requests'
|
|
1112
|
+
PR_CREATE_SETTING_PATH='Settings -> Actions -> General -> Workflow permissions'
|
|
1113
|
+
# The most bytes of a question file one park comment carries. An issue comment
|
|
1114
|
+
# holds 262,144 bytes of UTF-8, and the refusal text's character count is not
|
|
1115
|
+
# to be trusted (docs/github-integration-research.md -> S6); the margin is the
|
|
1116
|
+
# framing lines and the marker.
|
|
1117
|
+
QUESTION_COMMENT_MAX_BYTES=250000
|
|
451
1118
|
GH="${HARNESS_GH_CLI:-gh}"
|
|
452
1119
|
|
|
453
1120
|
# How many runs `status` prints, and how many `run list` returns for status
|
|
@@ -468,6 +1135,17 @@ POLL_GIVE_UP_AFTER_MINUTES_DEFAULT=360
|
|
|
468
1135
|
# How many of the poller's own runs `poll` searches for the previous tick's
|
|
469
1136
|
# state artifact; each one without it costs an artifact lookup.
|
|
470
1137
|
POLL_STATE_RUNS_LIMIT=10
|
|
1138
|
+
# `trigger`'s bound on looking up the run its `start` dispatched; the wait
|
|
1139
|
+
# between tries is `HARNESS_TRIGGER_LOOKUP_SECS`, a test seam.
|
|
1140
|
+
TRIGGER_RUN_LOOKUP_TRIES=6
|
|
1141
|
+
TRIGGER_LOOKUP_SECS_DEFAULT=5
|
|
1142
|
+
# The `run` job's name in WORKFLOW_RUN_FILE: its key, since it has no `name:`.
|
|
1143
|
+
# `branch_settled_var` reads that job's status; harness-run.yml's header
|
|
1144
|
+
# declares the mirror.
|
|
1145
|
+
RUN_JOB_NAME='run'
|
|
1146
|
+
# How far before the previous round's `collected_at` `round_collect` lists
|
|
1147
|
+
# from, absorbing runner-clock skew; the id check drops what it re-lists.
|
|
1148
|
+
ROUND_OVERLAP_SECS=300
|
|
471
1149
|
|
|
472
1150
|
# GitHub's documented limit on a `workflow_dispatch` inputs payload: "The
|
|
473
1151
|
# maximum payload for inputs is 65,535 characters."
|
|
@@ -478,6 +1156,7 @@ EXIT_OK=0
|
|
|
478
1156
|
EXIT_USAGE=1
|
|
479
1157
|
EXIT_REFUSED=2
|
|
480
1158
|
EXIT_GH=3
|
|
1159
|
+
EXIT_PLACEMENT=4
|
|
481
1160
|
# pause-requested only: the read succeeded and found no pause.
|
|
482
1161
|
EXIT_NO_PAUSE=1
|
|
483
1162
|
|
|
@@ -486,15 +1165,25 @@ usage() {
|
|
|
486
1165
|
echo "usage: remote-run.sh dispatch <branch> --engine <task|user_review|docs> [--resume none|answer|pause] [--answers-from <clar_dir> --indexes \"<n> ...\"] [--park-loop-clear] [--chain <n>] [--repo <root>]" >&2
|
|
487
1166
|
echo " remote-run.sh pause <branch> [--repo <root>]" >&2
|
|
488
1167
|
echo " remote-run.sh warm [--repo <root>]" >&2
|
|
489
|
-
echo " remote-run.sh stop <branch> [--repo <root>]" >&2
|
|
1168
|
+
echo " remote-run.sh stop <branch> [--actor <login>] [--repo <root>]" >&2
|
|
490
1169
|
echo " remote-run.sh status <branch> [--repo <root>]" >&2
|
|
491
1170
|
echo " remote-run.sh sync <branch> [--repo <root>]" >&2
|
|
1171
|
+
echo " remote-run.sh fetch <branch> <out_dir> [--repo <root>]" >&2
|
|
492
1172
|
echo " remote-run.sh restore <branch> --resume none|answer|pause [--repo <root>]" >&2
|
|
493
1173
|
echo " remote-run.sh save <branch> <out_dir> [--repo <root>]" >&2
|
|
494
1174
|
echo " remote-run.sh continue <branch> <bundle_dir> [--repo <root>]" >&2
|
|
495
1175
|
echo " remote-run.sh poll [--repo <root>]" >&2
|
|
496
1176
|
echo " remote-run.sh pause-requested <branch> <since_epoch> [--repo <root>]" >&2
|
|
497
1177
|
echo " remote-run.sh run-created-at <run_id> [--repo <root>]" >&2
|
|
1178
|
+
echo " remote-run.sh start <branch> --prompt-file <file> [--repo <root>]" >&2
|
|
1179
|
+
echo " remote-run.sh review <branch> --review-file <file> [--allow-no-run] [--actor <login>] [--reviewers <login,login,...>] [--source <https-url>] [--repo <root>]" >&2
|
|
1180
|
+
echo " remote-run.sh trigger [--repo <root>]" >&2
|
|
1181
|
+
echo " remote-run.sh list [--repo <root>]" >&2
|
|
1182
|
+
echo " remote-run.sh discard <dir> [--repo <root>]" >&2
|
|
1183
|
+
echo " remote-run.sh report <event> <branch> [--note <text>] [--repo <root>]" >&2
|
|
1184
|
+
echo " remote-run.sh deliver <branch> <bundle_dir> [--repo <root>]" >&2
|
|
1185
|
+
echo " remote-run.sh collect <branch> [--pr <n>] [--repo <root>]" >&2
|
|
1186
|
+
echo " remote-run.sh control [--repo <root>]" >&2
|
|
498
1187
|
[ "${verb-}" != save ] || exit "$EXIT_OK"
|
|
499
1188
|
exit "$EXIT_USAGE"
|
|
500
1189
|
}
|
|
@@ -505,7 +1194,22 @@ usage() {
|
|
|
505
1194
|
GH_OUT=""
|
|
506
1195
|
GH_ERR=""
|
|
507
1196
|
gh_call() {
|
|
508
|
-
|
|
1197
|
+
gh_run 0 "" "$@"
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
# gh_call_token <token> <args...> — gh_call with GH_TOKEN set to <token> for
|
|
1201
|
+
# that one gh process only; every other call keeps the environment's token.
|
|
1202
|
+
gh_call_token() {
|
|
1203
|
+
local token="$1"
|
|
1204
|
+
shift
|
|
1205
|
+
gh_run 1 "$token" "$@"
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
# gh_run <0|1> <token> <args...> — gh_call's body; with 1, GH_TOKEN is a prefix
|
|
1209
|
+
# assignment on the external command, never on a function, so it cannot leak.
|
|
1210
|
+
gh_run() {
|
|
1211
|
+
local with_token="$1" token="$2" errfile status
|
|
1212
|
+
shift 2
|
|
509
1213
|
GH_OUT=""
|
|
510
1214
|
GH_ERR=""
|
|
511
1215
|
if ! command -v "$GH" >/dev/null 2>&1; then
|
|
@@ -513,7 +1217,11 @@ gh_call() {
|
|
|
513
1217
|
return 127
|
|
514
1218
|
fi
|
|
515
1219
|
errfile=$(mktemp) || { GH_ERR="mktemp failed"; return 1; }
|
|
516
|
-
|
|
1220
|
+
if [ "$with_token" = 1 ]; then
|
|
1221
|
+
GH_OUT=$(GH_TOKEN="$token" "$GH" "$@" 2>"$errfile")
|
|
1222
|
+
else
|
|
1223
|
+
GH_OUT=$("$GH" "$@" 2>"$errfile")
|
|
1224
|
+
fi
|
|
517
1225
|
status=$?
|
|
518
1226
|
if [ "$status" -ne 0 ]; then
|
|
519
1227
|
IFS= read -r GH_ERR <"$errfile" || :
|
|
@@ -545,7 +1253,7 @@ verb=""
|
|
|
545
1253
|
verb="$1"
|
|
546
1254
|
shift
|
|
547
1255
|
case "$verb" in
|
|
548
|
-
dispatch|pause|warm|stop|status|sync|restore|save|continue|poll|pause-requested|run-created-at) ;;
|
|
1256
|
+
dispatch|pause|warm|stop|status|sync|fetch|restore|save|continue|poll|pause-requested|run-created-at|start|review|trigger|list|discard|report|deliver|collect|control) ;;
|
|
549
1257
|
*) usage "unknown verb '$verb'" ;;
|
|
550
1258
|
esac
|
|
551
1259
|
|
|
@@ -563,6 +1271,17 @@ chain="0"
|
|
|
563
1271
|
repo_arg=""
|
|
564
1272
|
since_arg=""
|
|
565
1273
|
run_id_arg=""
|
|
1274
|
+
prompt_file=""
|
|
1275
|
+
review_file=""
|
|
1276
|
+
discard_dir=""
|
|
1277
|
+
discard_base=""
|
|
1278
|
+
report_event=""
|
|
1279
|
+
report_note=""
|
|
1280
|
+
actor_arg=""
|
|
1281
|
+
source_arg=""
|
|
1282
|
+
reviewers_arg=""
|
|
1283
|
+
allow_no_run=0
|
|
1284
|
+
pr_arg=""
|
|
566
1285
|
|
|
567
1286
|
while [ "$#" -gt 0 ]; do
|
|
568
1287
|
case "$1" in
|
|
@@ -586,20 +1305,60 @@ while [ "$#" -gt 0 ]; do
|
|
|
586
1305
|
--park-loop-clear)
|
|
587
1306
|
[ "$verb" = dispatch ] || usage "$1 is a dispatch option"
|
|
588
1307
|
park_loop_clear=1; shift ;;
|
|
1308
|
+
--prompt-file)
|
|
1309
|
+
[ "$verb" = start ] || usage "$1 is a start option"
|
|
1310
|
+
[ "$#" -ge 2 ] && [ -n "$2" ] || usage "$1 needs a value"
|
|
1311
|
+
prompt_file="$2"; shift 2 ;;
|
|
1312
|
+
--review-file)
|
|
1313
|
+
[ "$verb" = review ] || usage "$1 is a review option"
|
|
1314
|
+
[ "$#" -ge 2 ] && [ -n "$2" ] || usage "$1 needs a value"
|
|
1315
|
+
review_file="$2"; shift 2 ;;
|
|
1316
|
+
--note)
|
|
1317
|
+
[ "$verb" = report ] || usage "$1 is a report option"
|
|
1318
|
+
[ "$#" -ge 2 ] || usage "$1 needs a value"
|
|
1319
|
+
report_note="$2"; shift 2 ;;
|
|
1320
|
+
--actor)
|
|
1321
|
+
[ "$verb" = stop ] || [ "$verb" = review ] || usage "$1 is a stop or review option"
|
|
1322
|
+
[ "$#" -ge 2 ] && [ -n "$2" ] || usage "$1 needs a value"
|
|
1323
|
+
actor_arg="$2"; shift 2 ;;
|
|
1324
|
+
--source)
|
|
1325
|
+
[ "$verb" = review ] || usage "$1 is a review option"
|
|
1326
|
+
[ "$#" -ge 2 ] && [ -n "$2" ] || usage "$1 needs a value"
|
|
1327
|
+
source_arg="$2"; shift 2 ;;
|
|
1328
|
+
--reviewers)
|
|
1329
|
+
[ "$verb" = review ] || usage "$1 is a review option"
|
|
1330
|
+
[ "$#" -ge 2 ] && [ -n "$2" ] || usage "$1 needs a value"
|
|
1331
|
+
reviewers_arg="$2"; shift 2 ;;
|
|
1332
|
+
--allow-no-run)
|
|
1333
|
+
[ "$verb" = review ] || usage "$1 is a review option"
|
|
1334
|
+
allow_no_run=1; shift ;;
|
|
1335
|
+
--pr)
|
|
1336
|
+
[ "$verb" = collect ] || usage "$1 is a collect option"
|
|
1337
|
+
[ "$#" -ge 2 ] || usage "$1 needs a value"
|
|
1338
|
+
case "$2" in
|
|
1339
|
+
''|*[!0-9]*|0*) usage "--pr needs a positive pull request number" ;;
|
|
1340
|
+
esac
|
|
1341
|
+
pr_arg="$2"; shift 2 ;;
|
|
589
1342
|
-*)
|
|
590
1343
|
usage "unknown option '$1'" ;;
|
|
591
1344
|
*)
|
|
592
|
-
[ "$verb" != warm ] && [ "$verb" != poll ]
|
|
1345
|
+
[ "$verb" != warm ] && [ "$verb" != poll ] && [ "$verb" != trigger ] \
|
|
1346
|
+
&& [ "$verb" != list ] && [ "$verb" != control ] || usage "$verb takes no branch"
|
|
593
1347
|
if [ "$verb" = run-created-at ]; then
|
|
594
1348
|
[ -z "$run_id_arg" ] || usage "unexpected argument '$1'"
|
|
595
1349
|
run_id_arg="$1"
|
|
1350
|
+
elif [ "$verb" = discard ]; then
|
|
1351
|
+
[ -z "$discard_dir" ] || usage "unexpected argument '$1'"
|
|
1352
|
+
discard_dir="$1"
|
|
1353
|
+
elif [ "$verb" = report ] && [ -z "$report_event" ]; then
|
|
1354
|
+
report_event="$1"
|
|
596
1355
|
elif [ -z "$branch" ]; then
|
|
597
1356
|
branch="$1"
|
|
598
1357
|
elif [ "$verb" = pause-requested ] && [ -z "$since_arg" ]; then
|
|
599
1358
|
since_arg="$1"
|
|
600
|
-
elif [ "$verb" = save ] && [ -z "$out_dir" ]; then
|
|
1359
|
+
elif { [ "$verb" = save ] || [ "$verb" = fetch ]; } && [ -z "$out_dir" ]; then
|
|
601
1360
|
out_dir="$1"
|
|
602
|
-
elif [ "$verb" = continue ] && [ -z "$bundle_dir" ]; then
|
|
1361
|
+
elif { [ "$verb" = continue ] || [ "$verb" = deliver ]; } && [ -z "$bundle_dir" ]; then
|
|
603
1362
|
bundle_dir="$1"
|
|
604
1363
|
else
|
|
605
1364
|
usage "unexpected argument '$1'"
|
|
@@ -608,10 +1367,39 @@ while [ "$#" -gt 0 ]; do
|
|
|
608
1367
|
esac
|
|
609
1368
|
done
|
|
610
1369
|
|
|
611
|
-
if [ "$verb" != warm ] && [ "$verb" != poll ] && [ "$verb" != run-created-at ]
|
|
1370
|
+
if [ "$verb" != warm ] && [ "$verb" != poll ] && [ "$verb" != run-created-at ] && [ "$verb" != trigger ] \
|
|
1371
|
+
&& [ "$verb" != list ] && [ "$verb" != discard ] && [ "$verb" != control ]; then
|
|
612
1372
|
valid_branch "$branch" || usage "$verb needs a <branch>"
|
|
613
1373
|
fi
|
|
614
1374
|
|
|
1375
|
+
if [ "$verb" = discard ] && [ -z "$discard_dir" ]; then
|
|
1376
|
+
usage "discard needs a <dir>"
|
|
1377
|
+
fi
|
|
1378
|
+
|
|
1379
|
+
# The event lands in the comment marker, so it is a word.
|
|
1380
|
+
if [ "$verb" = report ] && ! [[ "$report_event" =~ ^[a-z][a-z_]*$ ]]; then
|
|
1381
|
+
usage "report needs an <event> of lowercase letters and underscores"
|
|
1382
|
+
fi
|
|
1383
|
+
|
|
1384
|
+
# The actor lands in the stop or round comment, so it is a login: the trigger's shape.
|
|
1385
|
+
if [ -n "$actor_arg" ] && ! [[ "$actor_arg" =~ ^[A-Za-z0-9][A-Za-z0-9-]*(\[bot\])?$ ]]; then
|
|
1386
|
+
usage "--actor needs a GitHub login"
|
|
1387
|
+
fi
|
|
1388
|
+
|
|
1389
|
+
# The reviewers land in the round comment, so each is a login.
|
|
1390
|
+
if [ -n "$reviewers_arg" ]; then
|
|
1391
|
+
[[ ",$reviewers_arg," =~ ^(,[A-Za-z0-9][A-Za-z0-9-]*(\[bot\])?)+,$ ]] \
|
|
1392
|
+
|| usage "--reviewers needs comma-separated GitHub logins"
|
|
1393
|
+
fi
|
|
1394
|
+
|
|
1395
|
+
# The source lands in the round comment as a link.
|
|
1396
|
+
if [ -n "$source_arg" ]; then
|
|
1397
|
+
case "$source_arg" in
|
|
1398
|
+
https://*) ;;
|
|
1399
|
+
*) usage "--source needs an https:// URL" ;;
|
|
1400
|
+
esac
|
|
1401
|
+
fi
|
|
1402
|
+
|
|
615
1403
|
if [ "$verb" = pause-requested ]; then
|
|
616
1404
|
case "$since_arg" in
|
|
617
1405
|
''|*[!0-9]*) usage "pause-requested needs a <since_epoch> that is a non-negative integer" ;;
|
|
@@ -626,14 +1414,26 @@ if [ "$verb" = run-created-at ]; then
|
|
|
626
1414
|
esac
|
|
627
1415
|
fi
|
|
628
1416
|
|
|
629
|
-
if [ "$verb" = continue ] && [ -z "$bundle_dir" ]; then
|
|
630
|
-
usage "
|
|
1417
|
+
if { [ "$verb" = continue ] || [ "$verb" = deliver ]; } && [ -z "$bundle_dir" ]; then
|
|
1418
|
+
usage "$verb needs a <bundle_dir>"
|
|
631
1419
|
fi
|
|
632
1420
|
|
|
633
1421
|
if [ "$verb" = save ] && [ -z "$out_dir" ]; then
|
|
634
1422
|
usage "save needs an <out_dir>"
|
|
635
1423
|
fi
|
|
636
1424
|
|
|
1425
|
+
if [ "$verb" = fetch ] && [ -z "$out_dir" ]; then
|
|
1426
|
+
usage "fetch needs an <out_dir>"
|
|
1427
|
+
fi
|
|
1428
|
+
|
|
1429
|
+
if [ "$verb" = review ] && [ -z "$review_file" ]; then
|
|
1430
|
+
usage "review needs --review-file"
|
|
1431
|
+
fi
|
|
1432
|
+
|
|
1433
|
+
if [ "$verb" = start ] && [ -z "$prompt_file" ]; then
|
|
1434
|
+
usage "start needs --prompt-file"
|
|
1435
|
+
fi
|
|
1436
|
+
|
|
637
1437
|
if [ "$verb" = restore ]; then
|
|
638
1438
|
[ "$resume_given" -eq 1 ] || usage "restore needs --resume"
|
|
639
1439
|
case "$resume" in
|
|
@@ -673,17 +1473,20 @@ fi
|
|
|
673
1473
|
# The repository and its configuration.
|
|
674
1474
|
# ---------------------------------------------------------------------------
|
|
675
1475
|
|
|
676
|
-
# setup_fail <message> — a configuration problem: exit 1, except for save
|
|
1476
|
+
# setup_fail <message> — a configuration problem: exit 1, except for save,
|
|
1477
|
+
# report, deliver and collect, which never fail the step that calls them.
|
|
677
1478
|
setup_fail() {
|
|
678
1479
|
echo "remote-run.sh: $1" >&2
|
|
679
|
-
[ "$verb" != save ] || exit "$EXIT_OK"
|
|
1480
|
+
[ "$verb" != save ] && [ "$verb" != report ] && [ "$verb" != deliver ] && [ "$verb" != collect ] || exit "$EXIT_OK"
|
|
680
1481
|
exit "$EXIT_USAGE"
|
|
681
1482
|
}
|
|
682
1483
|
|
|
683
1484
|
if [ -n "$repo_arg" ]; then
|
|
684
1485
|
root=$(hr_repo_root "$repo_arg") || setup_fail "'$repo_arg' is not a git repository"
|
|
685
1486
|
elif [ "$verb" = restore ] || [ "$verb" = save ] || [ "$verb" = continue ] || [ "$verb" = poll ] \
|
|
686
|
-
|| [ "$verb" = pause-requested ] || [ "$verb" = run-created-at ]
|
|
1487
|
+
|| [ "$verb" = pause-requested ] || [ "$verb" = run-created-at ] || [ "$verb" = trigger ] \
|
|
1488
|
+
|| [ "$verb" = discard ] || [ "$verb" = report ] || [ "$verb" = deliver ] || [ "$verb" = collect ] \
|
|
1489
|
+
|| [ "$verb" = control ]; then
|
|
687
1490
|
root=$(hr_repo_root "${PWD-.}") || setup_fail "'${PWD-.}' is not inside a git repository"
|
|
688
1491
|
else
|
|
689
1492
|
root=$(hr_main_repo "${PWD-.}") || setup_fail "'${PWD-.}' is not inside a git repository"
|
|
@@ -691,13 +1494,20 @@ fi
|
|
|
691
1494
|
|
|
692
1495
|
hr_config_load "$root" || :
|
|
693
1496
|
registry=""
|
|
1497
|
+
status_no_record=0
|
|
694
1498
|
case "$verb" in
|
|
695
|
-
restore|save|continue|poll)
|
|
1499
|
+
restore|save|continue|poll|discard)
|
|
696
1500
|
hr_state_path "$root" >/dev/null || setup_fail "cannot resolve '$root/harness.config.json'"
|
|
697
1501
|
;;
|
|
698
1502
|
pause-requested|run-created-at)
|
|
699
1503
|
# Read verbs: no gate, and nothing of the configuration is read.
|
|
700
1504
|
;;
|
|
1505
|
+
trigger|control)
|
|
1506
|
+
# Gates itself, after reading the event, so a refusal can still be commented.
|
|
1507
|
+
;;
|
|
1508
|
+
report|deliver|collect)
|
|
1509
|
+
# Gates itself (`forge_on`) and exits 0 on every outcome.
|
|
1510
|
+
;;
|
|
701
1511
|
status|sync)
|
|
702
1512
|
registry=$(hr_state_path "$root" autonomous_logs/registry.json) || {
|
|
703
1513
|
echo "remote-run.sh: cannot resolve '$root/harness.config.json'" >&2
|
|
@@ -705,7 +1515,18 @@ case "$verb" in
|
|
|
705
1515
|
}
|
|
706
1516
|
# Tested with -f first: hr_registry_get creates an absent registry, and
|
|
707
1517
|
# status writes nothing.
|
|
708
|
-
if [ ! -f "$registry" ] || [ "$(hr_registry_get "$registry" "$branch"
|
|
1518
|
+
if [ "$verb" = status ] && { [ ! -f "$registry" ] || [ -z "$(hr_registry_get "$registry" "$branch" branch)" ]; }; then
|
|
1519
|
+
# No record: GitHub alone answers, behind the sending verbs' gate.
|
|
1520
|
+
status_no_record=1
|
|
1521
|
+
target=$(hr_execution_target "$root") || {
|
|
1522
|
+
echo "remote-run.sh: cannot resolve '$root/harness.config.json' (or execution.target is outside its enum)" >&2
|
|
1523
|
+
exit "$EXIT_USAGE"
|
|
1524
|
+
}
|
|
1525
|
+
if [ "$target" != github-actions ]; then
|
|
1526
|
+
echo "remote-run.sh: refused, nothing sent: execution.target is '$target', not github-actions" >&2
|
|
1527
|
+
exit "$EXIT_REFUSED"
|
|
1528
|
+
fi
|
|
1529
|
+
elif [ ! -f "$registry" ] || [ "$(hr_registry_get "$registry" "$branch" execution)" != github-actions ]; then
|
|
709
1530
|
echo "remote-run.sh: refused, nothing written: the local record of '$branch' does not carry execution: github-actions" >&2
|
|
710
1531
|
exit "$EXIT_REFUSED"
|
|
711
1532
|
fi
|
|
@@ -727,6 +1548,26 @@ case "$bundle_dir" in
|
|
|
727
1548
|
''|/*) ;;
|
|
728
1549
|
*) bundle_dir="${PWD-.}/$bundle_dir" ;;
|
|
729
1550
|
esac
|
|
1551
|
+
case "$prompt_file" in
|
|
1552
|
+
''|/*) ;;
|
|
1553
|
+
*) prompt_file="${PWD-.}/$prompt_file" ;;
|
|
1554
|
+
esac
|
|
1555
|
+
case "$review_file" in
|
|
1556
|
+
''|/*) ;;
|
|
1557
|
+
*) review_file="${PWD-.}/$review_file" ;;
|
|
1558
|
+
esac
|
|
1559
|
+
case "$answers_from" in
|
|
1560
|
+
''|/*) ;;
|
|
1561
|
+
*) answers_from="${PWD-.}/$answers_from" ;;
|
|
1562
|
+
esac
|
|
1563
|
+
if [ "$verb" = fetch ]; then
|
|
1564
|
+
case "$out_dir" in
|
|
1565
|
+
/*) ;;
|
|
1566
|
+
*) out_dir="${PWD-.}/$out_dir" ;;
|
|
1567
|
+
esac
|
|
1568
|
+
fi
|
|
1569
|
+
# Kept apart from <dir>, so the library's character tests see it as typed.
|
|
1570
|
+
[ "$verb" != discard ] || discard_base="${PWD-.}"
|
|
730
1571
|
|
|
731
1572
|
cd "$root" || setup_fail "cannot enter '$root'"
|
|
732
1573
|
|
|
@@ -734,6 +1575,9 @@ cd "$root" || setup_fail "cannot enter '$root'"
|
|
|
734
1575
|
# The verbs.
|
|
735
1576
|
# ---------------------------------------------------------------------------
|
|
736
1577
|
|
|
1578
|
+
# Appended to a failed dispatch's message; `start` sets it once its branch is pushed.
|
|
1579
|
+
dispatch_fail_note=""
|
|
1580
|
+
|
|
737
1581
|
verb_dispatch() {
|
|
738
1582
|
local answers="" value n file payload
|
|
739
1583
|
local -a inputs
|
|
@@ -770,8 +1614,19 @@ verb_dispatch() {
|
|
|
770
1614
|
exit "$EXIT_REFUSED"
|
|
771
1615
|
fi
|
|
772
1616
|
|
|
773
|
-
gh_call workflow run "$WORKFLOW_RUN_FILE" --ref "$branch" "${inputs[@]}" || gh_fail "dispatch of '$branch' failed"
|
|
1617
|
+
gh_call workflow run "$WORKFLOW_RUN_FILE" --ref "$branch" "${inputs[@]}" || gh_fail "dispatch of '$branch' failed$dispatch_fail_note"
|
|
774
1618
|
echo "remote-run.sh: dispatched action=run engine=$engine resume=$resume for $branch"
|
|
1619
|
+
|
|
1620
|
+
# A user's resume: the main checkout's remote record, when one exists, is
|
|
1621
|
+
# running now.
|
|
1622
|
+
if [ "$((10#$chain))" -eq 0 ] && { [ "$resume" = answer ] || [ "$resume" = pause ]; }; then
|
|
1623
|
+
local reg
|
|
1624
|
+
reg=$(hr_state_path "$root" autonomous_logs/registry.json) || reg=""
|
|
1625
|
+
if remote_record_exists "$reg"; then
|
|
1626
|
+
hr_registry_set "$reg" "$branch" status running resumed_at "$(date '+%Y-%m-%dT%H:%M:%S')" resume_kind "$resume" \
|
|
1627
|
+
|| echo "remote-run.sh: dispatched, but the local record of $branch could not be updated" >&2
|
|
1628
|
+
fi
|
|
1629
|
+
fi
|
|
775
1630
|
}
|
|
776
1631
|
|
|
777
1632
|
verb_pause() {
|
|
@@ -822,6 +1677,11 @@ verb_stop() {
|
|
|
822
1677
|
|| echo "remote-run.sh: stopped on GitHub, but the local record of $branch could not be updated" >&2
|
|
823
1678
|
fi
|
|
824
1679
|
echo "remote-run.sh: stopped $branch"
|
|
1680
|
+
if [ -n "$actor_arg" ]; then
|
|
1681
|
+
forge_report stopped "$branch" "Stopped by @$actor_arg."
|
|
1682
|
+
else
|
|
1683
|
+
forge_report stopped "$branch" "Stopped from a local \`remote-run.sh stop\`."
|
|
1684
|
+
fi
|
|
825
1685
|
}
|
|
826
1686
|
|
|
827
1687
|
# list_runs — the branch's runs of the workflow into GH_OUT; exits 3 on failure.
|
|
@@ -921,6 +1781,55 @@ set_many_or_fail() {
|
|
|
921
1781
|
}
|
|
922
1782
|
}
|
|
923
1783
|
|
|
1784
|
+
# open_questions_in <bundle_dir> — OPEN_QUESTIONS: the space-separated <n>,
|
|
1785
|
+
# ascending, of every top-level `clarifications/<branch>/question_<n>.md` in the
|
|
1786
|
+
# bundle with no `answer_<n>.md` beside it.
|
|
1787
|
+
OPEN_QUESTIONS=""
|
|
1788
|
+
open_questions_in() {
|
|
1789
|
+
local clar f n
|
|
1790
|
+
OPEN_QUESTIONS=""
|
|
1791
|
+
hr_remote_names_var
|
|
1792
|
+
clar="$1/$HR_REMOTE_CLARIFY_DIR/$branch"
|
|
1793
|
+
for f in "$clar"/question_*.md; do
|
|
1794
|
+
[ -f "$f" ] || continue
|
|
1795
|
+
n="${f##*/question_}"
|
|
1796
|
+
n="${n%.md}"
|
|
1797
|
+
[[ "$n" =~ ^[0-9]+$ ]] || continue
|
|
1798
|
+
[ -e "$clar/answer_$n.md" ] || OPEN_QUESTIONS="$OPEN_QUESTIONS $n"
|
|
1799
|
+
done
|
|
1800
|
+
if [ -n "$OPEN_QUESTIONS" ]; then
|
|
1801
|
+
OPEN_QUESTIONS=$(printf '%s\n' $OPEN_QUESTIONS | sort -n | tr '\n' ' ')
|
|
1802
|
+
OPEN_QUESTIONS="${OPEN_QUESTIONS% }"
|
|
1803
|
+
fi
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
# status_from_github — `status` with no local record: the newest run's state
|
|
1807
|
+
# read through `remote_state` into a temporary directory removed on exit.
|
|
1808
|
+
status_tmp=""
|
|
1809
|
+
status_from_github() {
|
|
1810
|
+
local n
|
|
1811
|
+
if [ -z "$(titled_runs "harness run $branch" | jq -c '.[0] // empty')" ]; then
|
|
1812
|
+
echo "remote-run.sh: no local record, and no run titled 'harness run $branch' on GitHub"
|
|
1813
|
+
return 0
|
|
1814
|
+
fi
|
|
1815
|
+
echo "remote-run.sh: no local record; state from GitHub (newest run):"
|
|
1816
|
+
status_tmp=$(mktemp -d) || { echo "remote-run.sh: cannot create a temporary directory" >&2; exit "$EXIT_USAGE"; }
|
|
1817
|
+
trap 'rm -rf "$status_tmp"' EXIT
|
|
1818
|
+
remote_state "$status_tmp"
|
|
1819
|
+
printf ' state: %s\n' "$RS_STATE"
|
|
1820
|
+
printf ' pause_reason: %s\n' "$RS_PAUSE_REASON"
|
|
1821
|
+
printf ' detail: %s\n' "$RS_DETAIL"
|
|
1822
|
+
printf ' engine: %s\n' "$RS_ENGINE"
|
|
1823
|
+
printf ' run_url: %s\n' "$RS_RUN_URL"
|
|
1824
|
+
if [ "$RS_BUNDLE" -eq 1 ]; then
|
|
1825
|
+
open_questions_in "$status_tmp"
|
|
1826
|
+
for n in $OPEN_QUESTIONS; do
|
|
1827
|
+
echo "remote-run.sh: open question question_$n.md"
|
|
1828
|
+
grep -E '^## Q[0-9]+' "$status_tmp/$HR_REMOTE_CLARIFY_DIR/$branch/question_$n.md" | sed 's/^/ /'
|
|
1829
|
+
done
|
|
1830
|
+
fi
|
|
1831
|
+
}
|
|
1832
|
+
|
|
924
1833
|
verb_status() {
|
|
925
1834
|
local runs finished_id synced_id field
|
|
926
1835
|
list_runs
|
|
@@ -929,6 +1838,10 @@ verb_status() {
|
|
|
929
1838
|
printf '%s' "$runs" | jq -r --argjson n "$STATUS_RUNS_SHOWN" '
|
|
930
1839
|
if length == 0 then " (none)" else
|
|
931
1840
|
.[:$n][] | " \(.databaseId) \(.displayTitle) \(.status)/\(.conclusion // "") \(.createdAt) \(.url)" end'
|
|
1841
|
+
if [ "$status_no_record" -eq 1 ]; then
|
|
1842
|
+
status_from_github
|
|
1843
|
+
return 0
|
|
1844
|
+
fi
|
|
932
1845
|
echo "remote-run.sh: local record (last synced):"
|
|
933
1846
|
for field in status pause_reason remote_run_url remote_synced_at; do
|
|
934
1847
|
printf ' %s: %s\n' "$field" "$(hr_registry_get "$registry" "$branch" "$field")"
|
|
@@ -957,78 +1870,215 @@ sync_expired() {
|
|
|
957
1870
|
echo "remote-run.sh: $line"
|
|
958
1871
|
}
|
|
959
1872
|
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1873
|
+
# remote_state <download_dir> [<applied_run_id>] — the one derivation of a
|
|
1874
|
+
# branch's newest remote state, from list_runs' answer in GH_OUT; `sync`,
|
|
1875
|
+
# `fetch`, `review` and `status` with no local record all call it. RS_STATE is `none` (no `harness run
|
|
1876
|
+
# <branch>` run listed), `running` (the newest is not `completed`), `applied`
|
|
1877
|
+
# (its id is <applied_run_id>: `sync`'s case 1, decided there), or the state
|
|
1878
|
+
# of `sync`'s cases 2-5, which it derives in that order. A bundle is downloaded
|
|
1879
|
+
# into <download_dir> — `sync`'s per-run directory under the main checkout when
|
|
1880
|
+
# empty — skipped when that directory already holds its status.json, and
|
|
1881
|
+
# RS_BUNDLE is then 1. <applied_run_id>, when set, also counts as a bundle
|
|
1882
|
+
# existing for case 4. <finished> 1 reads the newest run as finished whatever
|
|
1883
|
+
# its `status`: `branch_settled_var` passes it once that run's `run` job has
|
|
1884
|
+
# completed. Exits 3 when gh fails, 2 for an unrecognised bundle.
|
|
1885
|
+
RS_RUNS=""
|
|
1886
|
+
RS_RUN_ID=""
|
|
1887
|
+
RS_RUN_URL=""
|
|
1888
|
+
RS_GH_STATUS=""
|
|
1889
|
+
RS_STATE=""
|
|
1890
|
+
RS_PAUSE_REASON=""
|
|
1891
|
+
RS_DETAIL=""
|
|
1892
|
+
RS_ENGINE=""
|
|
1893
|
+
RS_USAGE_RESUME_AT=""
|
|
1894
|
+
RS_PARK_LOOP_CYCLES=""
|
|
1895
|
+
RS_BUNDLE=0
|
|
1896
|
+
RS_DOWNLOAD=""
|
|
1897
|
+
remote_state() {
|
|
1898
|
+
local download="${1-}" applied="${2-}" finished="${3-0}" newest status_file older bundle_exists=0
|
|
1899
|
+
RS_RUNS=""; RS_RUN_ID=""; RS_RUN_URL=""; RS_GH_STATUS=""; RS_STATE=""
|
|
1900
|
+
RS_PAUSE_REASON=""; RS_DETAIL=""; RS_ENGINE=""; RS_USAGE_RESUME_AT=""
|
|
1901
|
+
RS_PARK_LOOP_CYCLES=""; RS_BUNDLE=0; RS_DOWNLOAD=""
|
|
1902
|
+
RS_RUNS=$(titled_runs "harness run $branch") || RS_RUNS='[]'
|
|
1903
|
+
newest=$(printf '%s' "$RS_RUNS" | jq -c '.[0] // empty')
|
|
971
1904
|
if [ -z "$newest" ]; then
|
|
972
|
-
|
|
1905
|
+
RS_STATE=none
|
|
973
1906
|
return 0
|
|
974
1907
|
fi
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
if [ "$state" != completed ]; then
|
|
981
|
-
set_many_or_fail status running remote_synced_at "$now"
|
|
982
|
-
echo "remote-run.sh: run $id of $branch is $state; the record is running, nothing downloaded"
|
|
1908
|
+
RS_RUN_ID=$(printf '%s' "$newest" | jq -r '.databaseId | tostring')
|
|
1909
|
+
RS_GH_STATUS=$(printf '%s' "$newest" | jq -r '.status // ""')
|
|
1910
|
+
RS_RUN_URL=$(printf '%s' "$newest" | jq -r '.url // ""')
|
|
1911
|
+
if [ "$RS_GH_STATUS" != completed ] && [ "$finished" != 1 ]; then
|
|
1912
|
+
RS_STATE=running
|
|
983
1913
|
return 0
|
|
984
1914
|
fi
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
# Case 1 — already applied. A record still waiting on this run's bundle is
|
|
988
|
-
# re-checked: once it expires, the job can no longer take an answer.
|
|
989
|
-
if [ "$id" = "$synced_id" ]; then
|
|
990
|
-
case "$(hr_registry_get "$registry" "$branch" status)/$(hr_registry_get "$registry" "$branch" pause_reason)" in
|
|
991
|
-
paused/expired) ;;
|
|
992
|
-
parked/*|park_loop/*|paused/*)
|
|
993
|
-
bundle_state "$id"
|
|
994
|
-
if [ "$BUNDLE_STATE" = expired ]; then
|
|
995
|
-
sync_expired "$id" "$url" "$now"
|
|
996
|
-
return 0
|
|
997
|
-
fi
|
|
998
|
-
;;
|
|
999
|
-
esac
|
|
1000
|
-
set_or_fail remote_synced_at "$now"
|
|
1001
|
-
echo "remote-run.sh: run $id of $branch is the one last synced; the mirror is current"
|
|
1915
|
+
if [ -n "$applied" ] && [ "$RS_RUN_ID" = "$applied" ]; then
|
|
1916
|
+
RS_STATE=applied
|
|
1002
1917
|
return 0
|
|
1003
1918
|
fi
|
|
1004
1919
|
|
|
1005
|
-
# Case 2 —
|
|
1006
|
-
bundle_state "$
|
|
1920
|
+
# Case 2 — its bundle has expired.
|
|
1921
|
+
bundle_state "$RS_RUN_ID"
|
|
1007
1922
|
if [ "$BUNDLE_STATE" = expired ]; then
|
|
1008
|
-
|
|
1923
|
+
RS_STATE=paused
|
|
1924
|
+
RS_PAUSE_REASON=expired
|
|
1925
|
+
RS_DETAIL=$(expired_line "$RS_RUN_ID")
|
|
1009
1926
|
return 0
|
|
1010
1927
|
fi
|
|
1011
1928
|
|
|
1012
|
-
# Case 3 — a
|
|
1929
|
+
# Case 3 — a bundle.
|
|
1013
1930
|
if [ "$BUNDLE_STATE" = present ]; then
|
|
1014
1931
|
hr_remote_names_var
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1932
|
+
if [ -z "$download" ]; then
|
|
1933
|
+
download=$(hr_state_path "$root" "autonomous_logs/remote_download/$branch/$RS_RUN_ID") || {
|
|
1934
|
+
echo "remote-run.sh: cannot resolve '$root/harness.config.json'" >&2
|
|
1935
|
+
exit "$EXIT_USAGE"
|
|
1936
|
+
}
|
|
1937
|
+
fi
|
|
1938
|
+
RS_DOWNLOAD="$download"
|
|
1019
1939
|
status_file="$download/$HR_REMOTE_STATUS_FILE"
|
|
1020
1940
|
if [ ! -f "$status_file" ]; then
|
|
1021
1941
|
mkdir -p "$download" || { echo "remote-run.sh: cannot create '$download'" >&2; exit "$EXIT_USAGE"; }
|
|
1022
|
-
gh_call run download "$
|
|
1942
|
+
gh_call run download "$RS_RUN_ID" -n "$STATE_ARTIFACT_NAME" -D "$download" || gh_fail "downloading the bundle of run $RS_RUN_ID failed"
|
|
1023
1943
|
fi
|
|
1024
|
-
|
|
1025
|
-
|
|
1944
|
+
RS_BUNDLE=1
|
|
1945
|
+
RS_STATE=$(hr_remote_status_get "$status_file" status) || RS_STATE=""
|
|
1946
|
+
case "$RS_STATE" in
|
|
1026
1947
|
running|parked|park_loop|paused|completed|failed) ;;
|
|
1027
1948
|
*)
|
|
1028
1949
|
echo "remote-run.sh: refused, nothing written: the bundle in '$download' is unrecognised" >&2
|
|
1029
1950
|
exit "$EXIT_REFUSED"
|
|
1030
1951
|
;;
|
|
1031
1952
|
esac
|
|
1953
|
+
RS_PAUSE_REASON=$(hr_remote_status_get "$status_file" pause_reason) || RS_PAUSE_REASON=""
|
|
1954
|
+
RS_DETAIL=$(hr_remote_status_get "$status_file" detail) || RS_DETAIL=""
|
|
1955
|
+
if [ "$RS_STATE" = running ]; then
|
|
1956
|
+
RS_STATE=paused
|
|
1957
|
+
RS_PAUSE_REASON=killed
|
|
1958
|
+
RS_DETAIL="the job ended mid-run (its bundle still says running): $RS_RUN_URL"
|
|
1959
|
+
fi
|
|
1960
|
+
RS_USAGE_RESUME_AT=$(hr_remote_status_get "$status_file" usage_resume_at) || RS_USAGE_RESUME_AT=""
|
|
1961
|
+
RS_PARK_LOOP_CYCLES=$(hr_remote_status_get "$status_file" park_loop_cycles) || RS_PARK_LOOP_CYCLES=""
|
|
1962
|
+
RS_ENGINE=$(hr_remote_status_get "$status_file" engine) || RS_ENGINE=""
|
|
1963
|
+
return 0
|
|
1964
|
+
fi
|
|
1965
|
+
|
|
1966
|
+
# Case 4 — no bundle, while some bundle exists.
|
|
1967
|
+
if [ -n "$applied" ]; then
|
|
1968
|
+
bundle_exists=1
|
|
1969
|
+
else
|
|
1970
|
+
for older in $(printf '%s' "$RS_RUNS" | jq -r '.[1:][] | select(.status == "completed") | .databaseId | tostring'); do
|
|
1971
|
+
if has_bundle "$older"; then bundle_exists=1; break; fi
|
|
1972
|
+
done
|
|
1973
|
+
fi
|
|
1974
|
+
if [ "$bundle_exists" -eq 1 ]; then
|
|
1975
|
+
RS_STATE=paused
|
|
1976
|
+
RS_PAUSE_REASON=killed
|
|
1977
|
+
RS_DETAIL="run $RS_RUN_ID ended with no state bundle (killed, cancelled or replaced): $RS_RUN_URL"
|
|
1978
|
+
return 0
|
|
1979
|
+
fi
|
|
1980
|
+
|
|
1981
|
+
# Case 5 — no bundle in any run.
|
|
1982
|
+
RS_STATE=failed
|
|
1983
|
+
RS_DETAIL="no run of $branch ever uploaded a state bundle; newest: $RS_RUN_URL"
|
|
1984
|
+
return 0
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
# branch_settled_var <download_dir> <allow_no_run 0|1> — the one settledness
|
|
1988
|
+
# test `review`, `control` and `collect` share: whether a user-review round may be placed
|
|
1989
|
+
# on the branch now. Lists the runs itself, then derives RS_* through
|
|
1990
|
+
# `remote_state`. The newest `harness run <branch>` run decides:
|
|
1991
|
+
# none listed settled only under <allow_no_run> 1 (RS_STATE `none`)
|
|
1992
|
+
# `completed` `remote_state` as ever
|
|
1993
|
+
# anything else its jobs are read: while no job named RUN_JOB_NAME exists
|
|
1994
|
+
# (queued) or that job is not `completed`, in flight as
|
|
1995
|
+
# `running`; once it is, the run's bundle decides as for a
|
|
1996
|
+
# finished run, since only a later job of that run is left
|
|
1997
|
+
# SETTLED is 1 for RS_STATE `completed` or `failed` (and `none` as above), else
|
|
1998
|
+
# 0. Exits 3 when gh fails, 2 for an unrecognised bundle, as `remote_state` does.
|
|
1999
|
+
SETTLED=0
|
|
2000
|
+
branch_settled_var() {
|
|
2001
|
+
local download="${1-}" allow="${2-0}" listed newest id status job finished=0
|
|
2002
|
+
SETTLED=0
|
|
2003
|
+
list_runs
|
|
2004
|
+
# `remote_state` reads the listing from GH_OUT, which the jobs call replaces.
|
|
2005
|
+
listed="$GH_OUT"
|
|
2006
|
+
newest=$(titled_runs "harness run $branch" | jq -c '.[0] // empty') || newest=""
|
|
2007
|
+
if [ -n "$newest" ]; then
|
|
2008
|
+
status=$(printf '%s' "$newest" | jq -r '.status // ""')
|
|
2009
|
+
if [ "$status" != completed ]; then
|
|
2010
|
+
id=$(printf '%s' "$newest" | jq -r '.databaseId | tostring')
|
|
2011
|
+
gh_call api "repos/{owner}/{repo}/actions/runs/$id/jobs" || gh_fail "reading the jobs of run $id failed"
|
|
2012
|
+
job=$(printf '%s' "$GH_OUT" | jq -r --arg n "$RUN_JOB_NAME" \
|
|
2013
|
+
'[.jobs[]? | select(.name == $n) | .status // ""] | first // ""' 2>/dev/null) || {
|
|
2014
|
+
GH_ERR="its job list is not the expected JSON"
|
|
2015
|
+
gh_fail "reading the jobs of run $id failed"
|
|
2016
|
+
}
|
|
2017
|
+
[ "$job" != completed ] || finished=1
|
|
2018
|
+
fi
|
|
2019
|
+
fi
|
|
2020
|
+
GH_OUT="$listed"
|
|
2021
|
+
remote_state "$download" "" "$finished"
|
|
2022
|
+
case "$RS_STATE" in
|
|
2023
|
+
completed|failed) SETTLED=1 ;;
|
|
2024
|
+
none) [ "$allow" != 1 ] || SETTLED=1 ;;
|
|
2025
|
+
esac
|
|
2026
|
+
return 0
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
verb_sync() {
|
|
2030
|
+
local worktree id url synced_id now download
|
|
2031
|
+
local status reason detail
|
|
2032
|
+
worktree=$(hr_registry_get "$registry" "$branch" worktree)
|
|
2033
|
+
if [ -z "$worktree" ] || [ ! -d "$worktree" ]; then
|
|
2034
|
+
echo "remote-run.sh: refused, nothing written: the mirror working copy '$worktree' of $branch is missing" >&2
|
|
2035
|
+
exit "$EXIT_REFUSED"
|
|
2036
|
+
fi
|
|
2037
|
+
list_runs
|
|
2038
|
+
synced_id=$(hr_registry_get "$registry" "$branch" remote_run_id)
|
|
2039
|
+
now=$(date +%s)
|
|
2040
|
+
remote_state "" "$synced_id"
|
|
2041
|
+
id="$RS_RUN_ID"
|
|
2042
|
+
url="$RS_RUN_URL"
|
|
2043
|
+
|
|
2044
|
+
case "$RS_STATE" in
|
|
2045
|
+
none)
|
|
2046
|
+
echo "remote-run.sh: no run titled 'harness run $branch' is listed yet; the record is unchanged"
|
|
2047
|
+
return 0
|
|
2048
|
+
;;
|
|
2049
|
+
running)
|
|
2050
|
+
set_many_or_fail status running remote_synced_at "$now"
|
|
2051
|
+
echo "remote-run.sh: run $id of $branch is $RS_GH_STATUS; the record is running, nothing downloaded"
|
|
2052
|
+
return 0
|
|
2053
|
+
;;
|
|
2054
|
+
applied)
|
|
2055
|
+
# Case 1 — already applied. A record still waiting on this run's bundle
|
|
2056
|
+
# is re-checked: once it expires, the job can no longer take an answer.
|
|
2057
|
+
case "$(hr_registry_get "$registry" "$branch" status)/$(hr_registry_get "$registry" "$branch" pause_reason)" in
|
|
2058
|
+
paused/expired) ;;
|
|
2059
|
+
parked/*|park_loop/*|paused/*)
|
|
2060
|
+
bundle_state "$id"
|
|
2061
|
+
if [ "$BUNDLE_STATE" = expired ]; then
|
|
2062
|
+
sync_expired "$id" "$url" "$now"
|
|
2063
|
+
return 0
|
|
2064
|
+
fi
|
|
2065
|
+
;;
|
|
2066
|
+
esac
|
|
2067
|
+
set_or_fail remote_synced_at "$now"
|
|
2068
|
+
echo "remote-run.sh: run $id of $branch is the one last synced; the mirror is current"
|
|
2069
|
+
return 0
|
|
2070
|
+
;;
|
|
2071
|
+
esac
|
|
2072
|
+
|
|
2073
|
+
# Case 2 — a newer run whose bundle has expired.
|
|
2074
|
+
if [ "$RS_PAUSE_REASON" = expired ] && [ "$RS_BUNDLE" -eq 0 ]; then
|
|
2075
|
+
sync_expired "$id" "$url" "$now"
|
|
2076
|
+
return 0
|
|
2077
|
+
fi
|
|
2078
|
+
|
|
2079
|
+
# Case 3 — a newer run with a bundle.
|
|
2080
|
+
if [ "$RS_BUNDLE" -eq 1 ]; then
|
|
2081
|
+
download="$RS_DOWNLOAD"
|
|
1032
2082
|
hr_remote_bundle_restore "$download" "$worktree" "$branch" mirror
|
|
1033
2083
|
case $? in
|
|
1034
2084
|
0) ;;
|
|
@@ -1043,44 +2093,33 @@ verb_sync() {
|
|
|
1043
2093
|
|| { echo "remote-run.sh: copying the run log to '$remote_log' failed" >&2; exit "$EXIT_USAGE"; }
|
|
1044
2094
|
fi
|
|
1045
2095
|
fi
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
status=paused
|
|
1050
|
-
reason=killed
|
|
1051
|
-
detail="the job ended mid-run (its bundle still says running): $url"
|
|
1052
|
-
fi
|
|
2096
|
+
status="$RS_STATE"
|
|
2097
|
+
reason="$RS_PAUSE_REASON"
|
|
2098
|
+
detail="$RS_DETAIL"
|
|
1053
2099
|
[ -n "$detail" ] || detail="synced from $url"
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
2100
|
+
if valid_engine "$RS_ENGINE"; then
|
|
2101
|
+
set_many_or_fail status "$status" pause_reason "$reason" usage_resume_at "$RS_USAGE_RESUME_AT" \
|
|
2102
|
+
park_loop_cycles "$RS_PARK_LOOP_CYCLES" remote_run_id "$id" remote_run_url "$url" \
|
|
2103
|
+
remote_detail "$detail" remote_synced_at "$now" engine "$RS_ENGINE"
|
|
2104
|
+
else
|
|
2105
|
+
set_many_or_fail status "$status" pause_reason "$reason" usage_resume_at "$RS_USAGE_RESUME_AT" \
|
|
2106
|
+
park_loop_cycles "$RS_PARK_LOOP_CYCLES" remote_run_id "$id" remote_run_url "$url" \
|
|
2107
|
+
remote_detail "$detail" remote_synced_at "$now"
|
|
2108
|
+
fi
|
|
1059
2109
|
echo "remote-run.sh: synced run $id of $branch: $status${reason:+ ($reason)}"
|
|
1060
2110
|
return 0
|
|
1061
2111
|
fi
|
|
1062
2112
|
|
|
1063
2113
|
# Case 4 — a newer run with no bundle, while some bundle exists.
|
|
1064
|
-
|
|
1065
|
-
if [ -n "$synced_id" ]; then
|
|
1066
|
-
bundle_exists=1
|
|
1067
|
-
else
|
|
1068
|
-
for older in $(printf '%s' "$runs" | jq -r '.[1:][] | select(.status == "completed") | .databaseId | tostring'); do
|
|
1069
|
-
if has_bundle "$older"; then bundle_exists=1; break; fi
|
|
1070
|
-
done
|
|
1071
|
-
fi
|
|
1072
|
-
if [ "$bundle_exists" -eq 1 ]; then
|
|
2114
|
+
if [ "$RS_STATE" = paused ]; then
|
|
1073
2115
|
set_many_or_fail status paused pause_reason killed remote_run_id "$id" remote_run_url "$url" \
|
|
1074
|
-
remote_detail "
|
|
1075
|
-
remote_synced_at "$now"
|
|
2116
|
+
remote_detail "$RS_DETAIL" remote_synced_at "$now"
|
|
1076
2117
|
echo "remote-run.sh: run $id of $branch left no bundle; the record is paused (killed), nothing restored"
|
|
1077
2118
|
return 0
|
|
1078
2119
|
fi
|
|
1079
2120
|
|
|
1080
2121
|
# Case 5 — no bundle in any run.
|
|
1081
|
-
set_many_or_fail status failed
|
|
1082
|
-
remote_detail "no run of $branch ever uploaded a state bundle; newest: $url" \
|
|
1083
|
-
remote_synced_at "$now"
|
|
2122
|
+
set_many_or_fail status failed remote_detail "$RS_DETAIL" remote_synced_at "$now"
|
|
1084
2123
|
echo "remote-run.sh: no run of $branch carries a state bundle; the record is failed"
|
|
1085
2124
|
}
|
|
1086
2125
|
|
|
@@ -1094,26 +2133,71 @@ restore_refuse() {
|
|
|
1094
2133
|
exit "$EXIT_REFUSED"
|
|
1095
2134
|
}
|
|
1096
2135
|
|
|
2136
|
+
# lineage_commits_var <checkout> — the branch's current lineage, read from refs
|
|
2137
|
+
# alone: never a fetch, never gh, nothing on stdout. A run is of the current
|
|
2138
|
+
# lineage when its `headSha` is a commit reachable from HEAD and not from
|
|
2139
|
+
# `origin/<defaultBranch>`. The list is complete because `harness-run.yml`
|
|
2140
|
+
# checks out with `fetch-depth: 0`. It survives the branch's own history
|
|
2141
|
+
# edits because `refresh-branch.sh` merges and never rebases and
|
|
2142
|
+
# `push-branch.sh` never forces, so every own run's `headSha` stays an ancestor
|
|
2143
|
+
# of HEAD; a deleted, unmerged branch's commits are not ancestors of a branch
|
|
2144
|
+
# recreated under its name. An empty list leaves the lineage unbounded.
|
|
2145
|
+
# 0: LINEAGE_COMMITS holds the newline-separated full SHAs. 1: it is empty and
|
|
2146
|
+
# LINEAGE_WHY names the reason.
|
|
2147
|
+
LINEAGE_COMMITS=""
|
|
2148
|
+
LINEAGE_WHY=""
|
|
2149
|
+
lineage_commits_var() {
|
|
2150
|
+
local checkout="${1-}" default
|
|
2151
|
+
LINEAGE_COMMITS=""
|
|
2152
|
+
LINEAGE_WHY=""
|
|
2153
|
+
default=$(hr_default_branch "$checkout") && [ -n "$default" ] || {
|
|
2154
|
+
LINEAGE_WHY="the configuration could not be read"
|
|
2155
|
+
return 1
|
|
2156
|
+
}
|
|
2157
|
+
git -C "$checkout" rev-parse --verify --quiet "refs/remotes/origin/$default^{commit}" >/dev/null 2>&1 || {
|
|
2158
|
+
LINEAGE_WHY="origin/$default is not present"
|
|
2159
|
+
return 1
|
|
2160
|
+
}
|
|
2161
|
+
LINEAGE_COMMITS=$(git -C "$checkout" rev-list "refs/remotes/origin/$default..HEAD" 2>/dev/null) || LINEAGE_COMMITS=""
|
|
2162
|
+
[ -n "$LINEAGE_COMMITS" ] || {
|
|
2163
|
+
LINEAGE_WHY="HEAD carries no commit beyond origin/$default"
|
|
2164
|
+
return 1
|
|
2165
|
+
}
|
|
2166
|
+
return 0
|
|
2167
|
+
}
|
|
2168
|
+
|
|
1097
2169
|
# previous_bundle_run — PREV_RUN_ID is the newest finished `harness run
|
|
1098
2170
|
# <branch>` run, other than this job's own, carrying a state artifact, and
|
|
1099
|
-
# PREV_RUN_STATE is `present`, `expired` or empty when no run carries one.
|
|
1100
|
-
#
|
|
2171
|
+
# PREV_RUN_STATE is `present`, `expired` or empty when no run carries one. When
|
|
2172
|
+
# `lineage_commits_var` bounds the lineage, a run whose `headSha` is not in it
|
|
2173
|
+
# (or that has none) is dropped before the walk and counted in LINEAGE_SKIPPED.
|
|
2174
|
+
# A run with no artifact is walked past; an expired one stops the walk, because
|
|
1101
2175
|
# an older copy is staler state. Exits 3 when gh fails.
|
|
1102
2176
|
PREV_RUN_ID=""
|
|
1103
2177
|
PREV_RUN_STATE=""
|
|
2178
|
+
LINEAGE_SKIPPED=0
|
|
1104
2179
|
previous_bundle_run() {
|
|
1105
|
-
local ids id
|
|
2180
|
+
local ids id out bounded=0
|
|
1106
2181
|
PREV_RUN_ID=""
|
|
1107
2182
|
PREV_RUN_STATE=""
|
|
2183
|
+
LINEAGE_SKIPPED=0
|
|
2184
|
+
lineage_commits_var "$root" && bounded=1
|
|
1108
2185
|
gh_call run list --workflow "$WORKFLOW_RUN_FILE" --branch "$branch" \
|
|
1109
|
-
--json databaseId,displayTitle,status,createdAt --limit "$RUN_LIST_LIMIT" \
|
|
2186
|
+
--json databaseId,displayTitle,status,createdAt,headSha --limit "$RUN_LIST_LIMIT" \
|
|
1110
2187
|
|| gh_fail "listing the runs of '$branch' failed"
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
2188
|
+
# jq 1.5: membership by `any(gen; cond)`, not `index` / `IN`.
|
|
2189
|
+
out=$(printf '%s' "$GH_OUT" | jq -r --arg t "harness run $branch" --arg self "${GITHUB_RUN_ID-}" \
|
|
2190
|
+
--arg bounded "$bounded" --arg lineage "$LINEAGE_COMMITS" '
|
|
2191
|
+
($lineage | split("\n")) as $l
|
|
2192
|
+
| [.[] | select(.displayTitle == $t and .status == "completed" and (.databaseId | tostring) != $self)] as $done
|
|
2193
|
+
| [$done[] | select($bounded != "1" or ((.headSha // "") as $h | any($l[]; . == $h)))] as $kept
|
|
2194
|
+
| ((($done | length) - ($kept | length)) | tostring),
|
|
2195
|
+
($kept | sort_by([.createdAt, .databaseId]) | reverse | .[].databaseId | tostring)' 2>/dev/null) || {
|
|
1114
2196
|
GH_ERR="its run list is not the expected JSON"
|
|
1115
2197
|
gh_fail "listing the runs of '$branch' failed"
|
|
1116
2198
|
}
|
|
2199
|
+
LINEAGE_SKIPPED=$(printf '%s\n' "$out" | head -n 1)
|
|
2200
|
+
ids=$(printf '%s\n' "$out" | tail -n +2)
|
|
1117
2201
|
for id in $ids; do
|
|
1118
2202
|
bundle_state "$id"
|
|
1119
2203
|
if [ "$BUNDLE_STATE" != none ]; then
|
|
@@ -1144,13 +2228,18 @@ verb_restore() {
|
|
|
1144
2228
|
previous_bundle_run
|
|
1145
2229
|
id="$PREV_RUN_ID"
|
|
1146
2230
|
hr_remote_names_var
|
|
2231
|
+
if [ -n "$LINEAGE_WHY" ]; then
|
|
2232
|
+
echo "remote-run.sh: the lineage of $branch is not bounded ($LINEAGE_WHY); every finished run of it is a candidate"
|
|
2233
|
+
elif [ "${LINEAGE_SKIPPED:-0}" -gt 0 ]; then
|
|
2234
|
+
echo "remote-run.sh: skipped $LINEAGE_SKIPPED finished run(s) of $branch from before its current lineage"
|
|
2235
|
+
fi
|
|
1147
2236
|
if [ "$PREV_RUN_STATE" = expired ]; then
|
|
1148
2237
|
[ "$resume" != answer ] \
|
|
1149
2238
|
|| restore_refuse "the state bundle of run $id expired on $BUNDLE_EXPIRES_AT, so its questions can no longer be answered here: resume from the committed ledger with $RESUME_HINT $branch, or re-drop the task; nothing written"
|
|
1150
2239
|
echo "::warning::remote-run.sh: the state bundle of run $id expired on $BUNDLE_EXPIRES_AT: the park-loop, auto-resume and stall counts, the clarification history and any planning drafts not yet committed that it carried are lost; this job continues from the committed ledger"
|
|
1151
2240
|
elif [ -z "$id" ]; then
|
|
1152
2241
|
[ "$resume" != answer ] \
|
|
1153
|
-
|| restore_refuse "--resume answer, but no finished run of $branch carries a state bundle; nothing written"
|
|
2242
|
+
|| restore_refuse "--resume answer, but no finished run of $branch's current lineage carries a state bundle; nothing written"
|
|
1154
2243
|
echo "remote-run.sh: no previous bundle for $branch; this is its first job"
|
|
1155
2244
|
else
|
|
1156
2245
|
download=$(hr_state_path "$root" "autonomous_logs/remote_download/$branch/$id") \
|
|
@@ -1278,7 +2367,11 @@ verb_save() {
|
|
|
1278
2367
|
return 0
|
|
1279
2368
|
}
|
|
1280
2369
|
|
|
1281
|
-
# notify <event> <branch> <detail> — one lifecycle notification
|
|
2370
|
+
# notify <event> <branch> <detail> <forge_note> — one lifecycle notification,
|
|
2371
|
+
# then `forge_report` of the same event; never fails. Every call site supplies
|
|
2372
|
+
# both texts: <detail> is the push notification's, slash commands included;
|
|
2373
|
+
# <forge_note> is the comment's, naming no slash command and no shell command,
|
|
2374
|
+
# and states only what happened, since `forge_report` adds the next action.
|
|
1282
2375
|
notify() {
|
|
1283
2376
|
if [ -n "${HARNESS_REMOTE_SLUG-}" ]; then
|
|
1284
2377
|
HARNESS_REPO_SLUG="$HARNESS_REMOTE_SLUG"
|
|
@@ -1287,6 +2380,8 @@ notify() {
|
|
|
1287
2380
|
bash "$script_dir/autonomous-notify.sh" "$1" "$2" "" "$3" \
|
|
1288
2381
|
|| echo "remote-run.sh: the $1 notification for $2 could not be sent" >&2
|
|
1289
2382
|
echo "remote-run.sh: notified $1 for $2: $3"
|
|
2383
|
+
# stdin closed: `poll` calls this inside a loop reading its run list.
|
|
2384
|
+
forge_report "$1" "$2" "$4" </dev/null
|
|
1290
2385
|
}
|
|
1291
2386
|
|
|
1292
2387
|
this_run_url() {
|
|
@@ -1315,7 +2410,7 @@ ALL_RUNS_LISTED=0
|
|
|
1315
2410
|
list_all_runs() {
|
|
1316
2411
|
[ "$ALL_RUNS_LISTED" -eq 0 ] || return 0
|
|
1317
2412
|
gh_call run list --workflow "$WORKFLOW_RUN_FILE" \
|
|
1318
|
-
--json databaseId,headBranch,displayTitle,status,createdAt --limit "$ALL_RUNS_LIMIT" || return 1
|
|
2413
|
+
--json databaseId,headBranch,displayTitle,status,createdAt,url --limit "$ALL_RUNS_LIMIT" || return 1
|
|
1319
2414
|
printf '%s' "$GH_OUT" | jq -e 'type == "array"' >/dev/null 2>&1 || {
|
|
1320
2415
|
GH_ERR="its run list is not the expected JSON"
|
|
1321
2416
|
return 1
|
|
@@ -1385,46 +2480,49 @@ valid_engine() {
|
|
|
1385
2480
|
|
|
1386
2481
|
STOPPED_LINE="a 'harness stop' run is newer than its newest 'harness run' run"
|
|
1387
2482
|
RESUME_HINT="/autonomous-sdlc-harness:branch-resume"
|
|
2483
|
+
# A `paused` report on a usage pause says the run resumes by itself; a note
|
|
2484
|
+
# saying the automatic resume failed carries the action instead.
|
|
2485
|
+
USAGE_RESUME_NOTE="Comment \`$COMMAND_HANDLE resume\` after the limit resets to continue."
|
|
1388
2486
|
|
|
1389
2487
|
continue_redispatch() {
|
|
1390
2488
|
local status_file="$1" engine_value
|
|
1391
2489
|
if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
|
|
1392
|
-
notify paused "$branch" "Not re-dispatched: remote stop is set. Run $RESUME_HINT $branch to continue."
|
|
2490
|
+
notify paused "$branch" "Not re-dispatched: remote stop is set. Run $RESUME_HINT $branch to continue; $(hr_github_resume_route "$branch" "")." "Not continued: the repository variable \`HARNESS_REMOTE_STOP\` is set; clear it, then resume."
|
|
1393
2491
|
return 0
|
|
1394
2492
|
fi
|
|
1395
2493
|
remote_branch_stopped "$branch"
|
|
1396
2494
|
case $? in
|
|
1397
2495
|
0) echo "remote-run.sh: $branch is stopped ($STOPPED_LINE); not re-dispatched"; return 0 ;;
|
|
1398
|
-
2) notify paused "$branch" "Not re-dispatched: the stop-marker check failed ($GH_ERR). Run $RESUME_HINT $branch to continue."; return 0 ;;
|
|
2496
|
+
2) notify paused "$branch" "Not re-dispatched: the stop-marker check failed ($GH_ERR). Run $RESUME_HINT $branch to continue; $(hr_github_resume_route "$branch" "")." "Not continued: whether the run was stopped could not be checked ($GH_ERR)."; return 0 ;;
|
|
1399
2497
|
esac
|
|
1400
2498
|
if ! max_chain_var; then
|
|
1401
|
-
notify failed "$branch" "Not re-dispatched: HARNESS_MAX_CHAIN '$MAX_CHAIN' is not a non-negative integer."
|
|
2499
|
+
notify failed "$branch" "Not re-dispatched: HARNESS_MAX_CHAIN '$MAX_CHAIN' is not a non-negative integer." "Not continued: the repository variable \`HARNESS_MAX_CHAIN\` ('$MAX_CHAIN') is not a non-negative integer."
|
|
1402
2500
|
return 0
|
|
1403
2501
|
fi
|
|
1404
2502
|
next_chain_var "$status_file"
|
|
1405
2503
|
case $? in
|
|
1406
|
-
1) notify failed "$branch" "Not re-dispatched: chain unreadable in status.json."; return 0 ;;
|
|
1407
|
-
2) notify failed "$branch" "Not re-dispatched: chain limit reached ($NEXT_CHAIN over HARNESS_MAX_CHAIN $MAX_CHAIN)."; return 0 ;;
|
|
2504
|
+
1) notify failed "$branch" "Not re-dispatched: chain unreadable in status.json." "Not continued: the chain count in the run's status could not be read."; return 0 ;;
|
|
2505
|
+
2) notify failed "$branch" "Not re-dispatched: chain limit reached ($NEXT_CHAIN over HARNESS_MAX_CHAIN $MAX_CHAIN)." "Not continued: the chain limit was reached ($NEXT_CHAIN over \`HARNESS_MAX_CHAIN\` $MAX_CHAIN)."; return 0 ;;
|
|
1408
2506
|
esac
|
|
1409
2507
|
engine_value=$(hr_remote_status_get "$status_file" engine) || engine_value=""
|
|
1410
2508
|
if ! valid_engine "$engine_value"; then
|
|
1411
|
-
notify failed "$branch" "Not re-dispatched: engine '$engine_value' in status.json is not task, user_review or docs."
|
|
2509
|
+
notify failed "$branch" "Not re-dispatched: engine '$engine_value' in status.json is not task, user_review or docs." "Not continued: the engine '$engine_value' in the run's status is not task, user_review or docs."
|
|
1412
2510
|
return 0
|
|
1413
2511
|
fi
|
|
1414
2512
|
redispatch "$engine_value" "$NEXT_CHAIN" \
|
|
1415
|
-
|| notify paused "$branch" "Re-dispatch failed ($REDISPATCH_ERR). Run $RESUME_HINT $branch to continue."
|
|
2513
|
+
|| notify paused "$branch" "Re-dispatch failed ($REDISPATCH_ERR). Run $RESUME_HINT $branch to continue; $(hr_github_resume_route "$branch" "$engine_value")." "Not continued: dispatching the next job failed ($REDISPATCH_ERR)."
|
|
1416
2514
|
}
|
|
1417
2515
|
|
|
1418
2516
|
continue_wait_poller() {
|
|
1419
2517
|
remote_branch_stopped "$branch"
|
|
1420
2518
|
case $? in
|
|
1421
2519
|
0) echo "remote-run.sh: $branch is stopped ($STOPPED_LINE); the resume poller is not enabled"; return 0 ;;
|
|
1422
|
-
2) notify paused "$branch" "Auto-resume not enabled: the stop-marker check failed ($GH_ERR). Run $RESUME_HINT $branch to continue."; return 0 ;;
|
|
2520
|
+
2) notify paused "$branch" "Auto-resume not enabled: the stop-marker check failed ($GH_ERR). Run $RESUME_HINT $branch to continue; $(hr_github_resume_route "$branch" "")." "The automatic resume after the usage limit was not scheduled: whether the run was stopped could not be checked ($GH_ERR). $USAGE_RESUME_NOTE"; return 0 ;;
|
|
1423
2521
|
esac
|
|
1424
2522
|
if gh_call workflow enable "$WORKFLOW_RESUME_FILE"; then
|
|
1425
2523
|
echo "remote-run.sh: enabled $WORKFLOW_RESUME_FILE for $branch"
|
|
1426
2524
|
else
|
|
1427
|
-
notify paused "$branch" "Auto-resume is unavailable: enabling $WORKFLOW_RESUME_FILE failed ($GH_ERR). Run $RESUME_HINT $branch after the usage reset."
|
|
2525
|
+
notify paused "$branch" "Auto-resume is unavailable: enabling $WORKFLOW_RESUME_FILE failed ($GH_ERR). Run $RESUME_HINT $branch after the usage reset; $(hr_github_resume_route "$branch" "")." "The automatic resume after the usage limit could not be scheduled ($GH_ERR). $USAGE_RESUME_NOTE"
|
|
1428
2526
|
fi
|
|
1429
2527
|
}
|
|
1430
2528
|
|
|
@@ -1433,7 +2531,7 @@ verb_continue() {
|
|
|
1433
2531
|
hr_remote_names_var
|
|
1434
2532
|
status_file="$bundle_dir/$HR_REMOTE_STATUS_FILE"
|
|
1435
2533
|
if [ ! -f "$status_file" ]; then
|
|
1436
|
-
notify failed "$branch" "The job stopped before the harness run started: $(this_run_url)"
|
|
2534
|
+
notify failed "$branch" "The job stopped before the harness run started: $(this_run_url)" "The job stopped before the run started: $(this_run_url)."
|
|
1437
2535
|
return 0
|
|
1438
2536
|
fi
|
|
1439
2537
|
decision=$(hr_remote_status_get "$status_file" decision) || decision=""
|
|
@@ -1441,7 +2539,7 @@ verb_continue() {
|
|
|
1441
2539
|
continue) continue_redispatch "$status_file" ;;
|
|
1442
2540
|
wait-poller) continue_wait_poller ;;
|
|
1443
2541
|
stop) echo "remote-run.sh: decision stop for $branch; nothing to do" ;;
|
|
1444
|
-
*) notify failed "$branch" "Not re-dispatched: status.json carries no recognised decision: $(this_run_url)" ;;
|
|
2542
|
+
*) notify failed "$branch" "Not re-dispatched: status.json carries no recognised decision: $(this_run_url)" "Not continued: the run's status carries no recognised decision." ;;
|
|
1445
2543
|
esac
|
|
1446
2544
|
return 0
|
|
1447
2545
|
}
|
|
@@ -1622,12 +2720,12 @@ poll_branch() {
|
|
|
1622
2720
|
fi
|
|
1623
2721
|
next_chain_var "$POLL_STATUS_FILE"
|
|
1624
2722
|
case $? in
|
|
1625
|
-
1) [ "$may_dispatch" -eq 0 ] || notify failed "$branch" "Not resumed by the poller: chain unreadable in status.json."; return 1 ;;
|
|
1626
|
-
2) [ "$may_dispatch" -eq 0 ] || notify failed "$branch" "Not resumed by the poller: chain limit reached ($NEXT_CHAIN over HARNESS_MAX_CHAIN $MAX_CHAIN)."; return 1 ;;
|
|
2723
|
+
1) [ "$may_dispatch" -eq 0 ] || notify failed "$branch" "Not resumed by the poller: chain unreadable in status.json." "Not resumed after the usage limit: the chain count in the run's status could not be read."; return 1 ;;
|
|
2724
|
+
2) [ "$may_dispatch" -eq 0 ] || notify failed "$branch" "Not resumed by the poller: chain limit reached ($NEXT_CHAIN over HARNESS_MAX_CHAIN $MAX_CHAIN)." "Not resumed after the usage limit: the chain limit was reached ($NEXT_CHAIN over \`HARNESS_MAX_CHAIN\` $MAX_CHAIN)."; return 1 ;;
|
|
1627
2725
|
esac
|
|
1628
2726
|
engine_value=$(hr_remote_status_get "$POLL_STATUS_FILE" engine) || engine_value=""
|
|
1629
2727
|
if ! valid_engine "$engine_value"; then
|
|
1630
|
-
[ "$may_dispatch" -eq 0 ] || notify failed "$branch" "Not resumed by the poller: engine '$engine_value' in status.json is not task, user_review or docs."
|
|
2728
|
+
[ "$may_dispatch" -eq 0 ] || notify failed "$branch" "Not resumed by the poller: engine '$engine_value' in status.json is not task, user_review or docs." "Not resumed after the usage limit: the engine '$engine_value' in the run's status is not task, user_review or docs."
|
|
1631
2729
|
return 1
|
|
1632
2730
|
fi
|
|
1633
2731
|
if [ "$may_dispatch" -eq 0 ]; then
|
|
@@ -1648,7 +2746,7 @@ poll_branch() {
|
|
|
1648
2746
|
if [ "$failures" -ge "$((10#$POLL_MAX_FAILURES))" ] \
|
|
1649
2747
|
|| [ "$now" -gt "$((10#$at + 10#$POLL_GIVE_UP_MINUTES * 60))" ]; then
|
|
1650
2748
|
poll_state_put "$branch" "$id" "$failures" 1
|
|
1651
|
-
notify paused "$branch" "The resume poller could not re-dispatch $branch ($REDISPATCH_ERR) after $failures attempts; automatic resume has stopped. Run $RESUME_HINT $branch."
|
|
2749
|
+
notify paused "$branch" "The resume poller could not re-dispatch $branch ($REDISPATCH_ERR) after $failures attempts; automatic resume has stopped. Run $RESUME_HINT $branch; $(hr_github_resume_route "$branch" "$engine_value")." "Not resumed after the usage limit: dispatching the next job failed $failures times ($REDISPATCH_ERR), and the automatic resume has stopped."
|
|
1652
2750
|
return 1
|
|
1653
2751
|
fi
|
|
1654
2752
|
poll_state_put "$branch" "$id" "$failures" ""
|
|
@@ -1691,7 +2789,7 @@ EOF
|
|
|
1691
2789
|
# poll_recheck — after the disable: one fresh listing, evaluated without
|
|
1692
2790
|
# dispatching; re-enables the poller when a branch became waiting meanwhile.
|
|
1693
2791
|
poll_recheck() {
|
|
1694
|
-
local b
|
|
2792
|
+
local b enable_err
|
|
1695
2793
|
ALL_RUNS_LISTED=0
|
|
1696
2794
|
if ! list_all_runs || ! poll_pass 0; then
|
|
1697
2795
|
echo "remote-run.sh: poll: the re-check after disabling $WORKFLOW_RESUME_FILE could not list the runs ($GH_ERR); it stays disabled"
|
|
@@ -1704,8 +2802,10 @@ poll_recheck() {
|
|
|
1704
2802
|
done
|
|
1705
2803
|
return 0
|
|
1706
2804
|
fi
|
|
2805
|
+
# Captured once: each notify's report overwrites GH_ERR.
|
|
2806
|
+
enable_err="$GH_ERR"
|
|
1707
2807
|
for b in $POLL_WAITING; do
|
|
1708
|
-
notify paused "$b" "Auto-resume is unavailable: re-enabling $WORKFLOW_RESUME_FILE failed ($
|
|
2808
|
+
notify paused "$b" "Auto-resume is unavailable: re-enabling $WORKFLOW_RESUME_FILE failed ($enable_err). Run $RESUME_HINT $b after the usage reset; $(hr_github_resume_route "$b" "")." "The automatic resume after the usage limit could not be scheduled ($enable_err). $USAGE_RESUME_NOTE"
|
|
1709
2809
|
done
|
|
1710
2810
|
}
|
|
1711
2811
|
|
|
@@ -1778,6 +2878,2436 @@ verb_run_created_at() {
|
|
|
1778
2878
|
printf '%s\n' "$epoch"
|
|
1779
2879
|
}
|
|
1780
2880
|
|
|
2881
|
+
# placement_fail <step> — exit 4, naming the step; nothing was dispatched.
|
|
2882
|
+
placement_fail() {
|
|
2883
|
+
echo "remote-run.sh: start of '$branch' failed at $1; nothing was dispatched" >&2
|
|
2884
|
+
exit "$EXIT_PLACEMENT"
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
# start_remove_copy — remove the working copy and the local branch `start`
|
|
2888
|
+
# cut, and only those: `had_copy` / `had_branch` record what existed before the
|
|
2889
|
+
# cut. Idempotent; a failed step is one stderr line and never changes the exit
|
|
2890
|
+
# status already decided. `--force` because a failure exit may leave the placed,
|
|
2891
|
+
# uncommitted prompt, which is only a copy of --prompt-file.
|
|
2892
|
+
start_remove_copy() {
|
|
2893
|
+
if [ "$had_copy" -eq 0 ] && [ -e "$worktree" ]; then
|
|
2894
|
+
git -C "$root" worktree remove --force "$worktree" >/dev/null 2>&1 \
|
|
2895
|
+
|| echo "remote-run.sh: could not remove the working copy '$worktree'" >&2
|
|
2896
|
+
fi
|
|
2897
|
+
git -C "$root" worktree prune >/dev/null 2>&1 \
|
|
2898
|
+
|| echo "remote-run.sh: git worktree prune failed in '$root'" >&2
|
|
2899
|
+
if [ "$had_branch" -eq 0 ] && git -C "$root" show-ref --verify --quiet "refs/heads/$branch"; then
|
|
2900
|
+
git -C "$root" branch -D "$branch" >/dev/null 2>&1 \
|
|
2901
|
+
|| echo "remote-run.sh: could not delete the local branch '$branch'" >&2
|
|
2902
|
+
fi
|
|
2903
|
+
return 0
|
|
2904
|
+
}
|
|
2905
|
+
|
|
2906
|
+
verb_start() {
|
|
2907
|
+
local protected=0 status=0 state_rel rel subject
|
|
2908
|
+
hr_branch_is_protected "$root" "$branch" || protected=$?
|
|
2909
|
+
case "$protected" in
|
|
2910
|
+
0)
|
|
2911
|
+
echo "remote-run.sh: refused, nothing written: $branch is protected" >&2
|
|
2912
|
+
exit "$EXIT_REFUSED" ;;
|
|
2913
|
+
2)
|
|
2914
|
+
echo "remote-run.sh: refused, nothing written: cannot judge whether $branch is protected" >&2
|
|
2915
|
+
exit "$EXIT_REFUSED" ;;
|
|
2916
|
+
esac
|
|
2917
|
+
if [ ! -f "$prompt_file" ] || [ ! -r "$prompt_file" ]; then
|
|
2918
|
+
echo "remote-run.sh: refused, nothing written: the prompt file '$prompt_file' is not a readable regular file" >&2
|
|
2919
|
+
exit "$EXIT_REFUSED"
|
|
2920
|
+
fi
|
|
2921
|
+
|
|
2922
|
+
# Global, not local: the EXIT trap runs after this function's frame is gone.
|
|
2923
|
+
worktree=$(hr_worktree_dir "$root" "$branch") || placement_fail "resolving the working copy"
|
|
2924
|
+
had_copy=0
|
|
2925
|
+
[ ! -e "$worktree" ] || had_copy=1
|
|
2926
|
+
had_branch=0
|
|
2927
|
+
! git -C "$root" show-ref --verify --quiet "refs/heads/$branch" || had_branch=1
|
|
2928
|
+
|
|
2929
|
+
# The cut itself can leave a half-created copy, and every failure below exits.
|
|
2930
|
+
trap start_remove_copy EXIT
|
|
2931
|
+
bash "$script_dir/create-worktree.sh" --no-bootstrap "$branch" >&2 || status=$?
|
|
2932
|
+
[ "$status" -eq 0 ] || placement_fail "the branch cut (create-worktree.sh exited $status)"
|
|
2933
|
+
|
|
2934
|
+
state_rel=$(hr_state_dir "$worktree") || placement_fail "resolving the state directory in '$worktree'"
|
|
2935
|
+
[ -n "$state_rel" ] || placement_fail "resolving the state directory in '$worktree'"
|
|
2936
|
+
rel=$(hr_task_prompt_rel "$state_rel" "$branch")
|
|
2937
|
+
subject=$(hr_task_prompt_subject "$branch")
|
|
2938
|
+
|
|
2939
|
+
hr_place_artifact "$worktree" "$prompt_file" "$rel" || placement_fail "copying the prompt to '$worktree/$rel'"
|
|
2940
|
+
# 3 (nothing staged) cannot happen on a freshly cut branch, so it reads as 0.
|
|
2941
|
+
status=0
|
|
2942
|
+
hr_commit_placed "$script_dir/commit-on-branch.sh" "$worktree" "$rel" "$subject" >&2 || status=$?
|
|
2943
|
+
[ "$status" -ne 1 ] || placement_fail "committing '$rel'"
|
|
2944
|
+
hr_push_landed "$script_dir/push-branch.sh" "$worktree" "$branch" >&2 \
|
|
2945
|
+
|| placement_fail "pushing $branch (origin/$branch is not HEAD)"
|
|
2946
|
+
start_remove_copy
|
|
2947
|
+
trap - EXIT
|
|
2948
|
+
|
|
2949
|
+
engine=task
|
|
2950
|
+
resume=none
|
|
2951
|
+
chain=0
|
|
2952
|
+
dispatch_fail_note="; $branch and its task prompt are already pushed to origin, so re-send with: remote-run.sh dispatch $branch --engine task"
|
|
2953
|
+
verb_dispatch
|
|
2954
|
+
echo "remote-run.sh: started $branch"
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
# ---------------------------------------------------------------------------
|
|
2958
|
+
# `fetch` — the newest remote state of one branch, for the plugin commands.
|
|
2959
|
+
# ---------------------------------------------------------------------------
|
|
2960
|
+
|
|
2961
|
+
verb_fetch() {
|
|
2962
|
+
local open_questions="" bundle_dir=""
|
|
2963
|
+
if [ ! -d "$out_dir" ]; then
|
|
2964
|
+
echo "remote-run.sh: fetch needs an existing directory, not '$out_dir'" >&2
|
|
2965
|
+
exit "$EXIT_USAGE"
|
|
2966
|
+
fi
|
|
2967
|
+
if [ -n "$(ls -A "$out_dir" 2>/dev/null)" ]; then
|
|
2968
|
+
echo "remote-run.sh: fetch needs an empty directory, and '$out_dir' is not" >&2
|
|
2969
|
+
exit "$EXIT_USAGE"
|
|
2970
|
+
fi
|
|
2971
|
+
list_runs
|
|
2972
|
+
remote_state "$out_dir"
|
|
2973
|
+
if [ "$RS_BUNDLE" -eq 1 ]; then
|
|
2974
|
+
bundle_dir="$out_dir"
|
|
2975
|
+
open_questions_in "$out_dir"
|
|
2976
|
+
open_questions="$OPEN_QUESTIONS"
|
|
2977
|
+
fi
|
|
2978
|
+
printf 'run_id: %s\n' "$RS_RUN_ID"
|
|
2979
|
+
printf 'run_url: %s\n' "$RS_RUN_URL"
|
|
2980
|
+
printf 'run_status: %s\n' "$RS_GH_STATUS"
|
|
2981
|
+
printf 'state: %s\n' "$RS_STATE"
|
|
2982
|
+
printf 'pause_reason: %s\n' "$RS_PAUSE_REASON"
|
|
2983
|
+
printf 'engine: %s\n' "$RS_ENGINE"
|
|
2984
|
+
printf 'detail: %s\n' "$RS_DETAIL"
|
|
2985
|
+
printf 'open_questions: %s\n' "$open_questions"
|
|
2986
|
+
printf 'bundle_dir: %s\n' "$bundle_dir"
|
|
2987
|
+
}
|
|
2988
|
+
|
|
2989
|
+
# ---------------------------------------------------------------------------
|
|
2990
|
+
# `review` — a user review round placed on the branch tip, then dispatched.
|
|
2991
|
+
# ---------------------------------------------------------------------------
|
|
2992
|
+
|
|
2993
|
+
# review_fail <step> — exit 4, naming the step; nothing was dispatched.
|
|
2994
|
+
review_fail() {
|
|
2995
|
+
echo "remote-run.sh: review of '$branch' failed at $1; nothing was dispatched" >&2
|
|
2996
|
+
exit "$EXIT_PLACEMENT"
|
|
2997
|
+
}
|
|
2998
|
+
|
|
2999
|
+
# remote_record_exists <registry> — 0 when <registry> is a file holding a
|
|
3000
|
+
# record of the branch with `execution: github-actions`. Tested with -f first:
|
|
3001
|
+
# hr_registry_get creates an absent registry.
|
|
3002
|
+
remote_record_exists() {
|
|
3003
|
+
[ -n "${1-}" ] && [ -f "$1" ] && [ "$(hr_registry_get "$1" "$branch" execution)" = github-actions ]
|
|
3004
|
+
}
|
|
3005
|
+
|
|
3006
|
+
verb_review() {
|
|
3007
|
+
local protected=0 status=0 reg="" record_wt="" use_mirror=0 state_rel names name round max=0 next rel note pr pushed
|
|
3008
|
+
hr_branch_is_protected "$root" "$branch" || protected=$?
|
|
3009
|
+
case "$protected" in
|
|
3010
|
+
0)
|
|
3011
|
+
echo "remote-run.sh: refused, nothing written: $branch is protected" >&2
|
|
3012
|
+
exit "$EXIT_REFUSED" ;;
|
|
3013
|
+
2)
|
|
3014
|
+
echo "remote-run.sh: refused, nothing written: cannot judge whether $branch is protected" >&2
|
|
3015
|
+
exit "$EXIT_REFUSED" ;;
|
|
3016
|
+
esac
|
|
3017
|
+
if [ ! -f "$review_file" ] || [ ! -r "$review_file" ]; then
|
|
3018
|
+
echo "remote-run.sh: refused, nothing written: the review file '$review_file' is not a readable regular file" >&2
|
|
3019
|
+
exit "$EXIT_REFUSED"
|
|
3020
|
+
fi
|
|
3021
|
+
|
|
3022
|
+
# An unsettled branch takes no review here: a local round's file exists only
|
|
3023
|
+
# on the caller's machine, so no later collection could pick it up.
|
|
3024
|
+
branch_settled_var "" "$allow_no_run"
|
|
3025
|
+
if [ "$SETTLED" -ne 1 ]; then
|
|
3026
|
+
case "$RS_STATE" in
|
|
3027
|
+
none)
|
|
3028
|
+
echo "remote-run.sh: refused, nothing written: no \`harness run $branch\` run on GitHub" >&2 ;;
|
|
3029
|
+
paused)
|
|
3030
|
+
if [ "$RS_PAUSE_REASON" = expired ]; then
|
|
3031
|
+
echo "remote-run.sh: refused, nothing written: $RS_DETAIL" >&2
|
|
3032
|
+
else
|
|
3033
|
+
echo "remote-run.sh: refused, nothing written: $branch is paused${RS_PAUSE_REASON:+ ($RS_PAUSE_REASON)} on GitHub; a review waits until its run is completed or failed" >&2
|
|
3034
|
+
fi ;;
|
|
3035
|
+
*)
|
|
3036
|
+
echo "remote-run.sh: refused, nothing written: $branch is $RS_STATE on GitHub; a review waits until its run is completed or failed" >&2 ;;
|
|
3037
|
+
esac
|
|
3038
|
+
exit "$EXIT_REFUSED"
|
|
3039
|
+
fi
|
|
3040
|
+
|
|
3041
|
+
# The copy: the remote record's mirror when it is on the branch, never
|
|
3042
|
+
# removed; else a copy this verb cuts and removes on every exit.
|
|
3043
|
+
reg=$(hr_state_path "$root" autonomous_logs/registry.json) || reg=""
|
|
3044
|
+
if remote_record_exists "$reg"; then
|
|
3045
|
+
record_wt=$(hr_registry_get "$reg" "$branch" worktree)
|
|
3046
|
+
if [ -n "$record_wt" ] && [ -d "$record_wt" ] \
|
|
3047
|
+
&& [ "$(git -C "$record_wt" symbolic-ref --short HEAD 2>/dev/null)" = "$branch" ]; then
|
|
3048
|
+
use_mirror=1
|
|
3049
|
+
fi
|
|
3050
|
+
fi
|
|
3051
|
+
if [ "$use_mirror" -eq 1 ]; then
|
|
3052
|
+
worktree="$record_wt"
|
|
3053
|
+
else
|
|
3054
|
+
# Global, not local: the EXIT trap runs after this function's frame is gone.
|
|
3055
|
+
worktree=$(hr_worktree_dir "$root" "$branch") || review_fail "resolving the working copy"
|
|
3056
|
+
had_copy=0
|
|
3057
|
+
[ ! -e "$worktree" ] || had_copy=1
|
|
3058
|
+
had_branch=0
|
|
3059
|
+
! git -C "$root" show-ref --verify --quiet "refs/heads/$branch" || had_branch=1
|
|
3060
|
+
trap start_remove_copy EXIT
|
|
3061
|
+
bash "$script_dir/create-worktree.sh" --existing --no-bootstrap "$branch" >&2 || status=$?
|
|
3062
|
+
[ "$status" -eq 0 ] || review_fail "the working copy (create-worktree.sh exited $status)"
|
|
3063
|
+
fi
|
|
3064
|
+
# Also for a cut copy: a local branch that existed before may be behind origin.
|
|
3065
|
+
git -C "$worktree" fetch origin "$branch" >&2 || review_fail "git fetch origin $branch in '$worktree'"
|
|
3066
|
+
git -C "$worktree" merge --ff-only "origin/$branch" >&2 || review_fail "git merge --ff-only origin/$branch in '$worktree'"
|
|
3067
|
+
|
|
3068
|
+
# The round, from the branch tip: the engine's own round source.
|
|
3069
|
+
state_rel=$(hr_state_dir "$worktree") || review_fail "resolving the state directory in '$worktree'"
|
|
3070
|
+
[ -n "$state_rel" ] || review_fail "resolving the state directory in '$worktree'"
|
|
3071
|
+
state_rel="${state_rel%/}"
|
|
3072
|
+
names=$(git -C "$worktree" ls-tree --name-only HEAD -- "$state_rel/user_reviews/") \
|
|
3073
|
+
|| review_fail "listing $state_rel/user_reviews/ on $branch"
|
|
3074
|
+
while IFS= read -r name; do
|
|
3075
|
+
name="${name##*/}"
|
|
3076
|
+
[[ "$name" =~ ^(.+)_review(_([0-9]+))?\.md$ ]] || continue
|
|
3077
|
+
[ "${BASH_REMATCH[1]}" = "$branch" ] || continue
|
|
3078
|
+
round="${BASH_REMATCH[3]:-1}"
|
|
3079
|
+
round=$((10#$round))
|
|
3080
|
+
[ "$round" -le "$max" ] || max="$round"
|
|
3081
|
+
done <<NAMES
|
|
3082
|
+
$names
|
|
3083
|
+
NAMES
|
|
3084
|
+
if [ "$max" -eq 0 ]; then
|
|
3085
|
+
round=1
|
|
3086
|
+
next="${branch}_review.md"
|
|
3087
|
+
else
|
|
3088
|
+
round=$((max + 1))
|
|
3089
|
+
next="${branch}_review_$round.md"
|
|
3090
|
+
fi
|
|
3091
|
+
rel="$state_rel/user_reviews/$next"
|
|
3092
|
+
|
|
3093
|
+
hr_place_artifact "$worktree" "$review_file" "$rel" || review_fail "copying the review to '$worktree/$rel'"
|
|
3094
|
+
# 3 (nothing staged) cannot happen for a new round's file, so it reads as a failure.
|
|
3095
|
+
status=0
|
|
3096
|
+
hr_commit_placed "$script_dir/commit-on-branch.sh" "$worktree" "$rel" "$(hr_user_review_subject "$branch")" >&2 || status=$?
|
|
3097
|
+
[ "$status" -eq 0 ] || review_fail "committing '$rel'"
|
|
3098
|
+
hr_push_landed "$script_dir/push-branch.sh" "$worktree" "$branch" >&2 \
|
|
3099
|
+
|| review_fail "pushing $branch (origin/$branch is not HEAD)"
|
|
3100
|
+
pushed=$(git -C "$worktree" rev-parse HEAD 2>/dev/null) || pushed=""
|
|
3101
|
+
if [ "$use_mirror" -eq 0 ]; then
|
|
3102
|
+
start_remove_copy
|
|
3103
|
+
trap - EXIT
|
|
3104
|
+
fi
|
|
3105
|
+
|
|
3106
|
+
echo "remote-run.sh: placed $rel (round $round) on $branch"
|
|
3107
|
+
engine=user_review
|
|
3108
|
+
resume=none
|
|
3109
|
+
chain=0
|
|
3110
|
+
dispatch_fail_note="; the review is already pushed to origin/$branch, so re-send with: remote-run.sh dispatch $branch --engine user_review"
|
|
3111
|
+
verb_dispatch
|
|
3112
|
+
|
|
3113
|
+
# Held until GitHub lists the dispatched run: the next job serialized behind
|
|
3114
|
+
# this one would otherwise read the branch as settled and place a second
|
|
3115
|
+
# round, whose dispatch cancels this one's pending run in the run workflow's
|
|
3116
|
+
# concurrency group.
|
|
3117
|
+
if run_by_sha_var "$pushed"; then
|
|
3118
|
+
echo "remote-run.sh: the dispatched run of $branch is listed: $RUN_BY_SHA_URL"
|
|
3119
|
+
else
|
|
3120
|
+
echo "::warning::remote-run.sh: no \`harness run $branch\` run of ${pushed:-the pushed commit} was listed after $TRIGGER_RUN_LOOKUP_TRIES lookups; the round is dispatched, but a review job reading $branch now may read it as settled"
|
|
3121
|
+
fi
|
|
3122
|
+
|
|
3123
|
+
if remote_record_exists "$reg"; then
|
|
3124
|
+
hr_registry_set "$reg" "$branch" status running engine user_review \
|
|
3125
|
+
|| echo "remote-run.sh: dispatched, but the local record of $branch could not be updated" >&2
|
|
3126
|
+
fi
|
|
3127
|
+
|
|
3128
|
+
if [ -n "$reviewers_arg" ]; then
|
|
3129
|
+
pr=""
|
|
3130
|
+
if [[ "$source_arg" =~ /pull/([0-9]+)([/?#]|$) ]]; then
|
|
3131
|
+
pr="${BASH_REMATCH[1]}"
|
|
3132
|
+
elif forge_on && forge_repo_var && forge_pr_var "$branch"; then
|
|
3133
|
+
pr="$FORGE_PR"
|
|
3134
|
+
fi
|
|
3135
|
+
note="Round $round from pull request${pr:+ #$pr}"
|
|
3136
|
+
[ -z "$source_arg" ] || note="$note ($source_arg)"
|
|
3137
|
+
note="$note by @${reviewers_arg//,/, @}"
|
|
3138
|
+
else
|
|
3139
|
+
note="Round $round"
|
|
3140
|
+
[ -z "$source_arg" ] || note="$note from $source_arg"
|
|
3141
|
+
if [ -n "$actor_arg" ]; then
|
|
3142
|
+
note="$note by @$actor_arg"
|
|
3143
|
+
else
|
|
3144
|
+
note="$note from a local session"
|
|
3145
|
+
fi
|
|
3146
|
+
fi
|
|
3147
|
+
forge_report round "$branch" "$note"
|
|
3148
|
+
}
|
|
3149
|
+
|
|
3150
|
+
# unrecorded_runs — UNRECORDED: one `<branch>\t<url>` line per branch whose
|
|
3151
|
+
# newest `harness run <branch>` run is on GitHub, newest first, dropping a
|
|
3152
|
+
# branch with a registry record, one `hr_branch_is_protected` does not answer 1
|
|
3153
|
+
# for, and one that is not a live head on origin. One listing and one
|
|
3154
|
+
# `ls-remote`; writes nothing. Exits 3 when either fails.
|
|
3155
|
+
UNRECORDED=""
|
|
3156
|
+
unrecorded_runs() {
|
|
3157
|
+
local titled reg recorded="" heads="" live=$'\n' ref b url protected
|
|
3158
|
+
UNRECORDED=""
|
|
3159
|
+
reg=$(hr_state_path "$root" autonomous_logs/registry.json) || {
|
|
3160
|
+
echo "remote-run.sh: cannot resolve '$root/harness.config.json'" >&2
|
|
3161
|
+
exit "$EXIT_USAGE"
|
|
3162
|
+
}
|
|
3163
|
+
list_all_runs || gh_fail "listing the runs of $WORKFLOW_RUN_FILE failed"
|
|
3164
|
+
# Newest first, one line per branch: its newest `harness run <branch>` run.
|
|
3165
|
+
titled=$(printf '%s' "$ALL_RUNS" | jq -r '
|
|
3166
|
+
[.[] | select((.displayTitle // "") | startswith("harness run "))
|
|
3167
|
+
| . + {b: (.displayTitle | ltrimstr("harness run "))}]
|
|
3168
|
+
| sort_by([.createdAt, .databaseId]) | reverse
|
|
3169
|
+
| reduce .[] as $r ({seen: {}, out: []};
|
|
3170
|
+
if .seen[$r.b] then . else (.seen[$r.b] = true | .out += [$r]) end)
|
|
3171
|
+
| .out[] | "\(.b)\t\(.url // "")"' 2>/dev/null) || {
|
|
3172
|
+
GH_ERR="its run list is not the expected JSON"
|
|
3173
|
+
gh_fail "listing the runs of $WORKFLOW_RUN_FILE failed"
|
|
3174
|
+
}
|
|
3175
|
+
|
|
3176
|
+
# Tested with -f first: hr_registry_get creates an absent registry.
|
|
3177
|
+
if [ -f "$reg" ]; then
|
|
3178
|
+
recorded=$(jq -r '.runs | keys[]' "$reg" 2>/dev/null) || {
|
|
3179
|
+
echo "remote-run.sh: cannot read the registry '$reg'" >&2
|
|
3180
|
+
exit "$EXIT_USAGE"
|
|
3181
|
+
}
|
|
3182
|
+
fi
|
|
3183
|
+
recorded=$'\n'"$recorded"$'\n'
|
|
3184
|
+
|
|
3185
|
+
if [ -n "$titled" ]; then
|
|
3186
|
+
heads=$(git -C "$root" ls-remote --heads origin 2>&1) || {
|
|
3187
|
+
echo "remote-run.sh: reading origin's branches failed: ${heads%%$'\n'*}" >&2
|
|
3188
|
+
exit "$EXIT_GH"
|
|
3189
|
+
}
|
|
3190
|
+
while IFS=$'\t' read -r _ ref; do
|
|
3191
|
+
case "$ref" in refs/heads/*) live="$live${ref#refs/heads/}"$'\n' ;; esac
|
|
3192
|
+
done <<EOF
|
|
3193
|
+
$heads
|
|
3194
|
+
EOF
|
|
3195
|
+
fi
|
|
3196
|
+
|
|
3197
|
+
while IFS=$'\t' read -r b url; do
|
|
3198
|
+
valid_branch "$b" || continue
|
|
3199
|
+
case "$recorded" in *$'\n'"$b"$'\n'*) continue ;; esac
|
|
3200
|
+
protected=0
|
|
3201
|
+
hr_branch_is_protected "$root" "$b" || protected=$?
|
|
3202
|
+
[ "$protected" -eq 1 ] || continue
|
|
3203
|
+
case "$live" in *$'\n'"$b"$'\n'*) ;; *) continue ;; esac
|
|
3204
|
+
UNRECORDED="$UNRECORDED$b"$'\t'"$url"$'\n'
|
|
3205
|
+
done <<EOF
|
|
3206
|
+
$titled
|
|
3207
|
+
EOF
|
|
3208
|
+
}
|
|
3209
|
+
|
|
3210
|
+
# ---------------------------------------------------------------------------
|
|
3211
|
+
# `list` — the runs on GitHub with no local record, for `branch-status`.
|
|
3212
|
+
# ---------------------------------------------------------------------------
|
|
3213
|
+
|
|
3214
|
+
verb_list() {
|
|
3215
|
+
local b url
|
|
3216
|
+
unrecorded_runs
|
|
3217
|
+
if [ -z "$UNRECORDED" ]; then
|
|
3218
|
+
echo "remote-run.sh: no run on GitHub without a local record"
|
|
3219
|
+
return 0
|
|
3220
|
+
fi
|
|
3221
|
+
while IFS=$'\t' read -r b url; do
|
|
3222
|
+
[ -n "$b" ] || continue
|
|
3223
|
+
echo "remote-run.sh: on GitHub, no local record: $b $url"
|
|
3224
|
+
done <<EOF
|
|
3225
|
+
$UNRECORDED
|
|
3226
|
+
EOF
|
|
3227
|
+
}
|
|
3228
|
+
|
|
3229
|
+
# ---------------------------------------------------------------------------
|
|
3230
|
+
# `trigger` — the event adapter. Event text is data: every field is read by
|
|
3231
|
+
# `jq` into a variable and reaches a command only as one argument or as file
|
|
3232
|
+
# bytes, never as shell source.
|
|
3233
|
+
# ---------------------------------------------------------------------------
|
|
3234
|
+
|
|
3235
|
+
trigger_tmp=""
|
|
3236
|
+
trigger_label=""
|
|
3237
|
+
issue_number=""
|
|
3238
|
+
# `issue` or `dispatch`: where trigger_finish reports.
|
|
3239
|
+
trigger_source=""
|
|
3240
|
+
|
|
3241
|
+
# event_field <jq filter> — one field of the event into EVENT_VALUE, its bytes
|
|
3242
|
+
# kept (a command substitution alone would drop trailing newlines). 1 when jq
|
|
3243
|
+
# cannot read it.
|
|
3244
|
+
EVENT_VALUE=""
|
|
3245
|
+
event_field() {
|
|
3246
|
+
local out
|
|
3247
|
+
out=$(jq -j "$1" "$GITHUB_EVENT_PATH" 2>/dev/null && printf x) || return 1
|
|
3248
|
+
EVENT_VALUE=${out%x}
|
|
3249
|
+
}
|
|
3250
|
+
|
|
3251
|
+
# trigger_finish <exit> <comment> <event> — post <comment> on the issue with the
|
|
3252
|
+
# marker for <event> (`started` or `refused`; `branch=` is empty before a branch
|
|
3253
|
+
# is derived), remove the trigger label, on `started` set the state label
|
|
3254
|
+
# `running`, and exit <exit>. A comment that cannot be posted makes the exit 3;
|
|
3255
|
+
# a label that cannot be removed or set is a warning only. For a dispatch event,
|
|
3256
|
+
# print <comment> with no marker and append it to GITHUB_STEP_SUMMARY when set;
|
|
3257
|
+
# a summary that cannot be appended is a warning only, since stdout already
|
|
3258
|
+
# carries it.
|
|
3259
|
+
trigger_finish() {
|
|
3260
|
+
local code="$1" body="$2" event="$3" file
|
|
3261
|
+
if [ "$trigger_source" = dispatch ]; then
|
|
3262
|
+
printf '%s\n' "$body"
|
|
3263
|
+
if [ -n "${GITHUB_STEP_SUMMARY-}" ]; then
|
|
3264
|
+
printf '%s\n' "### harness trigger" "" "$body" "" >>"$GITHUB_STEP_SUMMARY" \
|
|
3265
|
+
|| echo "::warning::remote-run.sh: trigger: cannot append to GITHUB_STEP_SUMMARY"
|
|
3266
|
+
fi
|
|
3267
|
+
exit "$code"
|
|
3268
|
+
fi
|
|
3269
|
+
if [ -n "${GITHUB_RUN_ID-}" ]; then
|
|
3270
|
+
body="$body
|
|
3271
|
+
|
|
3272
|
+
_Posted by the trigger job ${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY-}/actions/runs/$GITHUB_RUN_ID._"
|
|
3273
|
+
fi
|
|
3274
|
+
if ! file=$(mktemp "$trigger_tmp/harness-trigger-comment.XXXXXX"); then
|
|
3275
|
+
echo "::error::remote-run.sh: trigger: cannot create the comment file for issue #$issue_number under '$trigger_tmp'"
|
|
3276
|
+
exit "$EXIT_GH"
|
|
3277
|
+
fi
|
|
3278
|
+
{ printf '%s\n\n' "$body"; forge_marker "$event" "$branch"; } >"$file"
|
|
3279
|
+
if ! gh_call issue comment "$issue_number" --repo "${GITHUB_REPOSITORY-}" --body-file "$file"; then
|
|
3280
|
+
echo "::error::remote-run.sh: trigger: the comment on issue #$issue_number could not be posted: $GH_ERR"
|
|
3281
|
+
code="$EXIT_GH"
|
|
3282
|
+
fi
|
|
3283
|
+
rm -f "$file"
|
|
3284
|
+
if ! gh_call issue edit "$issue_number" --repo "${GITHUB_REPOSITORY-}" --remove-label "$trigger_label"; then
|
|
3285
|
+
echo "::warning::remote-run.sh: trigger: removing the label '$trigger_label' from issue #$issue_number failed: $GH_ERR"
|
|
3286
|
+
fi
|
|
3287
|
+
if [ "$event" = started ]; then
|
|
3288
|
+
if ! { forge_repo_var && forge_set_state "$issue_number" running; }; then
|
|
3289
|
+
echo "::warning::remote-run.sh: trigger: setting the label '${STATE_LABEL_PREFIX}running' on issue #$issue_number failed: $GH_ERR"
|
|
3290
|
+
fi
|
|
3291
|
+
fi
|
|
3292
|
+
exit "$code"
|
|
3293
|
+
}
|
|
3294
|
+
|
|
3295
|
+
# trigger_refuse <reason> <way on> — print the reason, comment both, exit 2.
|
|
3296
|
+
trigger_refuse() {
|
|
3297
|
+
echo "remote-run.sh: trigger: refused, nothing sent: $1" >&2
|
|
3298
|
+
trigger_finish "$EXIT_REFUSED" "No run started: $1
|
|
3299
|
+
|
|
3300
|
+
$2" refused
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
# trigger_bot_listed <login> — 0 when <login> is an exact entry of
|
|
3304
|
+
# HARNESS_TRIGGER_ALLOWED_BOTS, split on `,` with each entry trimmed.
|
|
3305
|
+
trigger_bot_listed() {
|
|
3306
|
+
local rest="${HARNESS_TRIGGER_ALLOWED_BOTS-}," entry
|
|
3307
|
+
while [ -n "$rest" ]; do
|
|
3308
|
+
entry=${rest%%,*}
|
|
3309
|
+
rest=${rest#*,}
|
|
3310
|
+
entry=${entry#"${entry%%[![:space:]]*}"}
|
|
3311
|
+
entry=${entry%"${entry##*[![:space:]]}"}
|
|
3312
|
+
[ -n "$entry" ] && [ "$entry" = "$1" ] && return 0
|
|
3313
|
+
done
|
|
3314
|
+
return 1
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3317
|
+
# authorise_actor <login> <type> — the one actor check, shared by `trigger` and
|
|
3318
|
+
# `control`: 0 when authorised. Otherwise AUTH_WHY holds one sentence and the
|
|
3319
|
+
# status names the arm: 1 `ghost`, empty or not a login shape; 2 a non-`User`
|
|
3320
|
+
# not listed in HARNESS_TRIGGER_ALLOWED_BOTS, decided with no permission call,
|
|
3321
|
+
# because the permission API answers `none` or 404 for a bot; 3 a `User` whose
|
|
3322
|
+
# permission is not `admin` or `write` (AUTH_PERMISSION holds it); 4 that
|
|
3323
|
+
# permission call failed (GH_ERR holds why). Prints nothing and never posts.
|
|
3324
|
+
AUTH_WHY=""
|
|
3325
|
+
AUTH_PERMISSION=""
|
|
3326
|
+
authorise_actor() {
|
|
3327
|
+
local login="$1" type="$2"
|
|
3328
|
+
AUTH_WHY=""
|
|
3329
|
+
AUTH_PERMISSION=""
|
|
3330
|
+
if [ "$login" = ghost ] || [ -z "$login" ] \
|
|
3331
|
+
|| ! { [[ "$login" =~ ^[A-Za-z0-9][A-Za-z0-9-]*$ ]] \
|
|
3332
|
+
|| { [ "$type" = Bot ] && [[ "$login" =~ ^[A-Za-z0-9][A-Za-z0-9-]*\[bot\]$ ]]; }; }; then
|
|
3333
|
+
AUTH_WHY="the actor is an account GitHub does not name, or not a login."
|
|
3334
|
+
return 1
|
|
3335
|
+
fi
|
|
3336
|
+
if [ "$type" != User ]; then
|
|
3337
|
+
trigger_bot_listed "$login" && return 0
|
|
3338
|
+
AUTH_WHY="@$login is not a person, and is not listed in HARNESS_TRIGGER_ALLOWED_BOTS."
|
|
3339
|
+
return 2
|
|
3340
|
+
fi
|
|
3341
|
+
if ! gh_call api "repos/${GITHUB_REPOSITORY-}/collaborators/$login/permission"; then
|
|
3342
|
+
AUTH_WHY="the permission check for @$login failed ($GH_ERR)."
|
|
3343
|
+
return 4
|
|
3344
|
+
fi
|
|
3345
|
+
AUTH_PERMISSION=$(printf '%s' "$GH_OUT" | jq -r '.permission // empty' 2>/dev/null) || AUTH_PERMISSION=""
|
|
3346
|
+
case "$AUTH_PERMISSION" in
|
|
3347
|
+
admin|write) return 0 ;;
|
|
3348
|
+
esac
|
|
3349
|
+
AUTH_WHY="GitHub reports the permission of @$login as ${AUTH_PERMISSION:-nothing}, not write or admin."
|
|
3350
|
+
return 3
|
|
3351
|
+
}
|
|
3352
|
+
|
|
3353
|
+
# run_by_sha_var <sha> — RUN_BY_SHA_URL: the URL of the `harness run
|
|
3354
|
+
# <branch>` run whose `headSha` is <sha>, a commit just pushed and dispatched,
|
|
3355
|
+
# looked up at most TRIGGER_RUN_LOOKUP_TRIES times, `HARNESS_TRIGGER_LOOKUP_SECS`
|
|
3356
|
+
# apart; 1 when none was listed within that bound, and at once when <sha> is
|
|
3357
|
+
# empty. Never exits. Matched on `headSha` rather than a `createdAt` bound: the
|
|
3358
|
+
# SHA identifies this dispatch exactly and reads no runner clock, where a time
|
|
3359
|
+
# bound still admits an unrelated run created in the same second.
|
|
3360
|
+
RUN_BY_SHA_URL=""
|
|
3361
|
+
run_by_sha_var() {
|
|
3362
|
+
local sha="${1-}" try=1 secs url=""
|
|
3363
|
+
RUN_BY_SHA_URL=""
|
|
3364
|
+
secs="${HARNESS_TRIGGER_LOOKUP_SECS-}"
|
|
3365
|
+
case "$secs" in
|
|
3366
|
+
''|*[!0-9]*) secs="$TRIGGER_LOOKUP_SECS_DEFAULT" ;;
|
|
3367
|
+
esac
|
|
3368
|
+
while [ -n "$sha" ]; do
|
|
3369
|
+
if gh_call run list --workflow "$WORKFLOW_RUN_FILE" --branch "$branch" --json url,displayTitle,headSha --limit 5; then
|
|
3370
|
+
url=$(printf '%s' "$GH_OUT" | jq -r --arg t "harness run $branch" --arg s "$sha" \
|
|
3371
|
+
'[.[]? | select(.displayTitle == $t and .headSha == $s) | .url | strings] | first // empty' 2>/dev/null) || url=""
|
|
3372
|
+
[ -z "$url" ] || break
|
|
3373
|
+
else
|
|
3374
|
+
echo "remote-run.sh: $verb: looking up the run of $branch failed: $GH_ERR" >&2
|
|
3375
|
+
fi
|
|
3376
|
+
[ "$try" -lt "$TRIGGER_RUN_LOOKUP_TRIES" ] || break
|
|
3377
|
+
try=$((try + 1))
|
|
3378
|
+
sleep "$secs"
|
|
3379
|
+
done
|
|
3380
|
+
RUN_BY_SHA_URL="$url"
|
|
3381
|
+
[ -n "$url" ]
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
# trigger_run_url <sha> — `run_by_sha_var`'s URL for the commit `start` just
|
|
3385
|
+
# pushed; the branch's filtered run list when none appears. Never fails.
|
|
3386
|
+
trigger_run_url() {
|
|
3387
|
+
local url=""
|
|
3388
|
+
! run_by_sha_var "${1-}" || url="$RUN_BY_SHA_URL"
|
|
3389
|
+
# A derived name is `[a-z0-9_]` only, so it needs no encoding in the query.
|
|
3390
|
+
[ -n "$url" ] || url="${GITHUB_SERVER_URL:-https://github.com}/${GITHUB_REPOSITORY-}/actions/workflows/${WORKFLOW_RUN_FILE}?query=branch%3A$branch"
|
|
3391
|
+
printf '%s\n' "$url"
|
|
3392
|
+
}
|
|
3393
|
+
|
|
3394
|
+
verb_trigger() {
|
|
3395
|
+
local LC_ALL=C
|
|
3396
|
+
local action label title body html_url state login sender_type source=""
|
|
3397
|
+
local forge="" target="" default name_file status prompt errfile last url sha
|
|
3398
|
+
local fallback retry_then retry_again task_what
|
|
3399
|
+
case "${GITHUB_EVENT_NAME-}" in
|
|
3400
|
+
issues) trigger_source=issue ;;
|
|
3401
|
+
repository_dispatch) trigger_source=dispatch ;;
|
|
3402
|
+
*)
|
|
3403
|
+
echo "remote-run.sh: trigger handles GITHUB_EVENT_NAME issues or repository_dispatch, not '${GITHUB_EVENT_NAME-}'" >&2
|
|
3404
|
+
exit "$EXIT_USAGE" ;;
|
|
3405
|
+
esac
|
|
3406
|
+
if [ -z "${GITHUB_EVENT_PATH-}" ] || [ ! -f "$GITHUB_EVENT_PATH" ] || [ ! -r "$GITHUB_EVENT_PATH" ]; then
|
|
3407
|
+
echo "remote-run.sh: trigger: cannot read the event file '${GITHUB_EVENT_PATH-}'" >&2
|
|
3408
|
+
exit "$EXIT_USAGE"
|
|
3409
|
+
fi
|
|
3410
|
+
hr_have_jq || { echo "remote-run.sh: trigger needs jq" >&2; exit "$EXIT_USAGE"; }
|
|
3411
|
+
|
|
3412
|
+
if [ "$trigger_source" = issue ]; then
|
|
3413
|
+
{ event_field '.action // ""' && action="$EVENT_VALUE" \
|
|
3414
|
+
&& event_field '.label.name // ""' && label="$EVENT_VALUE" \
|
|
3415
|
+
&& event_field '.issue.number // ""' && issue_number="$EVENT_VALUE" \
|
|
3416
|
+
&& event_field '.issue.title // ""' && title="$EVENT_VALUE" \
|
|
3417
|
+
&& event_field '.issue.body // ""' && body="$EVENT_VALUE" \
|
|
3418
|
+
&& event_field '.issue.html_url // ""' && html_url="$EVENT_VALUE" \
|
|
3419
|
+
&& event_field '.issue.state // ""' && state="$EVENT_VALUE" \
|
|
3420
|
+
&& event_field '.sender.login // ""' && login="$EVENT_VALUE" \
|
|
3421
|
+
&& event_field '.sender.type // ""' && sender_type="$EVENT_VALUE"; } || {
|
|
3422
|
+
echo "remote-run.sh: trigger: '$GITHUB_EVENT_PATH' is not a readable event" >&2
|
|
3423
|
+
exit "$EXIT_USAGE"
|
|
3424
|
+
}
|
|
3425
|
+
trigger_label="${HARNESS_TRIGGER_LABEL:-$DEFAULT_TRIGGER_LABEL}"
|
|
3426
|
+
if [ -z "${HARNESS_TRIGGER_LABEL-}" ] && [ "$label" = "$LEGACY_TRIGGER_LABEL" ]; then
|
|
3427
|
+
trigger_label="$LEGACY_TRIGGER_LABEL"
|
|
3428
|
+
fi
|
|
3429
|
+
if [ "$action" != labeled ] || [ "$label" != "$trigger_label" ]; then
|
|
3430
|
+
echo "remote-run.sh: trigger: ignored, not the label '$trigger_label' being applied"
|
|
3431
|
+
return 0
|
|
3432
|
+
fi
|
|
3433
|
+
case "$issue_number" in
|
|
3434
|
+
''|*[!0-9]*|0*)
|
|
3435
|
+
echo "remote-run.sh: trigger: the event carries no issue number" >&2
|
|
3436
|
+
exit "$EXIT_USAGE" ;;
|
|
3437
|
+
esac
|
|
3438
|
+
fallback="issue_$issue_number"
|
|
3439
|
+
retry_then="re-apply the label \`$trigger_label\`"
|
|
3440
|
+
retry_again="Re-apply the label \`$trigger_label\` to try again."
|
|
3441
|
+
task_what="this issue's task"
|
|
3442
|
+
else
|
|
3443
|
+
# GitHub puts a repository_dispatch's `event_type` in `.action`.
|
|
3444
|
+
{ event_field '.action // ""' && action="$EVENT_VALUE" \
|
|
3445
|
+
&& event_field '.client_payload.title // ""' && title="$EVENT_VALUE" \
|
|
3446
|
+
&& event_field '.client_payload.body // ""' && body="$EVENT_VALUE" \
|
|
3447
|
+
&& event_field '.client_payload.source // ""' && source="$EVENT_VALUE"; } || {
|
|
3448
|
+
echo "remote-run.sh: trigger: '$GITHUB_EVENT_PATH' is not a readable event" >&2
|
|
3449
|
+
exit "$EXIT_USAGE"
|
|
3450
|
+
}
|
|
3451
|
+
if [ "$action" != "$TRIGGER_DISPATCH_EVENT_TYPE" ]; then
|
|
3452
|
+
echo "remote-run.sh: trigger: ignored, a repository_dispatch of type '$action', not '$TRIGGER_DISPATCH_EVENT_TYPE'"
|
|
3453
|
+
return 0
|
|
3454
|
+
fi
|
|
3455
|
+
fallback="task_${GITHUB_RUN_ID-}"
|
|
3456
|
+
retry_then="send the \`$TRIGGER_DISPATCH_EVENT_TYPE\` dispatch again"
|
|
3457
|
+
retry_again="Send the \`$TRIGGER_DISPATCH_EVENT_TYPE\` dispatch again to try again."
|
|
3458
|
+
task_what="the dispatched task"
|
|
3459
|
+
fi
|
|
3460
|
+
|
|
3461
|
+
trigger_tmp="${RUNNER_TEMP-}"
|
|
3462
|
+
if [ -z "$trigger_tmp" ] || [ ! -d "$trigger_tmp" ]; then
|
|
3463
|
+
trigger_tmp=$(mktemp -d) || { echo "remote-run.sh: trigger: mktemp failed" >&2; exit "$EXIT_USAGE"; }
|
|
3464
|
+
fi
|
|
3465
|
+
|
|
3466
|
+
if [ "$trigger_source" = dispatch ] && [ -z "$title" ]; then
|
|
3467
|
+
trigger_refuse "the dispatch's \`client_payload\` carries no \`title\`." \
|
|
3468
|
+
"Send it as \`{\"event_type\": \"$TRIGGER_DISPATCH_EVENT_TYPE\", \"client_payload\": {\"title\": …, \"body\": …, \"source\": …}}\`, with a non-empty \`title\`."
|
|
3469
|
+
fi
|
|
3470
|
+
|
|
3471
|
+
if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
|
|
3472
|
+
trigger_refuse "the repository variable \`HARNESS_REMOTE_STOP\` is set, which stops every start." \
|
|
3473
|
+
"Clear it under **Settings → Secrets and variables → Actions → Variables**, then $retry_then."
|
|
3474
|
+
fi
|
|
3475
|
+
|
|
3476
|
+
forge=$(hr_forge "$root") || forge=""
|
|
3477
|
+
target=$(hr_execution_target "$root") || target=""
|
|
3478
|
+
if [ "$forge" != github ] || [ "$target" != github-actions ]; then
|
|
3479
|
+
trigger_refuse "the default branch's \`harness.config.json\` does not turn the $trigger_source trigger on: it needs \`forge\` set to \`github\` (it is ${forge:-not set or unreadable}) and \`execution.target\` set to \`github-actions\` (it is ${target:-unreadable})." \
|
|
3480
|
+
"Set both keys on the default branch, then $retry_then."
|
|
3481
|
+
fi
|
|
3482
|
+
|
|
3483
|
+
if [ "$trigger_source" = issue ]; then
|
|
3484
|
+
if [ "$state" != open ]; then
|
|
3485
|
+
trigger_refuse "this issue is not open." "Reopen it, then re-apply the label \`$trigger_label\`."
|
|
3486
|
+
fi
|
|
3487
|
+
|
|
3488
|
+
status=0
|
|
3489
|
+
authorise_actor "$login" "$sender_type" || status=$?
|
|
3490
|
+
case "$status" in
|
|
3491
|
+
0) ;;
|
|
3492
|
+
1) trigger_refuse "the label was applied by an account GitHub does not name (a deleted account shows as \`ghost\`)." \
|
|
3493
|
+
"A collaborator with write access can re-apply the label \`$trigger_label\`." ;;
|
|
3494
|
+
2) trigger_refuse "@$login is not a person, and is not listed in the repository variable \`HARNESS_TRIGGER_ALLOWED_BOTS\`." \
|
|
3495
|
+
"Add \`$login\` to that comma-separated list to let it start runs, or have a collaborator with write access apply the label \`$trigger_label\`." ;;
|
|
3496
|
+
3) trigger_refuse "could not confirm write access for @$login: GitHub reports their permission as \`${AUTH_PERMISSION:-nothing}\`." \
|
|
3497
|
+
"Only a collaborator with write, maintain or admin access starts a run by labelling an issue; one of them can re-apply the label \`$trigger_label\`." ;;
|
|
3498
|
+
*) trigger_refuse "could not confirm write access for @$login: the permission check failed ($GH_ERR)." \
|
|
3499
|
+
"Re-apply the label \`$trigger_label\` to try again." ;;
|
|
3500
|
+
esac
|
|
3501
|
+
fi
|
|
3502
|
+
|
|
3503
|
+
# The name check reads origin/<defaultBranch>; a failed fetch leaves it to say so.
|
|
3504
|
+
default=$(hr_default_branch "$root") || default=""
|
|
3505
|
+
if [ -n "$default" ]; then
|
|
3506
|
+
git -C "$root" fetch --quiet origin "$default" >&2 || echo "remote-run.sh: trigger: fetching origin $default failed" >&2
|
|
3507
|
+
fi
|
|
3508
|
+
name_file=$(mktemp "$trigger_tmp/harness-trigger-branch.XXXXXX") || trigger_refuse \
|
|
3509
|
+
"the branch name could not be derived (mktemp failed)." "$retry_again"
|
|
3510
|
+
status=0
|
|
3511
|
+
hr_derive_branch "$root" "$title" "$fallback" "" "$GH" >"$name_file" || status=$?
|
|
3512
|
+
branch=""
|
|
3513
|
+
IFS= read -r branch <"$name_file" || :
|
|
3514
|
+
rm -f "$name_file"
|
|
3515
|
+
case "$status" in
|
|
3516
|
+
0) ;;
|
|
3517
|
+
3) if [ "$trigger_source" = issue ]; then
|
|
3518
|
+
trigger_refuse "every branch name derived from this issue's title, through the suffix \`_99\`, is already taken." \
|
|
3519
|
+
"Retitle the issue, then re-apply the label \`$trigger_label\`."
|
|
3520
|
+
fi
|
|
3521
|
+
trigger_refuse "every branch name derived from the dispatch's \`title\`, through the suffix \`_99\`, is already taken." \
|
|
3522
|
+
"Send the dispatch again with another \`title\`." ;;
|
|
3523
|
+
*) trigger_refuse "the branch name for $task_what could not be checked (${HR_TAKEN_WHY:-no usable name})." \
|
|
3524
|
+
"$retry_again" ;;
|
|
3525
|
+
esac
|
|
3526
|
+
|
|
3527
|
+
prompt=$(mktemp "$trigger_tmp/harness-trigger-prompt.XXXXXX") || trigger_finish "$EXIT_PLACEMENT" \
|
|
3528
|
+
"No run started: the task prompt for \`$branch\` could not be written. $retry_again" refused
|
|
3529
|
+
if [ "$trigger_source" = issue ]; then
|
|
3530
|
+
printf '# %s\n\n%s\n\n---\n\nStarted from %s by @%s, who applied the label `%s` at %s. This is the issue'"'"'s text at that moment; later edits to the issue do not reach this run.\n' \
|
|
3531
|
+
"$title" "$body" "$html_url" "$login" "$trigger_label" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$prompt"
|
|
3532
|
+
else
|
|
3533
|
+
[ -z "$source" ] || source=", from $source"
|
|
3534
|
+
printf '# %s\n\n%s\n\n---\n\nStarted by a repository_dispatch event of type `%s`%s at %s.\n' \
|
|
3535
|
+
"$title" "$body" "$TRIGGER_DISPATCH_EVENT_TYPE" "$source" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$prompt"
|
|
3536
|
+
fi
|
|
3537
|
+
|
|
3538
|
+
errfile=$(mktemp "$trigger_tmp/harness-trigger-start.XXXXXX") || errfile=/dev/null
|
|
3539
|
+
status=0
|
|
3540
|
+
bash "$script_dir/remote-run.sh" start "$branch" --prompt-file "$prompt" --repo "$root" 2>"$errfile" || status=$?
|
|
3541
|
+
last=""
|
|
3542
|
+
if [ "$errfile" != /dev/null ]; then
|
|
3543
|
+
cat "$errfile" >&2
|
|
3544
|
+
last=$(grep -v '^[[:space:]]*$' "$errfile" | tail -n 1)
|
|
3545
|
+
rm -f "$errfile"
|
|
3546
|
+
fi
|
|
3547
|
+
case "$status" in
|
|
3548
|
+
0) ;;
|
|
3549
|
+
3)
|
|
3550
|
+
echo "remote-run.sh: trigger: $branch is pushed, but its dispatch failed" >&2
|
|
3551
|
+
trigger_finish "$EXIT_GH" "The branch \`$branch\` was pushed with $task_what, but dispatching its run failed:
|
|
3552
|
+
|
|
3553
|
+
\`\`\`
|
|
3554
|
+
$last
|
|
3555
|
+
\`\`\`
|
|
3556
|
+
|
|
3557
|
+
Start it by hand: **Actions → \`$WORKFLOW_RUN_FILE\` → Run workflow**, with *Use workflow from* set to \`$branch\`, \`action\` \`run\` and \`branch\` \`$branch\`." refused ;;
|
|
3558
|
+
*)
|
|
3559
|
+
echo "remote-run.sh: trigger: the start of $branch failed (exit $status)" >&2
|
|
3560
|
+
trigger_finish "$EXIT_PLACEMENT" "No run started: placing $task_what on the branch \`$branch\` failed:
|
|
3561
|
+
|
|
3562
|
+
\`\`\`
|
|
3563
|
+
$last
|
|
3564
|
+
\`\`\`
|
|
3565
|
+
|
|
3566
|
+
$retry_again" refused ;;
|
|
3567
|
+
esac
|
|
3568
|
+
|
|
3569
|
+
# The ref `start`'s `hr_push_landed` confirmed equal to the pushed `HEAD`;
|
|
3570
|
+
# `start_remove_copy` deletes only the local branch.
|
|
3571
|
+
sha=$(git -C "$root" rev-parse --verify --quiet "refs/remotes/origin/$branch^{commit}") || sha=""
|
|
3572
|
+
url=$(trigger_run_url "$sha")
|
|
3573
|
+
if [ "$trigger_source" = issue ]; then
|
|
3574
|
+
echo "remote-run.sh: trigger: started $branch from issue #$issue_number: $url"
|
|
3575
|
+
trigger_finish "$EXIT_OK" "Started a harness run on the branch \`$branch\`: $url
|
|
3576
|
+
|
|
3577
|
+
The task is this issue's title and body as they were when the label \`$trigger_label\` was applied; later edits to the issue do not reach this run. Re-applying the label starts another run, on the next indexed branch." started
|
|
3578
|
+
fi
|
|
3579
|
+
echo "remote-run.sh: trigger: started $branch from a repository_dispatch: $url"
|
|
3580
|
+
trigger_finish "$EXIT_OK" "Started a harness run on the branch \`$branch\`: $url
|
|
3581
|
+
|
|
3582
|
+
The task is the dispatch's \`client_payload\` title and body. Sending the same dispatch again starts another run, on the next indexed branch." started
|
|
3583
|
+
}
|
|
3584
|
+
|
|
3585
|
+
# ---------------------------------------------------------------------------
|
|
3586
|
+
# THE FORGE SURFACE — the one place this script reads or writes an issue or a
|
|
3587
|
+
# pull request for a run. Every function sets globals rather than printing,
|
|
3588
|
+
# never exits, and reports a failure as one `remote-run.sh: …` line on stderr.
|
|
3589
|
+
#
|
|
3590
|
+
# THE TARGET RULE. A comment goes to the open same-repository pull request
|
|
3591
|
+
# whose head is the branch when `forge_recognised` holds for that branch, else
|
|
3592
|
+
# to the issue the run was started from (`FORGE_ISSUE`), else nowhere. The
|
|
3593
|
+
# state label goes on that issue and on that pull request, each when known.
|
|
3594
|
+
#
|
|
3595
|
+
# THE LABEL IS A VIEW, NEVER AN AUTHORITY. The run list is the authority
|
|
3596
|
+
# (`remote_state`); the harness overwrites any state label set by hand, and
|
|
3597
|
+
# nothing reads one back to decide anything.
|
|
3598
|
+
# ---------------------------------------------------------------------------
|
|
3599
|
+
|
|
3600
|
+
# forge_on — 0 when `forge` is `github` and `execution.target` is
|
|
3601
|
+
# `github-actions`; the shell mirror of `forgeTriggerApplies`.
|
|
3602
|
+
forge_on() {
|
|
3603
|
+
[ "$(hr_forge "$root" 2>/dev/null)" = github ] \
|
|
3604
|
+
&& [ "$(hr_execution_target "$root" 2>/dev/null)" = github-actions ]
|
|
3605
|
+
}
|
|
3606
|
+
|
|
3607
|
+
# forge_repo_var — FORGE_REPO (owner/name) and FORGE_SERVER, from the runner's
|
|
3608
|
+
# environment when it names the repository, else one `gh repo view`; a success
|
|
3609
|
+
# is kept for the invocation.
|
|
3610
|
+
FORGE_REPO=""
|
|
3611
|
+
FORGE_SERVER=""
|
|
3612
|
+
FORGE_REPO_KNOWN=0
|
|
3613
|
+
forge_repo_var() {
|
|
3614
|
+
local repo
|
|
3615
|
+
[ "$FORGE_REPO_KNOWN" -eq 0 ] || return 0
|
|
3616
|
+
if [ -n "${GITHUB_REPOSITORY-}" ]; then
|
|
3617
|
+
repo="$GITHUB_REPOSITORY"
|
|
3618
|
+
else
|
|
3619
|
+
if ! gh_call repo view --json nameWithOwner; then
|
|
3620
|
+
echo "remote-run.sh: reading the repository's name failed: $GH_ERR" >&2
|
|
3621
|
+
return 1
|
|
3622
|
+
fi
|
|
3623
|
+
repo=$(printf '%s' "$GH_OUT" | jq -r '.nameWithOwner // empty' 2>/dev/null) || repo=""
|
|
3624
|
+
fi
|
|
3625
|
+
# Interpolated into every API path below, so its shape is checked once here.
|
|
3626
|
+
if ! [[ "$repo" =~ ^[A-Za-z0-9._-]+/[A-Za-z0-9._-]+$ ]]; then
|
|
3627
|
+
GH_ERR="the repository name '$repo' is not owner/name"
|
|
3628
|
+
echo "remote-run.sh: reading the repository's name failed: $GH_ERR" >&2
|
|
3629
|
+
return 1
|
|
3630
|
+
fi
|
|
3631
|
+
FORGE_REPO="$repo"
|
|
3632
|
+
FORGE_SERVER="${GITHUB_SERVER_URL:-https://github.com}"
|
|
3633
|
+
FORGE_SERVER="${FORGE_SERVER%/}"
|
|
3634
|
+
FORGE_REPO_KNOWN=1
|
|
3635
|
+
}
|
|
3636
|
+
|
|
3637
|
+
# forge_fetch_branch <branch> — update refs/remotes/origin/<branch>, at most
|
|
3638
|
+
# once per branch per invocation. The refspec is explicit because a single-branch
|
|
3639
|
+
# checkout's bare `fetch origin <branch>` updates only FETCH_HEAD. A failure is
|
|
3640
|
+
# one line and tolerated.
|
|
3641
|
+
FORGE_FETCHED=" "
|
|
3642
|
+
forge_fetch_branch() {
|
|
3643
|
+
local err
|
|
3644
|
+
case "$FORGE_FETCHED" in *" $1 "*) return 0 ;; esac
|
|
3645
|
+
FORGE_FETCHED="$FORGE_FETCHED$1 "
|
|
3646
|
+
if ! err=$(git -C "$root" fetch --quiet origin "+refs/heads/$1:refs/remotes/origin/$1" 2>&1 >/dev/null); then
|
|
3647
|
+
echo "remote-run.sh: fetching origin $1 failed: ${err%%$'\n'*}" >&2
|
|
3648
|
+
fi
|
|
3649
|
+
return 0
|
|
3650
|
+
}
|
|
3651
|
+
|
|
3652
|
+
# forge_marker <event> <branch> [<question>] — the one producer of a comment's
|
|
3653
|
+
# marker line, built from COMMENT_MARKER.
|
|
3654
|
+
forge_marker() {
|
|
3655
|
+
if [ -n "${3-}" ]; then
|
|
3656
|
+
printf '%s event=%s branch=%s question=%s -->\n' "$COMMENT_MARKER" "$1" "$2" "$3"
|
|
3657
|
+
else
|
|
3658
|
+
printf '%s event=%s branch=%s -->\n' "$COMMENT_MARKER" "$1" "$2"
|
|
3659
|
+
fi
|
|
3660
|
+
}
|
|
3661
|
+
|
|
3662
|
+
# forge_issue_var <branch> — FORGE_ISSUE from the last provenance line
|
|
3663
|
+
# `verb_trigger` writes into the branch's committed task prompt, matched against
|
|
3664
|
+
# this repository's own issue URL only; empty when there is none. Also sets
|
|
3665
|
+
# FORGE_TRIGGER_LABEL, the label that same provenance line names; empty when
|
|
3666
|
+
# it names none.
|
|
3667
|
+
FORGE_ISSUE=""
|
|
3668
|
+
FORGE_TRIGGER_LABEL=""
|
|
3669
|
+
forge_issue_var() {
|
|
3670
|
+
local state_rel rel prompt line rest num prefix label
|
|
3671
|
+
FORGE_ISSUE=""
|
|
3672
|
+
FORGE_TRIGGER_LABEL=""
|
|
3673
|
+
state_rel=$(hr_state_dir "$root" 2>/dev/null) || state_rel=""
|
|
3674
|
+
if [ -z "$state_rel" ]; then
|
|
3675
|
+
echo "remote-run.sh: cannot resolve the state directory under '$root'" >&2
|
|
3676
|
+
return 1
|
|
3677
|
+
fi
|
|
3678
|
+
rel=$(hr_task_prompt_rel "$state_rel" "$1")
|
|
3679
|
+
prompt=$(git -C "$root" show "refs/remotes/origin/$1:$rel" 2>/dev/null) || return 0
|
|
3680
|
+
prefix="Started from $FORGE_SERVER/$FORGE_REPO/issues/"
|
|
3681
|
+
while IFS= read -r line; do
|
|
3682
|
+
case "$line" in
|
|
3683
|
+
"$prefix"*) ;;
|
|
3684
|
+
*) continue ;;
|
|
3685
|
+
esac
|
|
3686
|
+
rest=${line#"$prefix"}
|
|
3687
|
+
num=${rest%%[!0-9]*}
|
|
3688
|
+
[ -n "$num" ] || continue
|
|
3689
|
+
case "${rest#"$num"}" in
|
|
3690
|
+
' by @'*)
|
|
3691
|
+
FORGE_ISSUE="$num"
|
|
3692
|
+
FORGE_TRIGGER_LABEL=""
|
|
3693
|
+
case "$line" in
|
|
3694
|
+
*'who applied the label `'*'`'*)
|
|
3695
|
+
label=${line#*'who applied the label `'}
|
|
3696
|
+
label=${label%%'`'*}
|
|
3697
|
+
[ -z "$label" ] || FORGE_TRIGGER_LABEL="$label" ;;
|
|
3698
|
+
esac ;;
|
|
3699
|
+
esac
|
|
3700
|
+
done <<<"$prompt"
|
|
3701
|
+
return 0
|
|
3702
|
+
}
|
|
3703
|
+
|
|
3704
|
+
# forge_recognised <branch> — the harness-branch test, read from committed
|
|
3705
|
+
# state: 0 when origin's copy of the branch carries its flow-progress ledger.
|
|
3706
|
+
forge_recognised() {
|
|
3707
|
+
local state_rel
|
|
3708
|
+
state_rel=$(hr_state_dir "$root" 2>/dev/null) || return 1
|
|
3709
|
+
[ -n "$state_rel" ] || return 1
|
|
3710
|
+
git -C "$root" cat-file -e "refs/remotes/origin/$1:${state_rel%/}/flow_progress/$1_progress.md" 2>/dev/null
|
|
3711
|
+
}
|
|
3712
|
+
|
|
3713
|
+
# forge_pr_var <branch> — FORGE_PR, the open pull request whose head is
|
|
3714
|
+
# <branch> in this repository (a fork's same-named head is skipped), or empty.
|
|
3715
|
+
FORGE_PR=""
|
|
3716
|
+
forge_pr_var() {
|
|
3717
|
+
FORGE_PR=""
|
|
3718
|
+
if ! gh_call pr list --repo "$FORGE_REPO" --head "$1" --state open --json number,isCrossRepository --limit 10; then
|
|
3719
|
+
echo "remote-run.sh: listing the open pull requests of $1 failed: $GH_ERR" >&2
|
|
3720
|
+
return 1
|
|
3721
|
+
fi
|
|
3722
|
+
if ! FORGE_PR=$(printf '%s' "$GH_OUT" | jq -r \
|
|
3723
|
+
'if type == "array" then [.[] | select(.isCrossRepository == false) | .number | numbers] | first // empty else error end' 2>/dev/null); then
|
|
3724
|
+
FORGE_PR=""
|
|
3725
|
+
GH_ERR="its pr list is not the expected JSON"
|
|
3726
|
+
echo "remote-run.sh: listing the open pull requests of $1 failed: $GH_ERR" >&2
|
|
3727
|
+
return 1
|
|
3728
|
+
fi
|
|
3729
|
+
return 0
|
|
3730
|
+
}
|
|
3731
|
+
|
|
3732
|
+
# forge_comment <number> <event> <branch> <body_file> [<question>] — append the
|
|
3733
|
+
# marker to <body_file> and post it on issue or pull request <number>.
|
|
3734
|
+
forge_comment() {
|
|
3735
|
+
local number="$1" event="$2" branch="$3" file="$4" question="${5-}" status
|
|
3736
|
+
if ! { printf '\n'; forge_marker "$event" "$branch" "$question"; } >>"$file"; then
|
|
3737
|
+
echo "remote-run.sh: cannot append the marker to '$file'" >&2
|
|
3738
|
+
return 1
|
|
3739
|
+
fi
|
|
3740
|
+
gh_call api --method POST "repos/$FORGE_REPO/issues/$number/comments" -F "body=@$file"
|
|
3741
|
+
status=$?
|
|
3742
|
+
[ "$status" -eq 0 ] || echo "remote-run.sh: the $event comment on #$number could not be posted: $GH_ERR" >&2
|
|
3743
|
+
return "$status"
|
|
3744
|
+
}
|
|
3745
|
+
|
|
3746
|
+
# forge_set_state <number> <state> — leave `STATE_LABEL_PREFIX<state>` as the
|
|
3747
|
+
# one state label on <number>. A failed add creates the label and retries once,
|
|
3748
|
+
# never more.
|
|
3749
|
+
forge_set_state() {
|
|
3750
|
+
local number="$1" state="$2" label names name encoded color try
|
|
3751
|
+
case " $RUN_STATES " in
|
|
3752
|
+
*" $state "*) ;;
|
|
3753
|
+
*) echo "remote-run.sh: '$state' is not one of: $RUN_STATES" >&2; return 1 ;;
|
|
3754
|
+
esac
|
|
3755
|
+
label="$STATE_LABEL_PREFIX$state"
|
|
3756
|
+
if ! gh_call api "repos/$FORGE_REPO/issues/$number/labels"; then
|
|
3757
|
+
echo "remote-run.sh: reading the labels of #$number failed: $GH_ERR" >&2
|
|
3758
|
+
return 1
|
|
3759
|
+
fi
|
|
3760
|
+
if ! names=$(printf '%s' "$GH_OUT" | jq -r --arg p "$STATE_LABEL_PREFIX" --arg t "$label" \
|
|
3761
|
+
'if type == "array" then .[] | .name | strings | select(startswith($p) and . != $t) else error end' 2>/dev/null); then
|
|
3762
|
+
GH_ERR="its label list is not the expected JSON"
|
|
3763
|
+
echo "remote-run.sh: reading the labels of #$number failed: $GH_ERR" >&2
|
|
3764
|
+
return 1
|
|
3765
|
+
fi
|
|
3766
|
+
while IFS= read -r name; do
|
|
3767
|
+
[ -n "$name" ] || continue
|
|
3768
|
+
encoded=$(jq -rn --arg s "$name" '$s|@uri')
|
|
3769
|
+
gh_call api --method DELETE "repos/$FORGE_REPO/issues/$number/labels/$encoded" \
|
|
3770
|
+
|| echo "remote-run.sh: removing the label '$name' from #$number failed: $GH_ERR" >&2
|
|
3771
|
+
done <<<"$names"
|
|
3772
|
+
for try in 1 2; do
|
|
3773
|
+
gh_call api --method POST "repos/$FORGE_REPO/issues/$number/labels" -f "labels[]=$label" && return 0
|
|
3774
|
+
if [ "$try" -eq 2 ]; then
|
|
3775
|
+
echo "remote-run.sh: adding the label '$label' to #$number failed: $GH_ERR" >&2
|
|
3776
|
+
return 1
|
|
3777
|
+
fi
|
|
3778
|
+
case "$state" in
|
|
3779
|
+
running) color=1d76db ;;
|
|
3780
|
+
parked) color=fbca04 ;;
|
|
3781
|
+
paused) color=c5def5 ;;
|
|
3782
|
+
done) color=0e8a16 ;;
|
|
3783
|
+
failed) color=b60205 ;;
|
|
3784
|
+
*) color=6a737d ;;
|
|
3785
|
+
esac
|
|
3786
|
+
gh_call api --method POST "repos/$FORGE_REPO/labels" -f "name=$label" -f "color=$color" \
|
|
3787
|
+
-f "description=Set by the harness from its run list; a hand-applied state label is overwritten." \
|
|
3788
|
+
|| echo "remote-run.sh: creating the label '$label' failed: $GH_ERR" >&2
|
|
3789
|
+
done
|
|
3790
|
+
}
|
|
3791
|
+
|
|
3792
|
+
# forge_utc <epoch> — print <epoch> as a UTC time, or nothing. `date -r` takes an
|
|
3793
|
+
# epoch on BSD and a reference file on GNU, hence the `-d @` fallback.
|
|
3794
|
+
forge_utc() {
|
|
3795
|
+
local out
|
|
3796
|
+
case "${1-}" in ''|*[!0-9]*) return 0 ;; esac
|
|
3797
|
+
out=$(date -u -r "$1" '+%Y-%m-%d %H:%M UTC' 2>/dev/null) || out=""
|
|
3798
|
+
[ -n "$out" ] || out=$(date -u -d "@$1" '+%Y-%m-%d %H:%M UTC' 2>/dev/null) || out=""
|
|
3799
|
+
printf '%s' "$out"
|
|
3800
|
+
}
|
|
3801
|
+
|
|
3802
|
+
# forge_question_body <out_file> <branch> <n> <open_count> <clar_dir> [<note>] —
|
|
3803
|
+
# write question <n>'s park comment, without its marker, into <out_file>: the
|
|
3804
|
+
# file's bytes, cut at the last whole line within QUESTION_COMMENT_MAX_BYTES
|
|
3805
|
+
# when it is over it (measured in bytes; `${#…}` counts characters).
|
|
3806
|
+
forge_question_body() {
|
|
3807
|
+
local out="$1" br="$2" n="$3" count="$4" qfile="$5/question_$3.md" note="${6-}" size cut=0 last
|
|
3808
|
+
size=$(wc -c <"$qfile") || return 1
|
|
3809
|
+
size=$((size))
|
|
3810
|
+
{
|
|
3811
|
+
printf 'The run on `%s` is waiting for an answer to question %s.\n\n' "$br" "$n"
|
|
3812
|
+
if [ "$size" -le "$QUESTION_COMMENT_MAX_BYTES" ]; then
|
|
3813
|
+
cat "$qfile"
|
|
3814
|
+
else
|
|
3815
|
+
cut=1
|
|
3816
|
+
head -c "$QUESTION_COMMENT_MAX_BYTES" "$qfile" >"$out.cut"
|
|
3817
|
+
last=$(tail -c 1 "$out.cut")
|
|
3818
|
+
# A non-empty last byte is a partial line; LC_ALL=C keeps sed from
|
|
3819
|
+
# refusing a multi-byte character the byte cut split.
|
|
3820
|
+
if [ -n "$last" ]; then LC_ALL=C sed '$d' "$out.cut"; else cat "$out.cut"; fi
|
|
3821
|
+
fi
|
|
3822
|
+
} >"$out" || return 1
|
|
3823
|
+
{
|
|
3824
|
+
[ -z "$(tail -c 1 "$out")" ] || printf '\n'
|
|
3825
|
+
[ "$cut" -eq 0 ] || printf '\nThis question was cut to fit a comment. The whole file is `%s/%s/question_%s.md` in the run'"'"'s `%s` artifact.\n' \
|
|
3826
|
+
"$HR_REMOTE_CLARIFY_DIR" "$br" "$n" "$STATE_ARTIFACT_NAME"
|
|
3827
|
+
printf '\nAnswer with a comment whose first line is `%s answer %s` and whose following lines are your answer.' "$COMMAND_HANDLE" "$n"
|
|
3828
|
+
[ "$count" -ne 1 ] || printf ' This is the only open question, so `%s` may be left out: `%s answer`.' "$n" "$COMMAND_HANDLE"
|
|
3829
|
+
printf '\n'
|
|
3830
|
+
[ -z "$note" ] || printf '\n%s\n' "$note"
|
|
3831
|
+
[ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
|
|
3832
|
+
} >>"$out"
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3835
|
+
# forge_report <event> <branch> [<note>] — one lifecycle comment (on `parked`,
|
|
3836
|
+
# one per open question) and the state label, by the target rule above.
|
|
3837
|
+
# Always 0.
|
|
3838
|
+
forge_report() {
|
|
3839
|
+
local event="$1" br="$2" note="${3-}" state reason="" resume_at="" when registry_file
|
|
3840
|
+
local target kind text tmp made_tmp="" file trigger_label stopped state_rel="" count n
|
|
3841
|
+
case "$event" in
|
|
3842
|
+
parked|park_loop) state=parked ;;
|
|
3843
|
+
paused) state=paused ;;
|
|
3844
|
+
resumed) state=running ;;
|
|
3845
|
+
failed) state=failed ;;
|
|
3846
|
+
stopped) state=stopped ;;
|
|
3847
|
+
round) state=running ;;
|
|
3848
|
+
completed)
|
|
3849
|
+
echo "remote-run.sh: report: completed is posted by deliver; nothing posted"
|
|
3850
|
+
return 0 ;;
|
|
3851
|
+
launched)
|
|
3852
|
+
echo "remote-run.sh: report: launched is the trigger's own comment; nothing posted"
|
|
3853
|
+
return 0 ;;
|
|
3854
|
+
*)
|
|
3855
|
+
echo "remote-run.sh: report: '$event' is not a reported event; nothing posted"
|
|
3856
|
+
return 0 ;;
|
|
3857
|
+
esac
|
|
3858
|
+
if ! forge_on; then
|
|
3859
|
+
echo "remote-run.sh: report: the forge coupling is off (forge github and execution.target github-actions); nothing posted"
|
|
3860
|
+
return 0
|
|
3861
|
+
fi
|
|
3862
|
+
forge_repo_var || return 0
|
|
3863
|
+
|
|
3864
|
+
if [ "$event" = failed ]; then
|
|
3865
|
+
remote_branch_stopped "$br"
|
|
3866
|
+
stopped=$?
|
|
3867
|
+
if [ "$stopped" -eq 0 ]; then
|
|
3868
|
+
echo "remote-run.sh: report: $br was stopped, and the stop already reported the run; nothing posted"
|
|
3869
|
+
return 0
|
|
3870
|
+
fi
|
|
3871
|
+
[ "$stopped" -eq 1 ] \
|
|
3872
|
+
|| echo "remote-run.sh: report: whether $br was stopped is unknown ($GH_ERR); reporting the failure" >&2
|
|
3873
|
+
fi
|
|
3874
|
+
|
|
3875
|
+
forge_fetch_branch "$br"
|
|
3876
|
+
forge_issue_var "$br" || FORGE_ISSUE=""
|
|
3877
|
+
forge_pr_var "$br" || FORGE_PR=""
|
|
3878
|
+
if [ -n "$FORGE_PR" ] && ! forge_recognised "$br"; then
|
|
3879
|
+
echo "remote-run.sh: report: pull request #$FORGE_PR's head carries no flow-progress ledger; it is not a target"
|
|
3880
|
+
FORGE_PR=""
|
|
3881
|
+
fi
|
|
3882
|
+
if [ -n "$FORGE_PR" ]; then
|
|
3883
|
+
target="$FORGE_PR"; kind=pr
|
|
3884
|
+
elif [ -n "$FORGE_ISSUE" ]; then
|
|
3885
|
+
target="$FORGE_ISSUE"; kind=issue
|
|
3886
|
+
else
|
|
3887
|
+
echo "remote-run.sh: report: $br has no open pull request and no issue it was started from; nothing posted"
|
|
3888
|
+
return 0
|
|
3889
|
+
fi
|
|
3890
|
+
|
|
3891
|
+
# Tested with -f first: hr_registry_get creates an absent registry.
|
|
3892
|
+
registry_file=$(hr_state_path "$root" autonomous_logs/registry.json 2>/dev/null) || registry_file=""
|
|
3893
|
+
if [ -n "$registry_file" ] && [ -f "$registry_file" ]; then
|
|
3894
|
+
reason=$(hr_registry_get "$registry_file" "$br" pause_reason)
|
|
3895
|
+
resume_at=$(hr_registry_get "$registry_file" "$br" usage_resume_at)
|
|
3896
|
+
fi
|
|
3897
|
+
|
|
3898
|
+
case "$event" in
|
|
3899
|
+
paused)
|
|
3900
|
+
if [ "$reason" = usage ]; then
|
|
3901
|
+
when=$(forge_utc "$resume_at")
|
|
3902
|
+
text="The harness run on \`$br\` paused: it reached its usage limit. It resumes by itself after the limit resets${when:+, at $when}."
|
|
3903
|
+
else
|
|
3904
|
+
text="The harness run on \`$br\` paused${reason:+ (reason: \`$reason\`)}. Comment \`${COMMAND_HANDLE} resume\` to continue."
|
|
3905
|
+
fi ;;
|
|
3906
|
+
park_loop)
|
|
3907
|
+
text="The harness run on \`$br\` is on hold: it parked on its questions again and again without progress. Comment \`${COMMAND_HANDLE} clear\` to clear the hold and let it continue." ;;
|
|
3908
|
+
parked)
|
|
3909
|
+
text="The harness run on \`$br\` is waiting for an answer. Its questions are in the run's \`$STATE_ARTIFACT_NAME\` artifact." ;;
|
|
3910
|
+
resumed)
|
|
3911
|
+
text="The harness run on \`$br\` resumed." ;;
|
|
3912
|
+
failed)
|
|
3913
|
+
if [ "$kind" = pr ]; then
|
|
3914
|
+
text="The harness run on \`$br\` failed. Its log is \`run.log\` in the run's \`$STATE_ARTIFACT_NAME\` artifact. To start again, submit a review on this pull request requesting changes."
|
|
3915
|
+
else
|
|
3916
|
+
trigger_label="${FORGE_TRIGGER_LABEL:-${HARNESS_TRIGGER_LABEL:-$DEFAULT_TRIGGER_LABEL}}"
|
|
3917
|
+
text="The harness run on \`$br\` failed. Its log is \`run.log\` in the run's \`$STATE_ARTIFACT_NAME\` artifact. To start again, re-apply the label \`$trigger_label\` to this issue; that starts a new run, on the next indexed branch."
|
|
3918
|
+
fi ;;
|
|
3919
|
+
stopped)
|
|
3920
|
+
text="The harness run on \`$br\` was stopped. Comment \`${COMMAND_HANDLE} resume\` to continue it from its committed ledger. A review that requests changes is collected now, and its round starts once the resumed run finishes." ;;
|
|
3921
|
+
round)
|
|
3922
|
+
text="A user-review round started on \`$br\`; a \`completed\` comment follows when the branch is ready for review again." ;;
|
|
3923
|
+
esac
|
|
3924
|
+
|
|
3925
|
+
OPEN_QUESTIONS=""
|
|
3926
|
+
if [ "$event" = parked ]; then
|
|
3927
|
+
state_rel=$(hr_state_dir "$root" 2>/dev/null) || state_rel=""
|
|
3928
|
+
[ -z "$state_rel" ] || open_questions_in "$root/${state_rel%/}"
|
|
3929
|
+
fi
|
|
3930
|
+
|
|
3931
|
+
tmp="${RUNNER_TEMP-}"
|
|
3932
|
+
if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then
|
|
3933
|
+
tmp=$(mktemp -d) || tmp=""
|
|
3934
|
+
made_tmp="$tmp"
|
|
3935
|
+
fi
|
|
3936
|
+
if [ -n "$OPEN_QUESTIONS" ]; then
|
|
3937
|
+
count=$(printf '%s\n' $OPEN_QUESTIONS | wc -l)
|
|
3938
|
+
count=$((count))
|
|
3939
|
+
for n in $OPEN_QUESTIONS; do
|
|
3940
|
+
if [ -n "$tmp" ] && file=$(mktemp "$tmp/harness-report-comment.XXXXXX"); then
|
|
3941
|
+
if forge_question_body "$file" "$br" "$n" "$count" "$root/${state_rel%/}/$HR_REMOTE_CLARIFY_DIR/$br" "$note"; then
|
|
3942
|
+
forge_comment "$target" "$event" "$br" "$file" "$n" || :
|
|
3943
|
+
else
|
|
3944
|
+
echo "remote-run.sh: report: cannot write question $n's comment for #$target; not posted" >&2
|
|
3945
|
+
fi
|
|
3946
|
+
rm -f "$file" "$file.cut"
|
|
3947
|
+
else
|
|
3948
|
+
echo "remote-run.sh: report: cannot create question $n's comment file for #$target; not posted" >&2
|
|
3949
|
+
fi
|
|
3950
|
+
done
|
|
3951
|
+
elif [ -n "$tmp" ] && file=$(mktemp "$tmp/harness-report-comment.XXXXXX"); then
|
|
3952
|
+
{
|
|
3953
|
+
printf '%s\n' "$text"
|
|
3954
|
+
[ -z "$note" ] || printf '\n%s\n' "$note"
|
|
3955
|
+
[ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
|
|
3956
|
+
} >"$file"
|
|
3957
|
+
forge_comment "$target" "$event" "$br" "$file" || :
|
|
3958
|
+
rm -f "$file"
|
|
3959
|
+
else
|
|
3960
|
+
echo "remote-run.sh: report: cannot create the comment file for #$target; no comment posted" >&2
|
|
3961
|
+
fi
|
|
3962
|
+
[ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
|
|
3963
|
+
|
|
3964
|
+
[ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" "$state" || :
|
|
3965
|
+
[ -z "$FORGE_PR" ] || forge_set_state "$FORGE_PR" "$state" || :
|
|
3966
|
+
echo "remote-run.sh: report: $event on $br reported on #$target"
|
|
3967
|
+
return 0
|
|
3968
|
+
}
|
|
3969
|
+
|
|
3970
|
+
verb_report() {
|
|
3971
|
+
forge_report "$report_event" "$branch" "$report_note"
|
|
3972
|
+
exit "$EXIT_OK"
|
|
3973
|
+
}
|
|
3974
|
+
|
|
3975
|
+
# GitHub's refusal of a pull request created with the job's own token while the
|
|
3976
|
+
# repository's Actions setting is off (docs/github-integration-research.md -> C3).
|
|
3977
|
+
PR_CREATE_FORBIDDEN='GitHub Actions is not permitted to create or approve pull requests'
|
|
3978
|
+
# GitHub's limit on a pull request title.
|
|
3979
|
+
PR_TITLE_MAX_CHARS=256
|
|
3980
|
+
|
|
3981
|
+
# deliver_title_var <branch> — DELIVER_TITLE: the committed task prompt's first
|
|
3982
|
+
# line without its `# ` when it is a heading, cut to PR_TITLE_MAX_CHARS, else <branch>.
|
|
3983
|
+
DELIVER_TITLE=""
|
|
3984
|
+
deliver_title_var() {
|
|
3985
|
+
local state_rel first=""
|
|
3986
|
+
DELIVER_TITLE="$1"
|
|
3987
|
+
state_rel=$(hr_state_dir "$root" 2>/dev/null) || return 0
|
|
3988
|
+
[ -n "$state_rel" ] || return 0
|
|
3989
|
+
IFS= read -r first < <(git -C "$root" show "refs/remotes/origin/$1:$(hr_task_prompt_rel "$state_rel" "$1")" 2>/dev/null) || :
|
|
3990
|
+
case "$first" in
|
|
3991
|
+
'# '?*) DELIVER_TITLE="${first#'# '}"; DELIVER_TITLE="${DELIVER_TITLE:0:$PR_TITLE_MAX_CHARS}" ;;
|
|
3992
|
+
esac
|
|
3993
|
+
return 0
|
|
3994
|
+
}
|
|
3995
|
+
|
|
3996
|
+
# deliver_pr_body <file> <branch> — the pull request's body: what it is, the
|
|
3997
|
+
# issue as a plain mention (never a closing keyword: the flow does not own the
|
|
3998
|
+
# issue's lifecycle), what a reviewer can do here, and the marker.
|
|
3999
|
+
deliver_pr_body() {
|
|
4000
|
+
local v verbs=""
|
|
4001
|
+
for v in $COMMAND_VERBS; do
|
|
4002
|
+
[ "$v" != answer ] || v="answer <n>"
|
|
4003
|
+
verbs="$verbs${verbs:+, }\`$COMMAND_HANDLE $v\`"
|
|
4004
|
+
done
|
|
4005
|
+
{
|
|
4006
|
+
printf 'This pull request carries the harness run on `%s`, ready for your review. The harness never merges it.\n' "$2"
|
|
4007
|
+
[ -z "$FORGE_ISSUE" ] || printf '\nStarted from #%s.\n' "$FORGE_ISSUE"
|
|
4008
|
+
printf '\nA review that requests changes starts a user-review round on this branch.\n'
|
|
4009
|
+
printf 'Comment %s to act on the run; each says when it applies (docs/github-run-control.md in the harness documentation).\n' "$verbs"
|
|
4010
|
+
printf '\n'
|
|
4011
|
+
forge_marker pull-request "$2"
|
|
4012
|
+
} >"$1"
|
|
4013
|
+
}
|
|
4014
|
+
|
|
4015
|
+
# deliver_create <base> <branch> <body_file> [--draft] — one `gh pr create`,
|
|
4016
|
+
# with HARNESS_PR_TOKEN when it is set, else the job's own token.
|
|
4017
|
+
deliver_create() {
|
|
4018
|
+
local base="$1" br="$2" body="$3"
|
|
4019
|
+
shift 3
|
|
4020
|
+
set -- pr create --repo "$FORGE_REPO" --base "$base" --head "$br" "$@" --title "$DELIVER_TITLE" --body-file "$body"
|
|
4021
|
+
if [ -n "${HARNESS_PR_TOKEN-}" ]; then
|
|
4022
|
+
gh_call_token "$HARNESS_PR_TOKEN" "$@"
|
|
4023
|
+
else
|
|
4024
|
+
gh_call "$@"
|
|
4025
|
+
fi
|
|
4026
|
+
}
|
|
4027
|
+
|
|
4028
|
+
# verb_deliver — after a `completed` bundle: find or open the branch's pull
|
|
4029
|
+
# request, post the one `completed` comment, and set `done`. Always exit 0.
|
|
4030
|
+
verb_deliver() {
|
|
4031
|
+
local status base="" pr_url="" opened=0 lookup_failed=0 create_err="" forbidden=0
|
|
4032
|
+
local tmp made_tmp="" body="" file target text compare
|
|
4033
|
+
if ! forge_on; then
|
|
4034
|
+
echo "remote-run.sh: deliver: the forge coupling is off (forge github and execution.target github-actions); nothing posted"
|
|
4035
|
+
exit "$EXIT_OK"
|
|
4036
|
+
fi
|
|
4037
|
+
hr_remote_names_var
|
|
4038
|
+
status=$(hr_remote_status_get "$bundle_dir/$HR_REMOTE_STATUS_FILE" status 2>/dev/null) || status=""
|
|
4039
|
+
if [ "$status" != completed ]; then
|
|
4040
|
+
echo "remote-run.sh: deliver: the bundle's status is '${status:-unreadable}', not completed; nothing posted"
|
|
4041
|
+
exit "$EXIT_OK"
|
|
4042
|
+
fi
|
|
4043
|
+
forge_repo_var || exit "$EXIT_OK"
|
|
4044
|
+
|
|
4045
|
+
forge_fetch_branch "$branch"
|
|
4046
|
+
forge_issue_var "$branch" || FORGE_ISSUE=""
|
|
4047
|
+
forge_pr_var "$branch" || { FORGE_PR=""; lookup_failed=1; create_err="whether a pull request is already open could not be read ($GH_ERR)"; }
|
|
4048
|
+
|
|
4049
|
+
tmp="${RUNNER_TEMP-}"
|
|
4050
|
+
if [ -z "$tmp" ] || [ ! -d "$tmp" ]; then
|
|
4051
|
+
tmp=$(mktemp -d) || tmp=""
|
|
4052
|
+
made_tmp="$tmp"
|
|
4053
|
+
fi
|
|
4054
|
+
|
|
4055
|
+
if [ -n "$FORGE_PR" ]; then
|
|
4056
|
+
pr_url="$FORGE_SERVER/$FORGE_REPO/pull/$FORGE_PR"
|
|
4057
|
+
echo "remote-run.sh: deliver: $branch already has pull request #$FORGE_PR; none opened"
|
|
4058
|
+
elif [ "$lookup_failed" -eq 0 ]; then
|
|
4059
|
+
base=$(hr_default_branch "$root" 2>/dev/null) || base=""
|
|
4060
|
+
deliver_title_var "$branch"
|
|
4061
|
+
if [ -z "$base" ]; then
|
|
4062
|
+
create_err="the configured defaultBranch could not be read"
|
|
4063
|
+
elif [ -z "$tmp" ] || ! body=$(mktemp "$tmp/harness-deliver-body.XXXXXX") || ! deliver_pr_body "$body" "$branch"; then
|
|
4064
|
+
create_err="its body file could not be written"
|
|
4065
|
+
elif deliver_create "$base" "$branch" "$body" --draft; then
|
|
4066
|
+
opened=1
|
|
4067
|
+
else
|
|
4068
|
+
create_err="$GH_ERR"
|
|
4069
|
+
case "$GH_ERR" in
|
|
4070
|
+
*"$PR_CREATE_FORBIDDEN"*) forbidden=1 ;;
|
|
4071
|
+
*)
|
|
4072
|
+
# Drafts depend on the account's plan (C3, not measured): one retry as ready.
|
|
4073
|
+
echo "remote-run.sh: deliver: opening a draft pull request failed ($GH_ERR); retrying once without --draft" >&2
|
|
4074
|
+
if deliver_create "$base" "$branch" "$body"; then
|
|
4075
|
+
opened=1
|
|
4076
|
+
else
|
|
4077
|
+
create_err="$GH_ERR"
|
|
4078
|
+
fi ;;
|
|
4079
|
+
esac
|
|
4080
|
+
fi
|
|
4081
|
+
[ -z "$body" ] || rm -f "$body"
|
|
4082
|
+
if [ "$opened" -eq 1 ]; then
|
|
4083
|
+
FORGE_PR=$(printf '%s\n' "$GH_OUT" | sed -n 's|.*/pull/\([0-9][0-9]*\).*|\1|p' | tail -n 1)
|
|
4084
|
+
if [ -n "$FORGE_PR" ]; then
|
|
4085
|
+
pr_url="$FORGE_SERVER/$FORGE_REPO/pull/$FORGE_PR"
|
|
4086
|
+
echo "remote-run.sh: deliver: opened pull request #$FORGE_PR for $branch"
|
|
4087
|
+
else
|
|
4088
|
+
opened=0
|
|
4089
|
+
create_err="gh printed no pull request URL"
|
|
4090
|
+
fi
|
|
4091
|
+
fi
|
|
4092
|
+
fi
|
|
4093
|
+
|
|
4094
|
+
compare="$FORGE_SERVER/$FORGE_REPO/compare/${base:-<default branch>}...$branch?expand=1"
|
|
4095
|
+
if [ -z "$pr_url" ]; then
|
|
4096
|
+
target="$FORGE_ISSUE"
|
|
4097
|
+
if [ "$forbidden" -eq 1 ]; then
|
|
4098
|
+
text="The harness run on \`$branch\` completed, but its pull request could not be opened: GitHub Actions is not permitted to create pull requests in this repository. Turn on *$PR_CREATE_SETTING* under $PR_CREATE_SETTING_PATH, or set the \`HARNESS_GIT_TOKEN\` secret, for the next run. For this one, open the pull request from the branch: $compare"
|
|
4099
|
+
else
|
|
4100
|
+
text="The harness run on \`$branch\` completed, but its pull request could not be opened: $create_err. Open it by hand from the branch: $compare"
|
|
4101
|
+
fi
|
|
4102
|
+
elif [ "$opened" -eq 0 ]; then
|
|
4103
|
+
target="$FORGE_PR"
|
|
4104
|
+
text="The harness round on \`$branch\` finished. Review this pull request; a review that requests changes starts another round."
|
|
4105
|
+
elif [ -n "$FORGE_ISSUE" ]; then
|
|
4106
|
+
target="$FORGE_ISSUE"
|
|
4107
|
+
text="The harness run on \`$branch\` completed. Its pull request is ready for your review: $pr_url
|
|
4108
|
+
Review it there; a review that requests changes starts another round."
|
|
4109
|
+
else
|
|
4110
|
+
target="$FORGE_PR"
|
|
4111
|
+
text="The harness run on \`$branch\` completed and opened this pull request. Review it; a review that requests changes starts another round."
|
|
4112
|
+
fi
|
|
4113
|
+
if hr_phase_enabled "$root" qa; then
|
|
4114
|
+
text="$text
|
|
4115
|
+
|
|
4116
|
+
The interactive-test phase was skipped on GitHub Actions. Before merging, run \`/autonomous-sdlc-harness:branch-qa-test $branch\` locally."
|
|
4117
|
+
fi
|
|
4118
|
+
|
|
4119
|
+
if [ -z "$target" ]; then
|
|
4120
|
+
echo "remote-run.sh: deliver: $branch has no pull request and no issue it was started from; nothing posted"
|
|
4121
|
+
elif [ -n "$tmp" ] && file=$(mktemp "$tmp/harness-deliver-comment.XXXXXX"); then
|
|
4122
|
+
{
|
|
4123
|
+
printf '%s\n' "$text"
|
|
4124
|
+
[ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
|
|
4125
|
+
} >"$file"
|
|
4126
|
+
forge_comment "$target" completed "$branch" "$file" || :
|
|
4127
|
+
rm -f "$file"
|
|
4128
|
+
else
|
|
4129
|
+
echo "remote-run.sh: deliver: cannot create the comment file for #$target; no comment posted" >&2
|
|
4130
|
+
fi
|
|
4131
|
+
[ -z "$made_tmp" ] || rmdir "$made_tmp" 2>/dev/null || :
|
|
4132
|
+
|
|
4133
|
+
[ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" done || :
|
|
4134
|
+
[ -z "$FORGE_PR" ] || forge_set_state "$FORGE_PR" done || :
|
|
4135
|
+
echo "remote-run.sh: deliver: completed on $branch reported${target:+ on #$target}"
|
|
4136
|
+
exit "$EXIT_OK"
|
|
4137
|
+
}
|
|
4138
|
+
|
|
4139
|
+
# ---------------------------------------------------------------------------
|
|
4140
|
+
# `control` — the comment adapter: one GitHub event, one harness action on one
|
|
4141
|
+
# branch, through the child verbs. Event text is data, as in `trigger`.
|
|
4142
|
+
# ---------------------------------------------------------------------------
|
|
4143
|
+
|
|
4144
|
+
CONTROL_BRANCH=""
|
|
4145
|
+
CONTROL_NUMBER=""
|
|
4146
|
+
CONTROL_ACTOR=""
|
|
4147
|
+
CONTROL_VERB=""
|
|
4148
|
+
CONTROL_ARGS=""
|
|
4149
|
+
CONTROL_BODY=""
|
|
4150
|
+
control_tmp=""
|
|
4151
|
+
# Directories control_state_var created, removed on exit.
|
|
4152
|
+
control_dirs=""
|
|
4153
|
+
|
|
4154
|
+
control_cleanup() {
|
|
4155
|
+
local d
|
|
4156
|
+
for d in $control_dirs; do
|
|
4157
|
+
rm -rf -- "$d"
|
|
4158
|
+
done
|
|
4159
|
+
return 0
|
|
4160
|
+
}
|
|
4161
|
+
|
|
4162
|
+
# control_post <text> — post <text> on CONTROL_NUMBER as a `reply` comment;
|
|
4163
|
+
# 1, after an `::error::` line, when it cannot be posted.
|
|
4164
|
+
control_post() {
|
|
4165
|
+
local text="$1" file status=0
|
|
4166
|
+
if ! forge_repo_var; then
|
|
4167
|
+
echo "::error::remote-run.sh: control: the reply on #$CONTROL_NUMBER could not be posted: $GH_ERR"
|
|
4168
|
+
return 1
|
|
4169
|
+
fi
|
|
4170
|
+
if ! file=$(mktemp "$control_tmp/harness-control-reply.XXXXXX"); then
|
|
4171
|
+
echo "::error::remote-run.sh: control: cannot create the reply file for #$CONTROL_NUMBER under '$control_tmp'"
|
|
4172
|
+
return 1
|
|
4173
|
+
fi
|
|
4174
|
+
{
|
|
4175
|
+
printf '%s\n' "$text"
|
|
4176
|
+
[ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
|
|
4177
|
+
} >"$file"
|
|
4178
|
+
if ! forge_comment "$CONTROL_NUMBER" reply "$CONTROL_BRANCH" "$file"; then
|
|
4179
|
+
echo "::error::remote-run.sh: control: the reply on #$CONTROL_NUMBER could not be posted: $GH_ERR"
|
|
4180
|
+
status=1
|
|
4181
|
+
fi
|
|
4182
|
+
rm -f "$file"
|
|
4183
|
+
return "$status"
|
|
4184
|
+
}
|
|
4185
|
+
|
|
4186
|
+
# control_reply <exit> <text> — control_post <text>, then exit <exit>; a reply
|
|
4187
|
+
# that cannot be posted makes the exit 3.
|
|
4188
|
+
control_reply() {
|
|
4189
|
+
control_post "$2" || exit "$EXIT_GH"
|
|
4190
|
+
exit "$1"
|
|
4191
|
+
}
|
|
4192
|
+
|
|
4193
|
+
# control_refuse <exit> <reason> <way on> — the refusal reply, then exit.
|
|
4194
|
+
control_refuse() {
|
|
4195
|
+
echo "remote-run.sh: control: \`${CONTROL_VERB:-$COMMAND_HANDLE}\` refused: $2" >&2
|
|
4196
|
+
control_reply "$1" "@$CONTROL_ACTOR: \`${CONTROL_VERB:-$COMMAND_HANDLE}\` was not run: $2. $3"
|
|
4197
|
+
}
|
|
4198
|
+
|
|
4199
|
+
# control_child <out_file> <args...> — run this script as a child with stderr
|
|
4200
|
+
# captured; CHILD_STATUS is its exit, CHILD_LAST its last non-empty stderr line,
|
|
4201
|
+
# which is also echoed to this process's stderr.
|
|
4202
|
+
CHILD_STATUS=0
|
|
4203
|
+
CHILD_LAST=""
|
|
4204
|
+
control_child() {
|
|
4205
|
+
local out="$1" errfile
|
|
4206
|
+
shift
|
|
4207
|
+
CHILD_STATUS=0
|
|
4208
|
+
CHILD_LAST=""
|
|
4209
|
+
errfile=$(mktemp "$control_tmp/harness-control-err.XXXXXX") || errfile=/dev/null
|
|
4210
|
+
bash "$script_dir/remote-run.sh" "$@" >"$out" 2>"$errfile" || CHILD_STATUS=$?
|
|
4211
|
+
if [ "$errfile" != /dev/null ]; then
|
|
4212
|
+
cat "$errfile" >&2
|
|
4213
|
+
CHILD_LAST=$(grep -v '^[[:space:]]*$' "$errfile" | tail -n 1)
|
|
4214
|
+
rm -f "$errfile"
|
|
4215
|
+
fi
|
|
4216
|
+
[ -n "$CHILD_LAST" ] || CHILD_LAST="exit $CHILD_STATUS, no message"
|
|
4217
|
+
}
|
|
4218
|
+
|
|
4219
|
+
# control_state_var <branch> — the branch's newest remote state, read by a
|
|
4220
|
+
# `fetch` child into a fresh directory: CS_STATE, CS_REASON, CS_ENGINE,
|
|
4221
|
+
# CS_OPEN, CS_DETAIL, CS_URL, CS_RUN_STATUS and CS_DIR. 1 with CS_ERR on a
|
|
4222
|
+
# failed child.
|
|
4223
|
+
CS_STATE=""; CS_REASON=""; CS_ENGINE=""; CS_OPEN=""; CS_DETAIL=""; CS_URL=""
|
|
4224
|
+
CS_RUN_STATUS=""; CS_DIR=""; CS_ERR=""
|
|
4225
|
+
control_state_var() {
|
|
4226
|
+
local out line key value
|
|
4227
|
+
CS_STATE=""; CS_REASON=""; CS_ENGINE=""; CS_OPEN=""; CS_DETAIL=""; CS_URL=""
|
|
4228
|
+
CS_RUN_STATUS=""; CS_DIR=""; CS_ERR=""
|
|
4229
|
+
if ! CS_DIR=$(mktemp -d "$control_tmp/harness-control-fetch.XXXXXX"); then
|
|
4230
|
+
CS_DIR=""
|
|
4231
|
+
CS_ERR="a fetch directory could not be created under '$control_tmp'"
|
|
4232
|
+
return 1
|
|
4233
|
+
fi
|
|
4234
|
+
control_dirs="$control_dirs $CS_DIR"
|
|
4235
|
+
out="$CS_DIR.out"
|
|
4236
|
+
control_dirs="$control_dirs $out"
|
|
4237
|
+
control_child "$out" fetch "$1" "$CS_DIR" --repo "$root"
|
|
4238
|
+
if [ "$CHILD_STATUS" -ne 0 ]; then
|
|
4239
|
+
CS_ERR="$CHILD_LAST"
|
|
4240
|
+
return 1
|
|
4241
|
+
fi
|
|
4242
|
+
while IFS= read -r line; do
|
|
4243
|
+
key=${line%%:*}
|
|
4244
|
+
value=${line#*: }
|
|
4245
|
+
[ "$value" != "$line" ] || value=""
|
|
4246
|
+
case "$key" in
|
|
4247
|
+
state) CS_STATE="$value" ;;
|
|
4248
|
+
pause_reason) CS_REASON="$value" ;;
|
|
4249
|
+
engine) CS_ENGINE="$value" ;;
|
|
4250
|
+
open_questions) CS_OPEN="$value" ;;
|
|
4251
|
+
detail) CS_DETAIL="$value" ;;
|
|
4252
|
+
run_url) CS_URL="$value" ;;
|
|
4253
|
+
run_status) CS_RUN_STATUS="$value" ;;
|
|
4254
|
+
esac
|
|
4255
|
+
done <"$out"
|
|
4256
|
+
return 0
|
|
4257
|
+
}
|
|
4258
|
+
|
|
4259
|
+
# control_check_branch <branch> — the refusals both paths share, in order: a
|
|
4260
|
+
# branch not answered 1 by hr_branch_is_protected, then (after a fetch) one
|
|
4261
|
+
# whose origin tip carries no flow-progress ledger. Each is a reply and exit 2.
|
|
4262
|
+
control_check_branch() {
|
|
4263
|
+
local b="$1" protected=0
|
|
4264
|
+
if ! valid_branch "$b" || ! git check-ref-format --branch "$b" >/dev/null 2>&1; then
|
|
4265
|
+
control_refuse "$EXIT_REFUSED" "\`$b\` is not a valid branch name" "Comment on the pull request of the run's branch instead."
|
|
4266
|
+
fi
|
|
4267
|
+
hr_branch_is_protected "$root" "$b" || protected=$?
|
|
4268
|
+
case "$protected" in
|
|
4269
|
+
1) ;;
|
|
4270
|
+
0) control_refuse "$EXIT_REFUSED" "\`$b\` is a protected branch, which the harness never acts on" \
|
|
4271
|
+
"Comment on the pull request of the run's own branch instead." ;;
|
|
4272
|
+
*) control_refuse "$EXIT_REFUSED" "whether \`$b\` is protected could not be judged from \`harness.config.json\`" \
|
|
4273
|
+
"Fix the configuration on the default branch, then comment again." ;;
|
|
4274
|
+
esac
|
|
4275
|
+
forge_fetch_branch "$b"
|
|
4276
|
+
if ! forge_recognised "$b"; then
|
|
4277
|
+
control_refuse "$EXIT_REFUSED" "\`$b\` is not a harness branch: its tip carries no flow-progress ledger" \
|
|
4278
|
+
"Only a branch a harness run works on can be commanded."
|
|
4279
|
+
fi
|
|
4280
|
+
CONTROL_BRANCH="$b"
|
|
4281
|
+
}
|
|
4282
|
+
|
|
4283
|
+
# control_branch_from_pr <number> — the pull request's head, refused for a
|
|
4284
|
+
# fork, a pull request that is not open, and control_check_branch's refusals.
|
|
4285
|
+
control_branch_from_pr() {
|
|
4286
|
+
local head cross state
|
|
4287
|
+
if ! gh_call pr view "$1" --repo "$FORGE_REPO" --json headRefName,isCrossRepository,state; then
|
|
4288
|
+
control_refuse "$EXIT_GH" "pull request #$1 could not be read ($GH_ERR)" "Comment again to retry."
|
|
4289
|
+
fi
|
|
4290
|
+
head=$(printf '%s' "$GH_OUT" | jq -r '.headRefName // empty' 2>/dev/null) || head=""
|
|
4291
|
+
cross=$(printf '%s' "$GH_OUT" | jq -r '.isCrossRepository | tostring' 2>/dev/null) || cross=""
|
|
4292
|
+
state=$(printf '%s' "$GH_OUT" | jq -r '.state // empty' 2>/dev/null) || state=""
|
|
4293
|
+
# A fork's head is never checked out or run: this event carries this
|
|
4294
|
+
# repository's secrets (docs/github-integration-research.md -> C2).
|
|
4295
|
+
if [ "$cross" != false ]; then
|
|
4296
|
+
control_refuse "$EXIT_REFUSED" "pull request #$1 comes from a fork, and the harness never acts on a fork's pull request" \
|
|
4297
|
+
"Push the branch to this repository and open the pull request from there."
|
|
4298
|
+
fi
|
|
4299
|
+
if [ "$state" != OPEN ]; then
|
|
4300
|
+
control_refuse "$EXIT_REFUSED" "pull request #$1 is ${state:-in an unknown state}, not open" "Reopen it, then comment again."
|
|
4301
|
+
fi
|
|
4302
|
+
control_check_branch "$head"
|
|
4303
|
+
}
|
|
4304
|
+
|
|
4305
|
+
# control_branch_from_issue <number> — the branch of the issue's last genuine
|
|
4306
|
+
# start comment: by `github-actions[bot]`, its first line opening with the
|
|
4307
|
+
# trigger's start sentence and a backticked <b>, and its last non-empty line
|
|
4308
|
+
# exactly `forge_marker started <b>`. A marker anywhere else is never trusted.
|
|
4309
|
+
control_branch_from_issue() {
|
|
4310
|
+
local count i login body first last b found="" lead='Started a harness run on the branch `'
|
|
4311
|
+
if ! gh_call api --paginate "repos/$FORGE_REPO/issues/$1/comments" --jq '.[] | {login: .user.login, body: .body}'; then
|
|
4312
|
+
control_refuse "$EXIT_GH" "the comments of issue #$1 could not be read ($GH_ERR)" "Comment again to retry."
|
|
4313
|
+
fi
|
|
4314
|
+
count=$(printf '%s' "$GH_OUT" | jq -s 'length' 2>/dev/null) || count=""
|
|
4315
|
+
case "$count" in
|
|
4316
|
+
''|*[!0-9]*)
|
|
4317
|
+
GH_ERR="its comment list is not the expected JSON"
|
|
4318
|
+
control_refuse "$EXIT_GH" "the comments of issue #$1 could not be read ($GH_ERR)" "Comment again to retry." ;;
|
|
4319
|
+
esac
|
|
4320
|
+
for ((i = 0; i < count; i++)); do
|
|
4321
|
+
login=$(printf '%s' "$GH_OUT" | jq -s -r --argjson i "$i" '.[$i].login // ""' 2>/dev/null) || continue
|
|
4322
|
+
[ "$login" = 'github-actions[bot]' ] || continue
|
|
4323
|
+
body=$(printf '%s' "$GH_OUT" | jq -s -j --argjson i "$i" '.[$i].body // ""' 2>/dev/null) || continue
|
|
4324
|
+
body=${body//$'\r'$'\n'/$'\n'}
|
|
4325
|
+
body=${body%$'\r'}
|
|
4326
|
+
case "$body" in
|
|
4327
|
+
"$lead"*) ;;
|
|
4328
|
+
*) continue ;;
|
|
4329
|
+
esac
|
|
4330
|
+
b=${body#"$lead"}
|
|
4331
|
+
b=${b%%$'\n'*}
|
|
4332
|
+
case "$b" in
|
|
4333
|
+
*'`'*) b=${b%%'`'*} ;;
|
|
4334
|
+
*) continue ;;
|
|
4335
|
+
esac
|
|
4336
|
+
[ -n "$b" ] || continue
|
|
4337
|
+
last=$(printf '%s\n' "$body" | grep -v '^[[:space:]]*$' | tail -n 1)
|
|
4338
|
+
[ "$last" = "$(forge_marker started "$b")" ] || continue
|
|
4339
|
+
found="$b"
|
|
4340
|
+
done
|
|
4341
|
+
if [ -z "$found" ]; then
|
|
4342
|
+
control_refuse "$EXIT_REFUSED" "no harness run was started from this issue" \
|
|
4343
|
+
"Comment on the pull request of the run's branch instead."
|
|
4344
|
+
fi
|
|
4345
|
+
control_check_branch "$found"
|
|
4346
|
+
}
|
|
4347
|
+
|
|
4348
|
+
# control_verb_handled <verb> — 0 when an arm below carries out <verb>.
|
|
4349
|
+
control_verb_handled() {
|
|
4350
|
+
case "$1" in
|
|
4351
|
+
answer|pause|stop|resume|clear) return 0 ;;
|
|
4352
|
+
esac
|
|
4353
|
+
return 1
|
|
4354
|
+
}
|
|
4355
|
+
|
|
4356
|
+
# control_resume_dispatch <reply> [<dispatch flag>] — the resume dispatch the
|
|
4357
|
+
# local relay sends for CS_ENGINE; on 0, <reply>, then `running` on the run's
|
|
4358
|
+
# issue and pull request. An empty engine is refused, never guessed: the run
|
|
4359
|
+
# workflow's `engine` input defaults to `task`.
|
|
4360
|
+
control_resume_dispatch() {
|
|
4361
|
+
local done_text="$1" out status route clear=""
|
|
4362
|
+
shift
|
|
4363
|
+
if [ -z "$CS_ENGINE" ]; then
|
|
4364
|
+
# The run's own engine is unrecorded, so the route names the choice.
|
|
4365
|
+
route=$(hr_github_resume_route "$CONTROL_BRANCH" "<task, user_review or docs: the one the run was started with>")
|
|
4366
|
+
route=${route#or from GitHub: }
|
|
4367
|
+
[ "$#" -eq 0 ] || clear=", with park_loop_clear true as well"
|
|
4368
|
+
control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` (\`$CS_STATE\`${CS_REASON:+, \`$CS_REASON\`}) records no engine, and the harness does not guess one" \
|
|
4369
|
+
"Resume it with the **Run workflow** form instead: $route$clear."
|
|
4370
|
+
fi
|
|
4371
|
+
out=$(mktemp "$control_tmp/harness-control-out.XXXXXX") || out=/dev/null
|
|
4372
|
+
control_child "$out" dispatch "$CONTROL_BRANCH" --engine "$CS_ENGINE" --resume pause "$@" --chain 0 --repo "$root"
|
|
4373
|
+
[ "$out" = /dev/null ] || { cat "$out"; rm -f "$out"; }
|
|
4374
|
+
case "$CHILD_STATUS" in
|
|
4375
|
+
0) ;;
|
|
4376
|
+
2) control_refuse "$EXIT_REFUSED" "the dispatch was refused ($CHILD_LAST)" "Comment \`$COMMAND_HANDLE $CONTROL_VERB\` again once that is fixed." ;;
|
|
4377
|
+
*) control_refuse "$EXIT_GH" "the dispatch could not be sent ($CHILD_LAST)" "Comment \`$COMMAND_HANDLE $CONTROL_VERB\` again to retry." ;;
|
|
4378
|
+
esac
|
|
4379
|
+
status="$EXIT_OK"
|
|
4380
|
+
control_post "$done_text" || status="$EXIT_GH"
|
|
4381
|
+
# The job posts its own `resumed` comment; the labels say `running` now.
|
|
4382
|
+
forge_issue_var "$CONTROL_BRANCH" || FORGE_ISSUE=""
|
|
4383
|
+
forge_pr_var "$CONTROL_BRANCH" || FORGE_PR=""
|
|
4384
|
+
[ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" running || :
|
|
4385
|
+
[ -z "$FORGE_PR" ] || forge_set_state "$FORGE_PR" running || :
|
|
4386
|
+
exit "$status"
|
|
4387
|
+
}
|
|
4388
|
+
|
|
4389
|
+
control_resume() {
|
|
4390
|
+
local open
|
|
4391
|
+
control_state_var "$CONTROL_BRANCH" \
|
|
4392
|
+
|| control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
|
|
4393
|
+
case "$CS_STATE" in
|
|
4394
|
+
paused)
|
|
4395
|
+
# Every pause reason, `expired` and `killed` included, resumes from the
|
|
4396
|
+
# committed ledger, as the local route does.
|
|
4397
|
+
control_resume_dispatch "Resume requested by @$CONTROL_ACTOR: \`$CONTROL_BRANCH\` continues from its committed ledger." ;;
|
|
4398
|
+
park_loop)
|
|
4399
|
+
control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is held by the park-loop guard" \
|
|
4400
|
+
"Comment \`$COMMAND_HANDLE clear\` to release the hold and resume it." ;;
|
|
4401
|
+
parked)
|
|
4402
|
+
open=""
|
|
4403
|
+
[ -z "$CS_OPEN" ] || open=" (open: $CS_OPEN)"
|
|
4404
|
+
control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is \`parked\`, waiting for an answer$open" \
|
|
4405
|
+
"Comment \`$COMMAND_HANDLE answer <n>\` with the answer to question <n> on the lines below it." ;;
|
|
4406
|
+
running)
|
|
4407
|
+
control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is already \`running\`" "Nothing needs resuming." ;;
|
|
4408
|
+
*)
|
|
4409
|
+
control_refuse "$EXIT_REFUSED" "only a paused run can be resumed, and the run on \`$CONTROL_BRANCH\` is \`${CS_STATE:-unknown}\`" \
|
|
4410
|
+
"A finished run continues by a review requesting changes on its pull request, or by applying the trigger label to its issue again." ;;
|
|
4411
|
+
esac
|
|
4412
|
+
}
|
|
4413
|
+
|
|
4414
|
+
control_clear() {
|
|
4415
|
+
control_state_var "$CONTROL_BRANCH" \
|
|
4416
|
+
|| control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
|
|
4417
|
+
if [ "$CS_STATE" != park_loop ]; then
|
|
4418
|
+
control_refuse "$EXIT_REFUSED" "there is no park-loop hold to clear: the run on \`$CONTROL_BRANCH\` is \`${CS_STATE:-unknown}\`" \
|
|
4419
|
+
"Only a run held by the park-loop guard is cleared."
|
|
4420
|
+
fi
|
|
4421
|
+
# On GitHub, typing `clear` is the confirmation `branch-resume` asks for.
|
|
4422
|
+
control_resume_dispatch "Park-loop hold on \`$CONTROL_BRANCH\` cleared by @$CONTROL_ACTOR; the run resumes from its committed ledger." \
|
|
4423
|
+
--park-loop-clear
|
|
4424
|
+
}
|
|
4425
|
+
|
|
4426
|
+
control_pause() {
|
|
4427
|
+
local out
|
|
4428
|
+
control_state_var "$CONTROL_BRANCH" \
|
|
4429
|
+
|| control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
|
|
4430
|
+
if [ "$CS_STATE" != running ]; then
|
|
4431
|
+
control_refuse "$EXIT_REFUSED" "only a running run can be paused, and the run on \`$CONTROL_BRANCH\` is \`$CS_STATE\`" \
|
|
4432
|
+
"Nothing needs pausing."
|
|
4433
|
+
fi
|
|
4434
|
+
out=$(mktemp "$control_tmp/harness-control-out.XXXXXX") || out=/dev/null
|
|
4435
|
+
control_child "$out" pause "$CONTROL_BRANCH" --repo "$root"
|
|
4436
|
+
[ "$out" = /dev/null ] || { cat "$out"; rm -f "$out"; }
|
|
4437
|
+
if [ "$CHILD_STATUS" -ne 0 ]; then
|
|
4438
|
+
control_refuse "$EXIT_GH" "the pause could not be sent ($CHILD_LAST)" "Comment \`$COMMAND_HANDLE pause\` again to retry."
|
|
4439
|
+
fi
|
|
4440
|
+
control_reply "$EXIT_OK" "Pause requested by @$CONTROL_ACTOR; the run on \`$CONTROL_BRANCH\` yields at its next clean checkpoint, and a paused comment follows."
|
|
4441
|
+
}
|
|
4442
|
+
|
|
4443
|
+
control_stop() {
|
|
4444
|
+
local out
|
|
4445
|
+
control_state_var "$CONTROL_BRANCH" \
|
|
4446
|
+
|| control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
|
|
4447
|
+
if [ "$CS_STATE" = none ]; then
|
|
4448
|
+
control_refuse "$EXIT_REFUSED" "there is no harness run on \`$CONTROL_BRANCH\` to stop" "Nothing needs stopping."
|
|
4449
|
+
fi
|
|
4450
|
+
out=$(mktemp "$control_tmp/harness-control-out.XXXXXX") || out=/dev/null
|
|
4451
|
+
control_child "$out" stop "$CONTROL_BRANCH" --actor "$CONTROL_ACTOR" --repo "$root"
|
|
4452
|
+
[ "$out" = /dev/null ] || { cat "$out"; rm -f "$out"; }
|
|
4453
|
+
case "$CHILD_STATUS" in
|
|
4454
|
+
0)
|
|
4455
|
+
# Always replied here: `stop`'s own `stopped` comment goes to the run's
|
|
4456
|
+
# target, which need not be the item the command was typed on.
|
|
4457
|
+
control_reply "$EXIT_OK" "Stop requested by @$CONTROL_ACTOR; the run on \`$CONTROL_BRANCH\` is stopped." ;;
|
|
4458
|
+
3)
|
|
4459
|
+
control_refuse "$EXIT_GH" "the stop of \`$CONTROL_BRANCH\` is partial ($CHILD_LAST)" \
|
|
4460
|
+
"Comment \`$COMMAND_HANDLE stop\` again to finish it." ;;
|
|
4461
|
+
*)
|
|
4462
|
+
control_refuse "$EXIT_GH" "the stop could not be sent ($CHILD_LAST)" "Comment \`$COMMAND_HANDLE stop\` again to retry." ;;
|
|
4463
|
+
esac
|
|
4464
|
+
}
|
|
4465
|
+
|
|
4466
|
+
# control_answer — one answer, one `resume: answer` dispatch with one entry. A
|
|
4467
|
+
# park left partly answered is safe: run_job stops an `answer` job whose park
|
|
4468
|
+
# is not fully answered before any session, and its bundle then carries the
|
|
4469
|
+
# `answer_<n>.md` restore wrote, so the next answer's job finds the set complete.
|
|
4470
|
+
control_answer() {
|
|
4471
|
+
local first short="" below="" text n="" v open_list="" rest="" cmds="" route form dir out status="$EXIT_OK"
|
|
4472
|
+
first=${CONTROL_ARGS%%[$' \t']*}
|
|
4473
|
+
if [[ "$first" =~ ^[1-9][0-9]*$ ]]; then
|
|
4474
|
+
n="$first"
|
|
4475
|
+
short=${CONTROL_ARGS#"$first"}
|
|
4476
|
+
short=${short#"${short%%[!$' \t']*}"}
|
|
4477
|
+
else
|
|
4478
|
+
short="$CONTROL_ARGS"
|
|
4479
|
+
fi
|
|
4480
|
+
case "$CONTROL_BODY" in
|
|
4481
|
+
*$'\n'*) below=${CONTROL_BODY#*$'\n'} ;;
|
|
4482
|
+
esac
|
|
4483
|
+
below=${below//$'\r'$'\n'/$'\n'}
|
|
4484
|
+
below=${below%$'\r'}
|
|
4485
|
+
if [[ "$below" =~ ^[[:space:]]*$ ]]; then
|
|
4486
|
+
text="$short"
|
|
4487
|
+
else
|
|
4488
|
+
text="$below"
|
|
4489
|
+
fi
|
|
4490
|
+
if [[ "$text" =~ ^[[:space:]]*$ ]]; then
|
|
4491
|
+
control_refuse "$EXIT_REFUSED" "the answer is empty" \
|
|
4492
|
+
"Comment \`$COMMAND_HANDLE answer <n>\` with the answer on the lines below it."
|
|
4493
|
+
fi
|
|
4494
|
+
|
|
4495
|
+
control_state_var "$CONTROL_BRANCH" \
|
|
4496
|
+
|| control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($CS_ERR)" "Comment again to retry."
|
|
4497
|
+
case "$CS_STATE:$CS_REASON" in
|
|
4498
|
+
park_loop:*)
|
|
4499
|
+
control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` is held by the park-loop guard, not waiting for an answer" \
|
|
4500
|
+
"Comment \`$COMMAND_HANDLE clear\` to release the hold and resume it." ;;
|
|
4501
|
+
paused:expired)
|
|
4502
|
+
# An expired bundle is reported as expired, never as no park.
|
|
4503
|
+
control_refuse "$EXIT_REFUSED" "${CS_DETAIL:-the state bundle of the run has expired}; the park's questions can no longer be answered here" \
|
|
4504
|
+
"Comment \`$COMMAND_HANDLE resume\` to resume from the committed ledger." ;;
|
|
4505
|
+
running:*)
|
|
4506
|
+
# Refused rather than queued: a newer pending run in the per-branch
|
|
4507
|
+
# concurrency group could cancel a queued one.
|
|
4508
|
+
control_refuse "$EXIT_REFUSED" "a job of the run on \`$CONTROL_BRANCH\` is in progress${CS_URL:+ ($CS_URL)}" \
|
|
4509
|
+
"Send the answer again once it finishes."
|
|
4510
|
+
;;
|
|
4511
|
+
parked:*) ;;
|
|
4512
|
+
*)
|
|
4513
|
+
control_refuse "$EXIT_REFUSED" "only a parked run can be answered, and the run on \`$CONTROL_BRANCH\` is \`${CS_STATE:-unknown}\`" \
|
|
4514
|
+
"Nothing is waiting for an answer." ;;
|
|
4515
|
+
esac
|
|
4516
|
+
if [ -z "$CS_OPEN" ]; then
|
|
4517
|
+
control_refuse "$EXIT_REFUSED" "only a parked run with an open question can be answered, and the run on \`$CONTROL_BRANCH\` is \`parked\` with none open" \
|
|
4518
|
+
"Nothing is waiting for an answer."
|
|
4519
|
+
fi
|
|
4520
|
+
for v in $CS_OPEN; do
|
|
4521
|
+
open_list="$open_list${open_list:+, }$v"
|
|
4522
|
+
cmds="$cmds${cmds:+, }\`$COMMAND_HANDLE answer $v\`"
|
|
4523
|
+
done
|
|
4524
|
+
if [ -z "$n" ]; then
|
|
4525
|
+
case "$CS_OPEN" in
|
|
4526
|
+
*' '*)
|
|
4527
|
+
control_refuse "$EXIT_REFUSED" "questions $open_list are open, so the command must name one" \
|
|
4528
|
+
"Answer each with its own comment: $cmds." ;;
|
|
4529
|
+
esac
|
|
4530
|
+
n="$CS_OPEN"
|
|
4531
|
+
fi
|
|
4532
|
+
case " $CS_OPEN " in
|
|
4533
|
+
*" $n "*) ;;
|
|
4534
|
+
*)
|
|
4535
|
+
control_refuse "$EXIT_REFUSED" "question $n is not open; the open questions are $open_list" \
|
|
4536
|
+
"Answer one of them: $cmds." ;;
|
|
4537
|
+
esac
|
|
4538
|
+
if [ -z "$CS_ENGINE" ]; then
|
|
4539
|
+
route=$(hr_github_resume_route "$CONTROL_BRANCH" "<task, user_review or docs: the one the run was started with>")
|
|
4540
|
+
route=${route#or from GitHub: }
|
|
4541
|
+
form="resume answer and answers \`{\"$n\": \"<the answer>\"}\`"
|
|
4542
|
+
route=${route/and resume pause/$form}
|
|
4543
|
+
control_refuse "$EXIT_REFUSED" "the run on \`$CONTROL_BRANCH\` (\`parked\`) records no engine, and the harness does not guess one" \
|
|
4544
|
+
"Answer it with the **Run workflow** form instead: $route."
|
|
4545
|
+
fi
|
|
4546
|
+
|
|
4547
|
+
if ! dir=$(mktemp -d "$control_tmp/harness-control-answer.XXXXXX"); then
|
|
4548
|
+
control_refuse "$EXIT_GH" "an answer directory could not be created under '$control_tmp'" "Comment again to retry."
|
|
4549
|
+
fi
|
|
4550
|
+
control_dirs="$control_dirs $dir"
|
|
4551
|
+
# The answer is untrusted data: written by printf, never sourced.
|
|
4552
|
+
if ! mkdir "$dir/answers" || ! printf '%s' "$text" >"$dir/answers/answer_$n.md"; then
|
|
4553
|
+
control_refuse "$EXIT_GH" "the answer could not be written under '$dir'" "Comment again to retry."
|
|
4554
|
+
fi
|
|
4555
|
+
out="$dir/dispatch.out"
|
|
4556
|
+
control_child "$out" dispatch "$CONTROL_BRANCH" --engine "$CS_ENGINE" --resume answer \
|
|
4557
|
+
--answers-from "$dir/answers" --indexes "$n" --chain 0 --repo "$root"
|
|
4558
|
+
cat "$out" 2>/dev/null || :
|
|
4559
|
+
case "$CHILD_STATUS" in
|
|
4560
|
+
0) ;;
|
|
4561
|
+
2)
|
|
4562
|
+
case "$CHILD_LAST" in
|
|
4563
|
+
*"workflow_dispatch limit"*)
|
|
4564
|
+
control_refuse "$EXIT_REFUSED" "the dispatch was refused ($CHILD_LAST)" \
|
|
4565
|
+
"Shorten the answer, or commit it to a file on \`$CONTROL_BRANCH\` and name that file in a shorter answer." ;;
|
|
4566
|
+
esac
|
|
4567
|
+
control_refuse "$EXIT_REFUSED" "the dispatch was refused ($CHILD_LAST)" \
|
|
4568
|
+
"Comment \`$COMMAND_HANDLE answer $n\` again once that is fixed." ;;
|
|
4569
|
+
*)
|
|
4570
|
+
control_refuse "$EXIT_GH" "the dispatch could not be sent ($CHILD_LAST)" \
|
|
4571
|
+
"Comment \`$COMMAND_HANDLE answer $n\` again to retry." ;;
|
|
4572
|
+
esac
|
|
4573
|
+
|
|
4574
|
+
cmds=""
|
|
4575
|
+
for v in $CS_OPEN; do
|
|
4576
|
+
[ "$v" != "$n" ] || continue
|
|
4577
|
+
rest="$rest${rest:+, }$v"
|
|
4578
|
+
cmds="$cmds${cmds:+, }\`$COMMAND_HANDLE answer $v\`"
|
|
4579
|
+
done
|
|
4580
|
+
if [ -n "$rest" ]; then
|
|
4581
|
+
# The label stays `parked` until the last answer: only that job resumes.
|
|
4582
|
+
control_reply "$EXIT_OK" "Answer to question $n received from @$CONTROL_ACTOR and sent; question(s) $rest still need an answer: $cmds."
|
|
4583
|
+
fi
|
|
4584
|
+
control_post "Answer to question $n received from @$CONTROL_ACTOR; every open question is answered, so \`$CONTROL_BRANCH\` resumes." \
|
|
4585
|
+
|| status="$EXIT_GH"
|
|
4586
|
+
forge_issue_var "$CONTROL_BRANCH" || FORGE_ISSUE=""
|
|
4587
|
+
forge_pr_var "$CONTROL_BRANCH" || FORGE_PR=""
|
|
4588
|
+
[ -z "$FORGE_ISSUE" ] || forge_set_state "$FORGE_ISSUE" running || :
|
|
4589
|
+
[ -z "$FORGE_PR" ] || forge_set_state "$FORGE_PR" running || :
|
|
4590
|
+
exit "$status"
|
|
4591
|
+
}
|
|
4592
|
+
|
|
4593
|
+
# The fields of a `pull_request_review` event control reads beyond the shared ones.
|
|
4594
|
+
REVIEW_ID=""
|
|
4595
|
+
REVIEW_URL=""
|
|
4596
|
+
REVIEW_AT=""
|
|
4597
|
+
REVIEW_HEAD=""
|
|
4598
|
+
|
|
4599
|
+
# control_review_story — refused unless origin's tip of CONTROL_BRANCH carries
|
|
4600
|
+
# its story index, which the round's statistics step reads.
|
|
4601
|
+
control_review_story() {
|
|
4602
|
+
local state_rel
|
|
4603
|
+
state_rel=$(hr_state_dir "$root" 2>/dev/null) || state_rel=""
|
|
4604
|
+
state_rel="${state_rel%/}"
|
|
4605
|
+
if [ -z "$state_rel" ] \
|
|
4606
|
+
|| ! git -C "$root" cat-file -e "refs/remotes/origin/$CONTROL_BRANCH:$state_rel/story_plans/${CONTROL_BRANCH}_story_plan.md" 2>/dev/null; then
|
|
4607
|
+
control_refuse "$EXIT_REFUSED" "\`$CONTROL_BRANCH\` carries no story index (\`$state_rel/story_plans/${CONTROL_BRANCH}_story_plan.md\`), which a user-review round reads, so the round cannot start on this branch" \
|
|
4608
|
+
"Review a branch whose run has planned its stories."
|
|
4609
|
+
fi
|
|
4610
|
+
}
|
|
4611
|
+
|
|
4612
|
+
# round_collect <pr_number> <out_file> — the cumulative round of the global
|
|
4613
|
+
# `branch`'s pull request <pr_number>, shared by `control` and `collect`: every
|
|
4614
|
+
# submitted review (never `PENDING`) that requests changes or carries a
|
|
4615
|
+
# non-blank body, and every inline comment, no earlier round consumed, by
|
|
4616
|
+
# every author `authorise_actor` accepts. What earlier rounds consumed is
|
|
4617
|
+
# read from the marker lines of their files on origin's tip; with none marked,
|
|
4618
|
+
# the boundary is the committer time of the newest round file. A comment
|
|
4619
|
+
# belonging to a pending review is pending whatever its `created_at`: a draft
|
|
4620
|
+
# comment is created before its review is submitted. RC_EVENT, set by the
|
|
4621
|
+
# caller, is the event's own review as a JSON object, merged when the listing
|
|
4622
|
+
# lacks it. Sets RC_REVIEWS (the kept reviews requesting changes only),
|
|
4623
|
+
# RC_REVIEWERS (distinct logins of every review written, comma-joined, in
|
|
4624
|
+
# order) and RC_EVENT_ROUND (the round whose marker records RC_EVENT's id).
|
|
4625
|
+
# Returns 0 with <out_file> written; 1 when no review requesting changes is
|
|
4626
|
+
# pending, writing nothing and recording nothing, so a *Comment* or *Approve*
|
|
4627
|
+
# review rides along in the next round one requesting changes starts; 3 a listing or a permission call failed; 4 the
|
|
4628
|
+
# previous rounds or <out_file> could not be read or written. RC_ERR holds why.
|
|
4629
|
+
RC_EVENT=""
|
|
4630
|
+
RC_REVIEWS=0
|
|
4631
|
+
RC_REVIEWERS=""
|
|
4632
|
+
RC_EVENT_ROUND=""
|
|
4633
|
+
RC_ERR=""
|
|
4634
|
+
# round_markers_read <event_review_id> — what the global `branch`'s rounds on
|
|
4635
|
+
# origin's tip consumed, from their marker lines: RC_SEEN_R and RC_SEEN_C (the
|
|
4636
|
+
# recorded review and comment ids, comma-wrapped), RC_MARKED_AT (the
|
|
4637
|
+
# highest-numbered marked round's `collected_at`, empty when none is marked),
|
|
4638
|
+
# RC_EVENT_ROUND (the round recording <event_review_id>), and RC_STATE_REL.
|
|
4639
|
+
# 4 with RC_ERR when a round cannot be listed or read. `control` also calls it
|
|
4640
|
+
# on its own, to name the round a review in flight is already part of.
|
|
4641
|
+
RC_SEEN_R=","
|
|
4642
|
+
RC_SEEN_C=","
|
|
4643
|
+
RC_MARKED_AT=""
|
|
4644
|
+
RC_STATE_REL=""
|
|
4645
|
+
round_markers_read() {
|
|
4646
|
+
local event_id="${1-}" names name path n line marked_max=0
|
|
4647
|
+
RC_SEEN_R=","
|
|
4648
|
+
RC_SEEN_C=","
|
|
4649
|
+
RC_MARKED_AT=""
|
|
4650
|
+
RC_EVENT_ROUND=""
|
|
4651
|
+
RC_STATE_REL=$(hr_state_dir "$root" 2>/dev/null) || RC_STATE_REL=""
|
|
4652
|
+
RC_STATE_REL="${RC_STATE_REL%/}"
|
|
4653
|
+
if [ -z "$RC_STATE_REL" ]; then
|
|
4654
|
+
RC_ERR="the state directory under '$root' could not be resolved"
|
|
4655
|
+
return 4
|
|
4656
|
+
fi
|
|
4657
|
+
if ! names=$(git -C "$root" ls-tree --name-only "refs/remotes/origin/$branch" -- "$RC_STATE_REL/user_reviews/" 2>/dev/null); then
|
|
4658
|
+
RC_ERR="the previous rounds of \`$branch\` could not be listed"
|
|
4659
|
+
return 4
|
|
4660
|
+
fi
|
|
4661
|
+
while IFS= read -r path; do
|
|
4662
|
+
name="${path##*/}"
|
|
4663
|
+
[[ "$name" =~ ^(.+)_review(_([0-9]+))?\.md$ ]] || continue
|
|
4664
|
+
[ "${BASH_REMATCH[1]}" = "$branch" ] || continue
|
|
4665
|
+
n="${BASH_REMATCH[3]:-1}"
|
|
4666
|
+
n=$((10#$n))
|
|
4667
|
+
if ! line=$(git -C "$root" show "refs/remotes/origin/$branch:$path" 2>/dev/null); then
|
|
4668
|
+
RC_ERR="the previous round \`$path\` could not be read"
|
|
4669
|
+
return 4
|
|
4670
|
+
fi
|
|
4671
|
+
# The last marker line wins; a round placed before markers carries none.
|
|
4672
|
+
line=$(printf '%s\n' "$line" | grep -F "$COMMENT_MARKER round collected_at=" | tail -n 1)
|
|
4673
|
+
[[ "$line" =~ ^"$COMMENT_MARKER round collected_at="([0-9T:Z-]+)" reviews="([0-9,]*)" comments="([0-9,]*)" -->"$ ]] || continue
|
|
4674
|
+
RC_SEEN_R="$RC_SEEN_R${BASH_REMATCH[2]}${BASH_REMATCH[2]:+,}"
|
|
4675
|
+
RC_SEEN_C="$RC_SEEN_C${BASH_REMATCH[3]}${BASH_REMATCH[3]:+,}"
|
|
4676
|
+
if [ "$n" -gt "$marked_max" ]; then
|
|
4677
|
+
marked_max="$n"
|
|
4678
|
+
RC_MARKED_AT="${BASH_REMATCH[1]}"
|
|
4679
|
+
fi
|
|
4680
|
+
case ",${BASH_REMATCH[2]}," in
|
|
4681
|
+
*",$event_id,"*) [ -z "$event_id" ] || RC_EVENT_ROUND="$n" ;;
|
|
4682
|
+
esac
|
|
4683
|
+
done <<NAMES
|
|
4684
|
+
$names
|
|
4685
|
+
NAMES
|
|
4686
|
+
return 0
|
|
4687
|
+
}
|
|
4688
|
+
|
|
4689
|
+
round_collect() {
|
|
4690
|
+
local pr="$1" file="$2" collected_at state_rel event_id=""
|
|
4691
|
+
local seen_r seen_c marked_at since="" tmp status
|
|
4692
|
+
local kept authors login type allowed="," count text
|
|
4693
|
+
RC_REVIEWS=0
|
|
4694
|
+
RC_REVIEWERS=""
|
|
4695
|
+
RC_EVENT_ROUND=""
|
|
4696
|
+
RC_ERR=""
|
|
4697
|
+
collected_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
4698
|
+
[ -z "$RC_EVENT" ] || event_id=$(printf '%s' "$RC_EVENT" | jq -r '.id // empty' 2>/dev/null) || event_id=""
|
|
4699
|
+
|
|
4700
|
+
round_markers_read "$event_id" || return 4
|
|
4701
|
+
state_rel="$RC_STATE_REL"
|
|
4702
|
+
seen_r="$RC_SEEN_R"
|
|
4703
|
+
seen_c="$RC_SEEN_C"
|
|
4704
|
+
marked_at="$RC_MARKED_AT"
|
|
4705
|
+
# Epoch seconds, so the comparison with `submitted_at` / `created_at` reads no time zone.
|
|
4706
|
+
if [ -n "$marked_at" ]; then
|
|
4707
|
+
if ! since=$(jq -n -r --arg t "$marked_at" --argjson o "$ROUND_OVERLAP_SECS" '($t | fromdateiso8601) - $o' 2>/dev/null); then
|
|
4708
|
+
RC_ERR="the previous round's collected_at '$marked_at' is not a UTC time"
|
|
4709
|
+
return 4
|
|
4710
|
+
fi
|
|
4711
|
+
elif ! since=$(git -C "$root" log -1 --format=%ct "refs/remotes/origin/$branch" -- \
|
|
4712
|
+
"$state_rel/user_reviews/${branch}_review.md" \
|
|
4713
|
+
"$state_rel/user_reviews/${branch}_review_[0-9]*.md" 2>/dev/null); then
|
|
4714
|
+
RC_ERR="the previous round of \`$branch\` could not be read"
|
|
4715
|
+
return 4
|
|
4716
|
+
fi
|
|
4717
|
+
|
|
4718
|
+
if ! tmp=$(mktemp -d); then
|
|
4719
|
+
RC_ERR="a collection directory could not be created"
|
|
4720
|
+
return 4
|
|
4721
|
+
fi
|
|
4722
|
+
if ! gh_call api --paginate "repos/$FORGE_REPO/pulls/$pr/reviews"; then
|
|
4723
|
+
RC_ERR="the reviews of pull request #$pr could not be read ($GH_ERR)"
|
|
4724
|
+
rm -rf -- "$tmp"
|
|
4725
|
+
return 3
|
|
4726
|
+
fi
|
|
4727
|
+
printf '%s' "$GH_OUT" >"$tmp/reviews.json"
|
|
4728
|
+
if ! gh_call api --paginate "repos/$FORGE_REPO/pulls/$pr/comments"; then
|
|
4729
|
+
RC_ERR="the inline comments of pull request #$pr could not be read ($GH_ERR)"
|
|
4730
|
+
rm -rf -- "$tmp"
|
|
4731
|
+
return 3
|
|
4732
|
+
fi
|
|
4733
|
+
printf '%s' "$GH_OUT" >"$tmp/comments.json"
|
|
4734
|
+
|
|
4735
|
+
# A paginated listing is one JSON array per page; jq selects, so every
|
|
4736
|
+
# listed field is data and never shell source.
|
|
4737
|
+
if ! kept=$(jq -n -c --slurpfile R "$tmp/reviews.json" --slurpfile C "$tmp/comments.json" \
|
|
4738
|
+
--argjson event "${RC_EVENT:-null}" --arg since "$since" --arg state "$REVIEW_ROUND_STATE" \
|
|
4739
|
+
--arg marker "$COMMENT_MARKER" --arg seen_r "$seen_r" --arg seen_c "$seen_c" '
|
|
4740
|
+
def flat: [ .[] | if type == "array" then .[] else error("not a page") end ];
|
|
4741
|
+
def unseen($ids): ("," + tostring + ",") as $k | ($ids | contains($k)) | not;
|
|
4742
|
+
def since_ok($t): $since == ""
|
|
4743
|
+
or ((($t // "") | try fromdateiso8601 catch 0) >= ($since | tonumber));
|
|
4744
|
+
($R | flat) as $listed
|
|
4745
|
+
| (if $event == null or any($listed[]; .id == $event.id) then $listed else $listed + [$event] end)
|
|
4746
|
+
| [ .[] | ((.state // "") | ascii_downcase) as $s
|
|
4747
|
+
| select($s != "" and $s != "pending")
|
|
4748
|
+
| select($s == $state or ((.body // "") | test("\\S")))
|
|
4749
|
+
| select(((.body // "") | contains($marker)) | not)
|
|
4750
|
+
| select(.id | unseen($seen_r))
|
|
4751
|
+
| select(since_ok(.submitted_at)) ]
|
|
4752
|
+
| sort_by([.submitted_at, .id]) as $reviews
|
|
4753
|
+
| ($reviews | map(.id)) as $ids
|
|
4754
|
+
| ($C | flat)
|
|
4755
|
+
| [ .[] | select(.id | unseen($seen_c))
|
|
4756
|
+
| select(((.body // "") | contains($marker)) | not)
|
|
4757
|
+
| select(since_ok(.created_at) or (.pull_request_review_id as $r | any($ids[]; . == $r))) ]
|
|
4758
|
+
| sort_by([.created_at, .id]) as $comments
|
|
4759
|
+
| {reviews: $reviews, comments: $comments}' 2>/dev/null); then
|
|
4760
|
+
RC_ERR="the reviews or inline comments of pull request #$pr are not the expected JSON"
|
|
4761
|
+
rm -rf -- "$tmp"
|
|
4762
|
+
return 3
|
|
4763
|
+
fi
|
|
4764
|
+
rm -rf -- "$tmp"
|
|
4765
|
+
|
|
4766
|
+
# One permission answer per distinct author; a refused author's items are
|
|
4767
|
+
# dropped with one line, and a failed call fails the collection.
|
|
4768
|
+
authors=$(printf '%s' "$kept" | jq -r '
|
|
4769
|
+
[ (.reviews[], .comments[]) | {l: (.user.login // ""), t: (.user.type // "")} ]
|
|
4770
|
+
| reduce .[] as $a ([]; if any(.[]; .l == $a.l) then . else . + [$a] end)
|
|
4771
|
+
| .[] | "\(.l)\t\(.t)"')
|
|
4772
|
+
while IFS=$'\t' read -r login type; do
|
|
4773
|
+
[ -n "$login$type" ] || continue
|
|
4774
|
+
status=0
|
|
4775
|
+
authorise_actor "$login" "$type" || status=$?
|
|
4776
|
+
case "$status" in
|
|
4777
|
+
0) allowed="$allowed$login," ;;
|
|
4778
|
+
4)
|
|
4779
|
+
RC_ERR="${AUTH_WHY%.}"
|
|
4780
|
+
return 3 ;;
|
|
4781
|
+
*)
|
|
4782
|
+
count=$(printf '%s' "$kept" | jq -r --arg l "$login" '[ (.reviews[], .comments[]) | select((.user.login // "") == $l) ] | length')
|
|
4783
|
+
echo "remote-run.sh: round: dropped $count item(s) by @${login:-(no login)}: $AUTH_WHY" ;;
|
|
4784
|
+
esac
|
|
4785
|
+
done <<AUTHORS
|
|
4786
|
+
$authors
|
|
4787
|
+
AUTHORS
|
|
4788
|
+
kept=$(printf '%s' "$kept" | jq -c --arg allowed "$allowed" '
|
|
4789
|
+
def ok: ("," + (.user.login // "") + ",") as $k | ($allowed | contains($k)) and (.user.login // "") != "";
|
|
4790
|
+
{reviews: [ .reviews[] | select(ok) ], comments: [ .comments[] | select(ok) ]}')
|
|
4791
|
+
|
|
4792
|
+
RC_REVIEWS=$(printf '%s' "$kept" | jq -r --arg s "$REVIEW_ROUND_STATE" \
|
|
4793
|
+
'[ .reviews[] | select(((.state // "") | ascii_downcase) == $s) ] | length')
|
|
4794
|
+
[ "$RC_REVIEWS" -gt 0 ] || return 1
|
|
4795
|
+
RC_REVIEWERS=$(printf '%s' "$kept" | jq -r '
|
|
4796
|
+
reduce (.reviews[] | .user.login) as $l ([]; if any(.[]; . == $l) then . else . + [$l] end) | join(",")')
|
|
4797
|
+
|
|
4798
|
+
# A hunk's fence is one backtick longer than its longest backtick run, at
|
|
4799
|
+
# least three. The trailing `x` keeps the text's final newline through the
|
|
4800
|
+
# substitution.
|
|
4801
|
+
if ! text=$(printf '%s' "$kept" | jq -j --arg pr "$pr" --arg at "$collected_at" --arg marker "$COMMENT_MARKER" --arg state "$REVIEW_ROUND_STATE" '
|
|
4802
|
+
def nl: if endswith("\n") then . else . + "\n" end;
|
|
4803
|
+
.reviews as $rv | .comments as $cm
|
|
4804
|
+
| ($rv | map(
|
|
4805
|
+
((.state // "") | ascii_downcase) as $s
|
|
4806
|
+
| (" pull request #" + $pr + " (" + (.html_url // "") + ") at " + (.submitted_at // "")) as $on
|
|
4807
|
+
| "## Review by @" + .user.login + "\n\n"
|
|
4808
|
+
+ (if (.body // "") == "" then "(The review carries no summary.)\n" else (.body | nl) end)
|
|
4809
|
+
+ "\n"
|
|
4810
|
+
+ (if $s == $state then "Requested changes on" + $on + ".\n"
|
|
4811
|
+
elif $s == "commented" then "Commented on" + $on + ".\n"
|
|
4812
|
+
elif $s == "approved" then "Approved" + $on + ".\n"
|
|
4813
|
+
elif $s == "dismissed" then "Reviewed" + $on + "; the review has since been dismissed.\n"
|
|
4814
|
+
else "Reviewed" + $on + " (state " + $s + ").\n" end)
|
|
4815
|
+
) | join("\n"))
|
|
4816
|
+
+ (if ($cm | length) == 0 then "" else
|
|
4817
|
+
"\n## Inline comments\n" + ($cm | map(
|
|
4818
|
+
(.diff_hunk // "") as $h
|
|
4819
|
+
| (([$h | match("`+"; "g") | .length] | max) // 0) as $m
|
|
4820
|
+
| ("`" * ([$m + 1, 3] | max)) as $f
|
|
4821
|
+
| "\n### `" + (.path // "") + "`"
|
|
4822
|
+
+ (if .line != null then ", line \(.line)"
|
|
4823
|
+
elif .original_line != null then ", original line \(.original_line) (outdated)"
|
|
4824
|
+
else "" end)
|
|
4825
|
+
+ "\n\nMade on commit `" + (.original_commit_id // .commit_id // "") + "`.\n"
|
|
4826
|
+
+ "By @" + .user.login + ": " + (.html_url // "") + "\n\n"
|
|
4827
|
+
+ ((.body // "") | nl)
|
|
4828
|
+
+ "\n" + $f + "diff\n" + ($h | nl) + $f + "\n"
|
|
4829
|
+
) | join(""))
|
|
4830
|
+
end)
|
|
4831
|
+
+ "\n" + $marker + " round collected_at=" + $at
|
|
4832
|
+
+ " reviews=" + ($rv | map(.id | tostring) | join(","))
|
|
4833
|
+
+ " comments=" + ($cm | map(.id | tostring) | join(",")) + " -->\n"
|
|
4834
|
+
+ "x"' 2>/dev/null); then
|
|
4835
|
+
RC_ERR="the round could not be rendered"
|
|
4836
|
+
return 4
|
|
4837
|
+
fi
|
|
4838
|
+
if ! printf '%s' "${text%x}" >"$file"; then
|
|
4839
|
+
RC_ERR="the round could not be written to '$file'"
|
|
4840
|
+
return 4
|
|
4841
|
+
fi
|
|
4842
|
+
return 0
|
|
4843
|
+
}
|
|
4844
|
+
|
|
4845
|
+
# The way on of every review reply that started nothing: a review is never
|
|
4846
|
+
# resubmitted to be kept, only to retry now.
|
|
4847
|
+
REVIEW_RETRY_WAY="The reviews stay on the pull request and are collected by the next round; submit any review requesting changes to retry now."
|
|
4848
|
+
|
|
4849
|
+
# control_settled_var — `branch_settled_var` for CONTROL_BRANCH, with no run
|
|
4850
|
+
# counted as settled, run in a command substitution so that its exit on a
|
|
4851
|
+
# failed read reaches this process as a status, not as an exit with no reply:
|
|
4852
|
+
# BS_SETTLED, BS_STATE and BS_REASON; 1 with BS_ERR, its last stderr line, on a
|
|
4853
|
+
# failed read.
|
|
4854
|
+
BS_SETTLED=0; BS_STATE=""; BS_REASON=""; BS_ERR=""
|
|
4855
|
+
control_settled_var() {
|
|
4856
|
+
local dir errfile line status=0
|
|
4857
|
+
BS_SETTLED=0; BS_STATE=""; BS_REASON=""; BS_ERR=""
|
|
4858
|
+
if ! dir=$(mktemp -d "$control_tmp/harness-control-settled.XXXXXX"); then
|
|
4859
|
+
BS_ERR="a state directory could not be created under '$control_tmp'"
|
|
4860
|
+
return 1
|
|
4861
|
+
fi
|
|
4862
|
+
errfile="$dir.err"
|
|
4863
|
+
control_dirs="$control_dirs $dir $errfile"
|
|
4864
|
+
branch="$CONTROL_BRANCH"
|
|
4865
|
+
line=$(branch_settled_var "$dir" 1 >/dev/null 2>"$errfile" \
|
|
4866
|
+
&& printf '%s|%s|%s\n' "$SETTLED" "$RS_STATE" "$RS_PAUSE_REASON") || status=$?
|
|
4867
|
+
cat "$errfile" >&2 2>/dev/null || :
|
|
4868
|
+
if [ "$status" -ne 0 ] || [ -z "$line" ]; then
|
|
4869
|
+
BS_ERR=$(grep -v '^[[:space:]]*$' "$errfile" 2>/dev/null | tail -n 1)
|
|
4870
|
+
[ -n "$BS_ERR" ] || BS_ERR="exit $status, no message"
|
|
4871
|
+
return 1
|
|
4872
|
+
fi
|
|
4873
|
+
IFS='|' read -r BS_SETTLED BS_STATE BS_REASON <<<"$line"
|
|
4874
|
+
return 0
|
|
4875
|
+
}
|
|
4876
|
+
|
|
4877
|
+
# control_review_in_flight — the reply to a review while CONTROL_BRANCH is in
|
|
4878
|
+
# flight (BS_STATE; empty when unknown), then exit 0: never a refusal, nothing
|
|
4879
|
+
# pushed or dispatched. Names the round whose marker on origin's tip already
|
|
4880
|
+
# records the review, else says it was collected, with the state's way on.
|
|
4881
|
+
control_review_in_flight() {
|
|
4882
|
+
local state="in flight" way=""
|
|
4883
|
+
if [ -n "$BS_STATE" ]; then
|
|
4884
|
+
state="\`$BS_STATE\`"
|
|
4885
|
+
[ -z "$BS_REASON" ] || state="$state (\`$BS_REASON\`)"
|
|
4886
|
+
fi
|
|
4887
|
+
case "$BS_STATE" in
|
|
4888
|
+
parked)
|
|
4889
|
+
way=" The run waits for an answer: comment \`$COMMAND_HANDLE answer <n>\` with the answer to its open question <n> on the lines below it." ;;
|
|
4890
|
+
park_loop)
|
|
4891
|
+
way=" The run is held by the park-loop guard: comment \`$COMMAND_HANDLE clear\` to release the hold." ;;
|
|
4892
|
+
paused)
|
|
4893
|
+
if [ "$BS_REASON" = usage ]; then
|
|
4894
|
+
way=" The run resumes by itself once the usage limit resets."
|
|
4895
|
+
else
|
|
4896
|
+
way=" Comment \`$COMMAND_HANDLE resume\` to resume the run from its committed ledger."
|
|
4897
|
+
fi ;;
|
|
4898
|
+
esac
|
|
4899
|
+
branch="$CONTROL_BRANCH"
|
|
4900
|
+
if round_markers_read "$REVIEW_ID" && [ -n "$RC_EVENT_ROUND" ]; then
|
|
4901
|
+
control_reply "$EXIT_OK" "@$CONTROL_ACTOR: your review is part of round $RC_EVENT_ROUND, which is $state on \`$CONTROL_BRANCH\`."
|
|
4902
|
+
fi
|
|
4903
|
+
control_reply "$EXIT_OK" "@$CONTROL_ACTOR: your review was collected. \`$CONTROL_BRANCH\` is $state; when that run finishes, the next user-review round starts by itself from every review requesting changes and every inline comment left since the previous round, yours included. Nothing needs to be submitted again.$way"
|
|
4904
|
+
}
|
|
4905
|
+
|
|
4906
|
+
# control_review — a review requesting changes. In flight: acknowledged and
|
|
4907
|
+
# left on the pull request for the next round. Settled: the cumulative round,
|
|
4908
|
+
# placed and dispatched by a `review` child, the local relay's own verb.
|
|
4909
|
+
control_review() {
|
|
4910
|
+
local dir file out status
|
|
4911
|
+
control_review_story
|
|
4912
|
+
control_settled_var \
|
|
4913
|
+
|| control_refuse "$EXIT_GH" "the state of the run on \`$CONTROL_BRANCH\` could not be read ($BS_ERR)" "$REVIEW_RETRY_WAY"
|
|
4914
|
+
[ "$BS_SETTLED" = 1 ] || control_review_in_flight
|
|
4915
|
+
if ! dir=$(mktemp -d "$control_tmp/harness-control-review.XXXXXX"); then
|
|
4916
|
+
control_refuse "$EXIT_PLACEMENT" "a round directory could not be created under '$control_tmp', so nothing was dispatched" \
|
|
4917
|
+
"$REVIEW_RETRY_WAY"
|
|
4918
|
+
fi
|
|
4919
|
+
control_dirs="$control_dirs $dir"
|
|
4920
|
+
file="$dir/review.md"
|
|
4921
|
+
if ! RC_EVENT=$(jq -n -c --argjson id "$REVIEW_ID" --arg body "$CONTROL_BODY" --arg url "$REVIEW_URL" \
|
|
4922
|
+
--arg at "$REVIEW_AT" --arg login "$CONTROL_ACTOR" --arg type "$CONTROL_SENDER_TYPE" \
|
|
4923
|
+
'{id: $id, state: "CHANGES_REQUESTED", body: $body, html_url: $url, submitted_at: $at, user: {login: $login, type: $type}}'); then
|
|
4924
|
+
control_refuse "$EXIT_PLACEMENT" "the review could not be read into the round, so nothing was dispatched" \
|
|
4925
|
+
"$REVIEW_RETRY_WAY"
|
|
4926
|
+
fi
|
|
4927
|
+
branch="$CONTROL_BRANCH"
|
|
4928
|
+
status=0
|
|
4929
|
+
round_collect "$CONTROL_NUMBER" "$file" || status=$?
|
|
4930
|
+
case "$status" in
|
|
4931
|
+
0) ;;
|
|
4932
|
+
1)
|
|
4933
|
+
if [ -n "$RC_EVENT_ROUND" ]; then
|
|
4934
|
+
control_reply "$EXIT_OK" "@$CONTROL_ACTOR: your review is part of round $RC_EVENT_ROUND of \`$CONTROL_BRANCH\`; nothing new was collected."
|
|
4935
|
+
fi
|
|
4936
|
+
control_reply "$EXIT_OK" "@$CONTROL_ACTOR: no review requesting changes is pending on pull request #$CONTROL_NUMBER since the previous round of \`$CONTROL_BRANCH\`, so no round was started." ;;
|
|
4937
|
+
3)
|
|
4938
|
+
control_refuse "$EXIT_GH" "the round could not be collected: $RC_ERR" "$REVIEW_RETRY_WAY" ;;
|
|
4939
|
+
*)
|
|
4940
|
+
control_refuse "$EXIT_PLACEMENT" "the round could not be collected: $RC_ERR, so nothing was dispatched" \
|
|
4941
|
+
"$REVIEW_RETRY_WAY" ;;
|
|
4942
|
+
esac
|
|
4943
|
+
out="$dir/review.out"
|
|
4944
|
+
set -- review "$CONTROL_BRANCH" --review-file "$file" --allow-no-run --reviewers "$RC_REVIEWERS"
|
|
4945
|
+
case "$FORGE_SERVER" in
|
|
4946
|
+
https://*) set -- "$@" --source "$FORGE_SERVER/$FORGE_REPO/pull/$CONTROL_NUMBER" ;;
|
|
4947
|
+
esac
|
|
4948
|
+
control_child "$out" "$@" --repo "$root"
|
|
4949
|
+
cat "$out" 2>/dev/null || :
|
|
4950
|
+
case "$CHILD_STATUS" in
|
|
4951
|
+
0) exit "$EXIT_OK" ;;
|
|
4952
|
+
2)
|
|
4953
|
+
# The branch became unsettled between the two reads: a run started in
|
|
4954
|
+
# between, so the review waits for it like any review in flight.
|
|
4955
|
+
control_settled_var && [ "$BS_SETTLED" != 1 ] || { BS_STATE=""; BS_REASON=""; }
|
|
4956
|
+
control_review_in_flight ;;
|
|
4957
|
+
3)
|
|
4958
|
+
control_refuse "$EXIT_GH" "the round is pushed but its dispatch failed ($CHILD_LAST)" \
|
|
4959
|
+
"Re-send the dispatch as that line says." ;;
|
|
4960
|
+
4)
|
|
4961
|
+
control_refuse "$EXIT_PLACEMENT" "placing the round failed and nothing was dispatched ($CHILD_LAST)" \
|
|
4962
|
+
"$REVIEW_RETRY_WAY" ;;
|
|
4963
|
+
*)
|
|
4964
|
+
control_refuse "$EXIT_GH" "the round could not be started ($CHILD_LAST)" "$REVIEW_RETRY_WAY" ;;
|
|
4965
|
+
esac
|
|
4966
|
+
}
|
|
4967
|
+
|
|
4968
|
+
# control_review_intake — read a `pull_request_review` event; returns 1 when it
|
|
4969
|
+
# is ignored, after one line.
|
|
4970
|
+
control_review_intake() {
|
|
4971
|
+
local action state head_repo
|
|
4972
|
+
{ event_field '.action // ""' && action="$EVENT_VALUE" \
|
|
4973
|
+
&& event_field '.review.state // ""' && state="$EVENT_VALUE" \
|
|
4974
|
+
&& event_field '.review.body // ""' && CONTROL_BODY="$EVENT_VALUE" \
|
|
4975
|
+
&& event_field '.review.id // ""' && REVIEW_ID="$EVENT_VALUE" \
|
|
4976
|
+
&& event_field '.review.html_url // ""' && REVIEW_URL="$EVENT_VALUE" \
|
|
4977
|
+
&& event_field '.review.submitted_at // ""' && REVIEW_AT="$EVENT_VALUE" \
|
|
4978
|
+
&& event_field '.pull_request.number // ""' && CONTROL_NUMBER="$EVENT_VALUE" \
|
|
4979
|
+
&& event_field '.pull_request.head.ref // ""' && REVIEW_HEAD="$EVENT_VALUE" \
|
|
4980
|
+
&& event_field '.pull_request.head.repo.full_name // ""' && head_repo="$EVENT_VALUE" \
|
|
4981
|
+
&& event_field '.sender.login // ""' && CONTROL_ACTOR="$EVENT_VALUE" \
|
|
4982
|
+
&& event_field '.sender.type // ""' && CONTROL_SENDER_TYPE="$EVENT_VALUE"; } || {
|
|
4983
|
+
echo "remote-run.sh: control: '$GITHUB_EVENT_PATH' is not a readable event" >&2
|
|
4984
|
+
exit "$EXIT_USAGE"
|
|
4985
|
+
}
|
|
4986
|
+
if [ "$action" != submitted ]; then
|
|
4987
|
+
echo "remote-run.sh: control: ignored, a review $action, not submitted"
|
|
4988
|
+
return 1
|
|
4989
|
+
fi
|
|
4990
|
+
# The REST API reports states in uppercase, the webhook in lowercase.
|
|
4991
|
+
if [ "$(printf '%s' "$state" | tr '[:upper:]' '[:lower:]')" != "$REVIEW_ROUND_STATE" ]; then
|
|
4992
|
+
echo "remote-run.sh: control: ignored, a review whose state is ${state:-empty}, not $REVIEW_ROUND_STATE"
|
|
4993
|
+
return 1
|
|
4994
|
+
fi
|
|
4995
|
+
case "$CONTROL_BODY" in
|
|
4996
|
+
*"$COMMENT_MARKER"*)
|
|
4997
|
+
echo "remote-run.sh: control: ignored, a review carrying the harness's marker"
|
|
4998
|
+
return 1 ;;
|
|
4999
|
+
esac
|
|
5000
|
+
# A fork's review job holds a read-only token, so it is not even replied to.
|
|
5001
|
+
if [ -z "$head_repo" ] || [ "$head_repo" != "${GITHUB_REPOSITORY-}" ]; then
|
|
5002
|
+
echo "remote-run.sh: control: ignored, a review of a head in ${head_repo:-an unnamed repository}, not ${GITHUB_REPOSITORY:-this repository}"
|
|
5003
|
+
return 1
|
|
5004
|
+
fi
|
|
5005
|
+
case "$REVIEW_ID" in
|
|
5006
|
+
''|*[!0-9]*|0*)
|
|
5007
|
+
echo "remote-run.sh: control: the event carries no review id" >&2
|
|
5008
|
+
exit "$EXIT_USAGE" ;;
|
|
5009
|
+
esac
|
|
5010
|
+
CONTROL_VERB=review
|
|
5011
|
+
return 0
|
|
5012
|
+
}
|
|
5013
|
+
|
|
5014
|
+
# control_comment_intake — read an `issue_comment` event; returns 1 when it is
|
|
5015
|
+
# ignored, after one line.
|
|
5016
|
+
CONTROL_SENDER_TYPE=""
|
|
5017
|
+
CONTROL_IS_PR=""
|
|
5018
|
+
control_comment_intake() {
|
|
5019
|
+
local action first word rest handle
|
|
5020
|
+
{ event_field '.action // ""' && action="$EVENT_VALUE" \
|
|
5021
|
+
&& event_field '.comment.body // ""' && CONTROL_BODY="$EVENT_VALUE" \
|
|
5022
|
+
&& event_field '.issue.number // ""' && CONTROL_NUMBER="$EVENT_VALUE" \
|
|
5023
|
+
&& event_field '.issue.pull_request.url // ""' && CONTROL_IS_PR="$EVENT_VALUE" \
|
|
5024
|
+
&& event_field '.sender.login // ""' && CONTROL_ACTOR="$EVENT_VALUE" \
|
|
5025
|
+
&& event_field '.sender.type // ""' && CONTROL_SENDER_TYPE="$EVENT_VALUE"; } || {
|
|
5026
|
+
echo "remote-run.sh: control: '$GITHUB_EVENT_PATH' is not a readable event" >&2
|
|
5027
|
+
exit "$EXIT_USAGE"
|
|
5028
|
+
}
|
|
5029
|
+
|
|
5030
|
+
if [ "$action" != created ]; then
|
|
5031
|
+
echo "remote-run.sh: control: ignored, a comment $action, not created"
|
|
5032
|
+
return 1
|
|
5033
|
+
fi
|
|
5034
|
+
case "$CONTROL_BODY" in
|
|
5035
|
+
*"$COMMENT_MARKER"*)
|
|
5036
|
+
echo "remote-run.sh: control: ignored, a comment the harness posted"
|
|
5037
|
+
return 1 ;;
|
|
5038
|
+
esac
|
|
5039
|
+
first=${CONTROL_BODY%%$'\n'*}
|
|
5040
|
+
first=${first%$'\r'}
|
|
5041
|
+
first=${first#"${first%%[!$' \t']*}"}
|
|
5042
|
+
word=${first%%[$' \t']*}
|
|
5043
|
+
rest=${first#"$word"}
|
|
5044
|
+
rest=${rest#"${rest%%[!$' \t']*}"}
|
|
5045
|
+
handle=$(printf '%s' "$COMMAND_HANDLE" | tr '[:upper:]' '[:lower:]')
|
|
5046
|
+
if [ "$(printf '%s' "$word" | tr '[:upper:]' '[:lower:]')" != "$handle" ]; then
|
|
5047
|
+
echo "remote-run.sh: control: ignored, the first line does not open with $COMMAND_HANDLE"
|
|
5048
|
+
return 1
|
|
5049
|
+
fi
|
|
5050
|
+
word=${rest%%[$' \t']*}
|
|
5051
|
+
CONTROL_VERB=$(printf '%s' "$word" | tr '[:upper:]' '[:lower:]')
|
|
5052
|
+
CONTROL_ARGS=${rest#"$word"}
|
|
5053
|
+
CONTROL_ARGS=${CONTROL_ARGS#"${CONTROL_ARGS%%[!$' \t']*}"}
|
|
5054
|
+
return 0
|
|
5055
|
+
}
|
|
5056
|
+
|
|
5057
|
+
verb_control() {
|
|
5058
|
+
local LC_ALL=C
|
|
5059
|
+
local review=0 forge="" target="" status verbs="" v
|
|
5060
|
+
case "${GITHUB_EVENT_NAME-}" in
|
|
5061
|
+
issue_comment) ;;
|
|
5062
|
+
pull_request_review) review=1 ;;
|
|
5063
|
+
*)
|
|
5064
|
+
echo "remote-run.sh: control handles GITHUB_EVENT_NAME issue_comment or pull_request_review, not '${GITHUB_EVENT_NAME-}'" >&2
|
|
5065
|
+
exit "$EXIT_USAGE" ;;
|
|
5066
|
+
esac
|
|
5067
|
+
if [ -z "${GITHUB_EVENT_PATH-}" ] || [ ! -f "$GITHUB_EVENT_PATH" ] || [ ! -r "$GITHUB_EVENT_PATH" ]; then
|
|
5068
|
+
echo "remote-run.sh: control: cannot read the event file '${GITHUB_EVENT_PATH-}'" >&2
|
|
5069
|
+
exit "$EXIT_USAGE"
|
|
5070
|
+
fi
|
|
5071
|
+
hr_have_jq || { echo "remote-run.sh: control needs jq" >&2; exit "$EXIT_USAGE"; }
|
|
5072
|
+
|
|
5073
|
+
if [ "$review" -eq 1 ]; then
|
|
5074
|
+
control_review_intake || return 0
|
|
5075
|
+
else
|
|
5076
|
+
control_comment_intake || return 0
|
|
5077
|
+
fi
|
|
5078
|
+
|
|
5079
|
+
case "$CONTROL_NUMBER" in
|
|
5080
|
+
''|*[!0-9]*|0*)
|
|
5081
|
+
echo "remote-run.sh: control: the event carries no issue number" >&2
|
|
5082
|
+
exit "$EXIT_USAGE" ;;
|
|
5083
|
+
esac
|
|
5084
|
+
|
|
5085
|
+
control_tmp="${RUNNER_TEMP-}"
|
|
5086
|
+
if [ -z "$control_tmp" ] || [ ! -d "$control_tmp" ]; then
|
|
5087
|
+
control_tmp=$(mktemp -d) || { echo "remote-run.sh: control: mktemp failed" >&2; exit "$EXIT_USAGE"; }
|
|
5088
|
+
control_dirs="$control_tmp"
|
|
5089
|
+
fi
|
|
5090
|
+
trap control_cleanup EXIT
|
|
5091
|
+
|
|
5092
|
+
if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
|
|
5093
|
+
control_refuse "$EXIT_REFUSED" "the repository variable \`HARNESS_REMOTE_STOP\` is set, which stops every command" \
|
|
5094
|
+
"Clear it under **Settings → Secrets and variables → Actions → Variables**, then comment again."
|
|
5095
|
+
fi
|
|
5096
|
+
|
|
5097
|
+
forge=$(hr_forge "$root") || forge=""
|
|
5098
|
+
target=$(hr_execution_target "$root") || target=""
|
|
5099
|
+
if [ "$forge" != github ] || [ "$target" != github-actions ]; then
|
|
5100
|
+
control_refuse "$EXIT_REFUSED" "the default branch's \`harness.config.json\` does not turn run control on: it needs \`forge\` set to \`github\` (it is ${forge:-not set or unreadable}) and \`execution.target\` set to \`github-actions\` (it is ${target:-unreadable})" \
|
|
5101
|
+
"Set both keys on the default branch, then comment again."
|
|
5102
|
+
fi
|
|
5103
|
+
|
|
5104
|
+
status=0
|
|
5105
|
+
authorise_actor "$CONTROL_ACTOR" "$CONTROL_SENDER_TYPE" || status=$?
|
|
5106
|
+
if [ "$status" -ne 0 ]; then
|
|
5107
|
+
control_refuse "$EXIT_REFUSED" "${AUTH_WHY%.}" \
|
|
5108
|
+
"Only a collaborator with write, maintain or admin access, or a bot listed in the repository variable \`HARNESS_TRIGGER_ALLOWED_BOTS\`, commands a run."
|
|
5109
|
+
fi
|
|
5110
|
+
|
|
5111
|
+
if [ "$review" -eq 0 ] && { [ -z "$CONTROL_VERB" ] || ! control_verb_handled "$CONTROL_VERB"; }; then
|
|
5112
|
+
for v in $COMMAND_VERBS; do
|
|
5113
|
+
[ "$v" != answer ] || v="answer [<n>]"
|
|
5114
|
+
verbs="$verbs${verbs:+, }\`$COMMAND_HANDLE $v\`"
|
|
5115
|
+
done
|
|
5116
|
+
control_refuse "$EXIT_REFUSED" "it is not a command this harness carries out" \
|
|
5117
|
+
"The commands are $verbs; \`docs/github-run-control.md\` in the harness documentation states each."
|
|
5118
|
+
fi
|
|
5119
|
+
|
|
5120
|
+
forge_repo_var || control_reply "$EXIT_GH" "@$CONTROL_ACTOR: \`$CONTROL_VERB\` was not run: the repository's name could not be read."
|
|
5121
|
+
if [ "$review" -eq 1 ]; then
|
|
5122
|
+
control_check_branch "$REVIEW_HEAD"
|
|
5123
|
+
elif [ -n "$CONTROL_IS_PR" ]; then
|
|
5124
|
+
control_branch_from_pr "$CONTROL_NUMBER"
|
|
5125
|
+
else
|
|
5126
|
+
control_branch_from_issue "$CONTROL_NUMBER"
|
|
5127
|
+
fi
|
|
5128
|
+
|
|
5129
|
+
echo "remote-run.sh: control: $CONTROL_VERB on $CONTROL_BRANCH from @$CONTROL_ACTOR on #$CONTROL_NUMBER"
|
|
5130
|
+
case "$CONTROL_VERB" in
|
|
5131
|
+
answer) control_answer ;;
|
|
5132
|
+
pause) control_pause ;;
|
|
5133
|
+
stop) control_stop ;;
|
|
5134
|
+
resume) control_resume ;;
|
|
5135
|
+
clear) control_clear ;;
|
|
5136
|
+
review) control_review ;;
|
|
5137
|
+
esac
|
|
5138
|
+
}
|
|
5139
|
+
|
|
5140
|
+
# ---------------------------------------------------------------------------
|
|
5141
|
+
# `collect` — the run workflow's last job: the next round from the reviews
|
|
5142
|
+
# collected while the run was in flight. It reuses `control`'s settledness read,
|
|
5143
|
+
# child runner and cleanup, with CONTROL_BRANCH set to the branch.
|
|
5144
|
+
# ---------------------------------------------------------------------------
|
|
5145
|
+
|
|
5146
|
+
# collect_notify <text> — the one comment `collect` posts, on FORGE_PR with the
|
|
5147
|
+
# `reply` marker; a failure is one line.
|
|
5148
|
+
collect_notify() {
|
|
5149
|
+
local file
|
|
5150
|
+
if ! file=$(mktemp "$control_tmp/harness-collect-comment.XXXXXX"); then
|
|
5151
|
+
echo "remote-run.sh: collect: cannot create the comment file for #$FORGE_PR; no comment posted" >&2
|
|
5152
|
+
return 0
|
|
5153
|
+
fi
|
|
5154
|
+
{
|
|
5155
|
+
printf '%s\n' "$1"
|
|
5156
|
+
[ -z "${GITHUB_RUN_ID-}" ] || printf '\nRun: %s\n' "$(this_run_url)"
|
|
5157
|
+
} >"$file"
|
|
5158
|
+
forge_comment "$FORGE_PR" reply "$branch" "$file" || :
|
|
5159
|
+
rm -f "$file"
|
|
5160
|
+
}
|
|
5161
|
+
|
|
5162
|
+
verb_collect() {
|
|
5163
|
+
local status=0 dir file out
|
|
5164
|
+
if ! forge_on; then
|
|
5165
|
+
echo "remote-run.sh: collect: the forge coupling is off (forge github and execution.target github-actions); nothing collected"
|
|
5166
|
+
exit "$EXIT_OK"
|
|
5167
|
+
fi
|
|
5168
|
+
if [ -n "${HARNESS_REMOTE_STOP-}" ]; then
|
|
5169
|
+
echo "remote-run.sh: collect: HARNESS_REMOTE_STOP is set; no round started"
|
|
5170
|
+
exit "$EXIT_OK"
|
|
5171
|
+
fi
|
|
5172
|
+
if ! hr_have_jq; then
|
|
5173
|
+
echo "remote-run.sh: collect: jq is missing; nothing collected"
|
|
5174
|
+
exit "$EXIT_OK"
|
|
5175
|
+
fi
|
|
5176
|
+
forge_repo_var || exit "$EXIT_OK"
|
|
5177
|
+
|
|
5178
|
+
remote_branch_stopped "$branch" || status=$?
|
|
5179
|
+
case "$status" in
|
|
5180
|
+
0)
|
|
5181
|
+
echo "remote-run.sh: collect: $branch is stopped; no round started"
|
|
5182
|
+
exit "$EXIT_OK" ;;
|
|
5183
|
+
2)
|
|
5184
|
+
echo "remote-run.sh: collect: whether $branch is stopped could not be read ($GH_ERR); no round started"
|
|
5185
|
+
exit "$EXIT_OK" ;;
|
|
5186
|
+
esac
|
|
5187
|
+
|
|
5188
|
+
if [ -n "$pr_arg" ]; then
|
|
5189
|
+
FORGE_PR="$pr_arg"
|
|
5190
|
+
elif ! forge_pr_var "$branch"; then
|
|
5191
|
+
echo "remote-run.sh: collect: the pull request of $branch could not be read; nothing collected"
|
|
5192
|
+
exit "$EXIT_OK"
|
|
5193
|
+
fi
|
|
5194
|
+
if [ -z "$FORGE_PR" ]; then
|
|
5195
|
+
echo "remote-run.sh: collect: no open pull request; nothing to collect"
|
|
5196
|
+
exit "$EXIT_OK"
|
|
5197
|
+
fi
|
|
5198
|
+
|
|
5199
|
+
control_tmp="${RUNNER_TEMP-}"
|
|
5200
|
+
if [ -z "$control_tmp" ] || [ ! -d "$control_tmp" ]; then
|
|
5201
|
+
control_tmp=$(mktemp -d) || { echo "remote-run.sh: collect: mktemp failed; nothing collected"; exit "$EXIT_OK"; }
|
|
5202
|
+
control_dirs="$control_tmp"
|
|
5203
|
+
fi
|
|
5204
|
+
trap control_cleanup EXIT
|
|
5205
|
+
|
|
5206
|
+
CONTROL_BRANCH="$branch"
|
|
5207
|
+
if ! control_settled_var; then
|
|
5208
|
+
echo "remote-run.sh: collect: the state of the run on $branch could not be read ($BS_ERR); no round started"
|
|
5209
|
+
exit "$EXIT_OK"
|
|
5210
|
+
fi
|
|
5211
|
+
if [ "$BS_SETTLED" != 1 ]; then
|
|
5212
|
+
echo "remote-run.sh: collect: $branch is ${BS_STATE:-in flight}${BS_REASON:+ ($BS_REASON)}; that run's own end collects"
|
|
5213
|
+
exit "$EXIT_OK"
|
|
5214
|
+
fi
|
|
5215
|
+
|
|
5216
|
+
forge_fetch_branch "$branch"
|
|
5217
|
+
if ! dir=$(mktemp -d "$control_tmp/harness-collect.XXXXXX"); then
|
|
5218
|
+
echo "::warning::remote-run.sh: collect: a round directory could not be created under '$control_tmp'; no round started"
|
|
5219
|
+
exit "$EXIT_OK"
|
|
5220
|
+
fi
|
|
5221
|
+
control_dirs="$control_dirs $dir"
|
|
5222
|
+
file="$dir/review.md"
|
|
5223
|
+
RC_EVENT=""
|
|
5224
|
+
status=0
|
|
5225
|
+
round_collect "$FORGE_PR" "$file" || status=$?
|
|
5226
|
+
case "$status" in
|
|
5227
|
+
0) ;;
|
|
5228
|
+
1)
|
|
5229
|
+
echo "remote-run.sh: collect: no review requesting changes is pending on #$FORGE_PR since the previous round of $branch; no round started"
|
|
5230
|
+
exit "$EXIT_OK" ;;
|
|
5231
|
+
*)
|
|
5232
|
+
echo "::warning::remote-run.sh: collect: the round of $branch could not be collected: $RC_ERR; no round started"
|
|
5233
|
+
exit "$EXIT_OK" ;;
|
|
5234
|
+
esac
|
|
5235
|
+
|
|
5236
|
+
out="$dir/review.out"
|
|
5237
|
+
set -- review "$branch" --review-file "$file" --allow-no-run --reviewers "$RC_REVIEWERS"
|
|
5238
|
+
case "$FORGE_SERVER" in
|
|
5239
|
+
https://*) set -- "$@" --source "$FORGE_SERVER/$FORGE_REPO/pull/$FORGE_PR" ;;
|
|
5240
|
+
esac
|
|
5241
|
+
control_child "$out" "$@" --repo "$root"
|
|
5242
|
+
cat "$out" 2>/dev/null || :
|
|
5243
|
+
if [ "$CHILD_STATUS" -eq 0 ]; then
|
|
5244
|
+
echo "remote-run.sh: collect: started the next round of $branch from @${RC_REVIEWERS//,/, @}"
|
|
5245
|
+
exit "$EXIT_OK"
|
|
5246
|
+
fi
|
|
5247
|
+
collect_notify "The reviews requesting changes collected during the run on \`$branch\` could not start the next round: ${CHILD_LAST%.}. They stay on the pull request; submit a review requesting changes to retry."
|
|
5248
|
+
echo "remote-run.sh: collect: review exited $CHILD_STATUS; the pull request was told, and nothing is retried"
|
|
5249
|
+
exit "$EXIT_OK"
|
|
5250
|
+
}
|
|
5251
|
+
|
|
5252
|
+
# ---------------------------------------------------------------------------
|
|
5253
|
+
# `discard` — remove a directory a command fetched into, inside scratch only.
|
|
5254
|
+
# ---------------------------------------------------------------------------
|
|
5255
|
+
|
|
5256
|
+
# The removal lives here rather than in the command because a supervised or
|
|
5257
|
+
# auto-mode session may refuse a recursive `rm` the agent types, and a
|
|
5258
|
+
# user-level `rm -rf` deny cannot be overridden (`.claude/context/
|
|
5259
|
+
# conventions.md` -> `## Shell assets`). The scope is the scratch directory
|
|
5260
|
+
# only, per the lessons ledger's rule that a script "never removes one it did
|
|
5261
|
+
# not create": scratch holds only throwaway files a session itself wrote. Containment is `hr_scratch_path_var`'s alone; this verb
|
|
5262
|
+
# maps its status and acts on `HR_SCRATCH_TARGET`. It never creates anything.
|
|
5263
|
+
verb_discard() {
|
|
5264
|
+
local status
|
|
5265
|
+
hr_scratch_path_var "$root" "$discard_dir" "$discard_base"
|
|
5266
|
+
status=$?
|
|
5267
|
+
case "$status:$HR_SCRATCH_WHY" in
|
|
5268
|
+
0:*) ;;
|
|
5269
|
+
1:dotdot)
|
|
5270
|
+
echo "remote-run.sh: discard refused, nothing removed: '$discard_dir' carries '..'" >&2
|
|
5271
|
+
exit "$EXIT_REFUSED" ;;
|
|
5272
|
+
1:charset)
|
|
5273
|
+
echo "remote-run.sh: discard refused, nothing removed: '$discard_dir' carries a character a scratch path may not" >&2
|
|
5274
|
+
exit "$EXIT_REFUSED" ;;
|
|
5275
|
+
1:itself)
|
|
5276
|
+
echo "remote-run.sh: discard refused, nothing removed: '$discard_dir' is the scratch directory itself" >&2
|
|
5277
|
+
exit "$EXIT_REFUSED" ;;
|
|
5278
|
+
1:symlink)
|
|
5279
|
+
echo "remote-run.sh: discard refused, nothing removed: '$discard_dir' is a symlink" >&2
|
|
5280
|
+
exit "$EXIT_REFUSED" ;;
|
|
5281
|
+
1:*)
|
|
5282
|
+
echo "remote-run.sh: discard refused, nothing removed: '$discard_dir' is not inside '$HR_SCRATCH_DIR/'" >&2
|
|
5283
|
+
exit "$EXIT_REFUSED" ;;
|
|
5284
|
+
2:*)
|
|
5285
|
+
echo "remote-run.sh: discard: the parent directory of '$discard_dir' cannot be resolved; nothing removed" >&2
|
|
5286
|
+
exit "$EXIT_USAGE" ;;
|
|
5287
|
+
3:no-scratch)
|
|
5288
|
+
echo "remote-run.sh: discard: the state directory's scratch/ under '$root' does not exist; nothing removed" >&2
|
|
5289
|
+
exit "$EXIT_USAGE" ;;
|
|
5290
|
+
3:*)
|
|
5291
|
+
echo "remote-run.sh: cannot resolve '$root/harness.config.json'" >&2
|
|
5292
|
+
exit "$EXIT_USAGE" ;;
|
|
5293
|
+
*)
|
|
5294
|
+
usage "discard needs a <dir>" ;;
|
|
5295
|
+
esac
|
|
5296
|
+
if [ ! -e "$HR_SCRATCH_TARGET" ]; then
|
|
5297
|
+
echo "remote-run.sh: $discard_dir does not exist; nothing removed"
|
|
5298
|
+
return 0
|
|
5299
|
+
fi
|
|
5300
|
+
if [ ! -d "$HR_SCRATCH_TARGET" ]; then
|
|
5301
|
+
echo "remote-run.sh: discard refused, nothing removed: '$discard_dir' is not a directory" >&2
|
|
5302
|
+
exit "$EXIT_REFUSED"
|
|
5303
|
+
fi
|
|
5304
|
+
if ! rm -rf -- "$HR_SCRATCH_TARGET" || [ -e "$HR_SCRATCH_TARGET" ]; then
|
|
5305
|
+
echo "remote-run.sh: discard: removing '$discard_dir' failed" >&2
|
|
5306
|
+
exit "$EXIT_USAGE"
|
|
5307
|
+
fi
|
|
5308
|
+
echo "remote-run.sh: removed $discard_dir"
|
|
5309
|
+
}
|
|
5310
|
+
|
|
1781
5311
|
case "$verb" in
|
|
1782
5312
|
dispatch) verb_dispatch ;;
|
|
1783
5313
|
pause) verb_pause ;;
|
|
@@ -1791,5 +5321,15 @@ case "$verb" in
|
|
|
1791
5321
|
poll) verb_poll ;;
|
|
1792
5322
|
pause-requested) verb_pause_requested ;;
|
|
1793
5323
|
run-created-at) verb_run_created_at ;;
|
|
5324
|
+
start) verb_start ;;
|
|
5325
|
+
fetch) verb_fetch ;;
|
|
5326
|
+
review) verb_review ;;
|
|
5327
|
+
trigger) verb_trigger ;;
|
|
5328
|
+
list) verb_list ;;
|
|
5329
|
+
discard) verb_discard ;;
|
|
5330
|
+
report) verb_report ;;
|
|
5331
|
+
deliver) verb_deliver ;;
|
|
5332
|
+
collect) verb_collect ;;
|
|
5333
|
+
control) verb_control ;;
|
|
1794
5334
|
esac
|
|
1795
5335
|
exit "$EXIT_OK"
|