11#! /usr/bin/env bash
22#
33# A reference item-update command: publishes a planned story onto the GitHub
4- # issue the brief was filed as.
4+ # issue the brief was filed as, and moves that issue's board Status as the
5+ # story is planned, run and finished.
56#
67# This is a template. It ships with the harness, l5-init installs a copy into a
78# target's .harness/item/, and the target is expected to edit it — where a
8- # projection is written, and what it looks like once it is there, are this
9- # file's business rather than the harness's. What is not negotiable is the
10- # contract below, which the harness relies on and cannot enforce.
9+ # projection is written, what it looks like once it is there, and which board
10+ # and column a status names, are this file's business rather than the
11+ # harness's. What is not negotiable is the contract below, which the harness
12+ # relies on and cannot enforce.
1113#
1214# THE CONTRACT
1315#
14- # stdin One JSON document, carrying "key" (the item's, opaque),
15- # "story_id", and "document" — the projection to publish. It carries
16- # "status" only where the caller supplied one, and nothing in the
17- # harness supplies one yet; see THE STATUS HALF below.
16+ # stdin One JSON document, carrying "key" (the item's, opaque) and
17+ # "story_id". It carries "document" — the projection to publish —
18+ # only where the caller had one, and "status" only where the caller
19+ # had one, and at least one of the two is always there; see THE
20+ # THREE MOMENTS below.
1821# L5_ITEM_KEY
1922# The item's key, the same value the document on stdin carries
2023# under "key", taken from the same field so the two cannot disagree.
5659# dedupe and brief fetch all stop working if they go missing. This script
5760# therefore only ever replaces the block between its own markers and appends
5861# after everything else, and it must keep doing both. It is the third member of
59- # that family and the only one that rewrites a body somebody else wrote.
62+ # that family and the only one that rewrites a body somebody else wrote. A
63+ # question carrying a status and no document rewrites no body at all, so every
64+ # one of those markers is left exactly as it was found.
6065#
61- # THE STATUS HALF IS DECLARED AND UNSENT. The question document may carry a
62- # "status", because the sibling work that moves an item's status as its story
63- # runs is about the same item, and two scripts that must agree about one item
64- # are two scripts that can disagree. Nothing in the harness sends one today, so
65- # nothing here acts on one; that work fills this half in rather than adding a
66- # second script beside this one. The harness parses no tracker status and
67- # infers none either.
66+ # THE THREE MOMENTS. The harness sends a status at three points in a story's
67+ # life: planned, when the planning session's artifact has been committed and
68+ # pushed; in_progress, when the run for that story begins; and ready_to_merge,
69+ # when that run completes. The first arrives beside a document, because the
70+ # invocation that publishes the projection carries it; the other two arrive
71+ # alone, because there is nothing new to publish then.
72+ #
73+ # THE HARNESS DOES NOT KNOW WHAT A STATUS IS. It knows a key and which of those
74+ # three moments the work has reached; what a token means in a tracker is
75+ # decided here and nowhere else. This copy reads it as a column on a project
76+ # board, and a target whose tracker says it differently — a label, a row in a
77+ # table — changes this file and changes nothing in the harness. No status is
78+ # ever read back: stdout is unread, and the harness parses none and infers
79+ # none.
80+ #
81+ # THE BOARD MECHANICS ARE GENERIC; THE VALUES ARE THE TARGET'S. This file
82+ # carries how a Status is written and no statement about which board: the
83+ # project, its owner, the field's name and the option each of the three tokens
84+ # names are the constants below, so a board that spells its columns differently
85+ # is a configuration rather than an edit to this logic. An empty project means
86+ # no board, and then a status is something this copy cannot honour and says so.
87+ #
88+ # A STATUS MOVES THE ITEM WHEREVER IT ALREADY IS, which is where this differs
89+ # from the sync script beside it. That script sets a Status only where it finds
90+ # one empty, because it puts a newly filed item into a column once and never
91+ # moves it between columns; the whole point of these three moments is the
92+ # movement, so this one writes over what it finds.
6893#
6994# It requires gh, authenticated, and jq. Both are the target's business.
7095
7196set -uo pipefail
7297
98+ # --- what this target moves items on. Edit these. ------------------------
99+ PROJECT=" ${L5_ITEM_PROJECT:- } " # a project number or URL; empty means no board
100+ PROJECT_OWNER=" ${L5_ITEM_PROJECT_OWNER:-@ me} " # who owns that project
101+ STATUS_FIELD=" ${L5_ITEM_STATUS_FIELD:- Status} "
102+
103+ # The option each of the three tokens names, by the name the board spells it
104+ # with. They are read from the environment for the reason the field name is: a
105+ # board saying "In Progress", "Doing" or "En cours" is a configuration rather
106+ # than an edit to the logic above it.
107+ PLANNED_OPTION=" ${L5_ITEM_PLANNED_OPTION:- Planned} "
108+ IN_PROGRESS_OPTION=" ${L5_ITEM_IN_PROGRESS_OPTION:- In progress} "
109+ READY_TO_MERGE_OPTION=" ${L5_ITEM_READY_TO_MERGE_OPTION:- Ready to merge} "
110+
111+ # How much of the project's item listing is read when looking for this issue's
112+ # item. Not an L5_ITEM_ constant, for the reason the sync script's own bound is
113+ # not: it is a mechanic rather than something this target files against, and a
114+ # bound that was too small costs a reported failure rather than a wrong write.
115+ ITEM_LIST_LIMIT=5000
116+
73117fail () { echo " $* " >&2 ; exit 1; }
74118
75119command -v gh > /dev/null 2>&1 || fail " gh is not on PATH"
@@ -93,40 +137,126 @@ story_id="$(printf '%s' "$question" | jq -r '.story_id // ""')" \
93137
94138document=" $( printf ' %s' " $question " | jq -r ' .document // ""' ) " \
95139 || fail " the question carries no document this command can use"
96- [ -n " $document " ] || fail " the question carried an empty document; nothing was published"
97-
98- # The markers this story's projection lives between. They name the story, so an
99- # item carrying several stories' projections keeps each of them separate, and a
100- # second invocation for one story replaces that story's block alone.
101- begin=" <!-- l5-story-begin: ${story_id} -->"
102- end=" <!-- l5-story-end: ${story_id} -->"
103-
104- body=" $( gh issue view " $key " --json body --jq ' .body // ""' 2> /dev/null) " \
105- || fail " the item ${key} could not be read, so nothing was published onto it"
106-
107- # Everything the item already says, with this story's own block removed if one
108- # is there. Only the region between this story's markers is dropped: the key
109- # marker, the path markers and the payload marker the sync script wrote are
110- # elsewhere in the body and are carried through untouched, which is what keeps
111- # filing, dedupe and brief fetch working over an item that has been published
112- # onto.
113- kept=" $( printf ' %s' " $body " | awk -v begin=" $begin " -v end=" $end " '
114- index($0, begin) { skipping = 1; next }
115- index($0, end) { skipping = 0; next }
116- !skipping { print }
117- ' ) " || fail " the item ${key} body could not be read, so nothing was published onto it"
118-
119- updated=" $( mktemp) " || fail " no temporary file could be made to write the body in"
120- trap ' rm -f "$updated"' EXIT
121-
122- {
123- printf ' %s\n' " $kept "
124- printf ' \n%s\n\n' " $begin "
125- printf ' %s\n' " $document "
126- printf ' \n%s\n' " $end "
127- } > " $updated " || fail " the new body could not be written"
128-
129- gh issue edit " $key " --body-file " $updated " > /dev/null 2>&1 \
130- || fail " the item ${key} could not be updated, so ${story_id} was not published onto it"
140+
141+ status=" $( printf ' %s' " $question " | jq -r ' .status // ""' ) " \
142+ || fail " the question carries no status this command can use"
143+
144+ # One of the two is always there on the harness's side, so a question carrying
145+ # neither is a question with nothing to say and is refused rather than treated
146+ # as a no-op that succeeded.
147+ if [ -z " $document " ] && [ -z " $status " ]; then
148+ fail " the question carried neither a document nor a status; there was nothing to do to ${key} "
149+ fi
150+
151+ # --- the status: which column each token names --------------------------
152+ # The whole of what this script knows about a token is which option it names.
153+ # An unrecognised one is refused rather than guessed at: writing a column
154+ # nobody asked for would be worse than saying the word was not understood.
155+ option=" "
156+ if [ -n " $status " ]; then
157+ case " $status " in
158+ planned) option=" $PLANNED_OPTION " ;;
159+ in_progress) option=" $IN_PROGRESS_OPTION " ;;
160+ ready_to_merge) option=" $READY_TO_MERGE_OPTION " ;;
161+ * ) fail " the status ${status} is not one this command knows an option for, so ${key} was not moved" ;;
162+ esac
163+ [ -n " $option " ] \
164+ || fail " this copy names no board option for ${status} , so ${key} was not moved"
165+ [ -n " $PROJECT " ] \
166+ || fail " no project is configured here, so there is no board to move ${key} to ${option} on"
167+ fi
168+
169+ # --- the document: publish the projection -------------------------------
170+ # Only where one was given. A status arriving alone leaves the item's body
171+ # exactly as it is, which is what the two run-time moments want: the projection
172+ # the planning session published is already there and has not changed.
173+ if [ -n " $document " ]; then
174+ # The markers this story's projection lives between. They name the story, so
175+ # an item carrying several stories' projections keeps each of them separate,
176+ # and a second invocation for one story replaces that story's block alone.
177+ begin=" <!-- l5-story-begin: ${story_id} -->"
178+ end=" <!-- l5-story-end: ${story_id} -->"
179+
180+ body=" $( gh issue view " $key " --json body --jq ' .body // ""' 2> /dev/null) " \
181+ || fail " the item ${key} could not be read, so nothing was published onto it"
182+
183+ # Everything the item already says, with this story's own block removed if
184+ # one is there. Only the region between this story's markers is dropped: the
185+ # key marker, the path markers and the payload marker the sync script wrote
186+ # are elsewhere in the body and are carried through untouched, which is what
187+ # keeps filing, dedupe and brief fetch working over an item that has been
188+ # published onto.
189+ kept=" $( printf ' %s' " $body " | awk -v begin=" $begin " -v end=" $end " '
190+ index($0, begin) { skipping = 1; next }
191+ index($0, end) { skipping = 0; next }
192+ !skipping { print }
193+ ' ) " || fail " the item ${key} body could not be read, so nothing was published onto it"
194+
195+ updated=" $( mktemp) " || fail " no temporary file could be made to write the body in"
196+ trap ' rm -f "$updated"' EXIT
197+
198+ {
199+ printf ' %s\n' " $kept "
200+ printf ' \n%s\n\n' " $begin "
201+ printf ' %s\n' " $document "
202+ printf ' \n%s\n' " $end "
203+ } > " $updated " || fail " the new body could not be written"
204+
205+ gh issue edit " $key " --body-file " $updated " > /dev/null 2>&1 \
206+ || fail " the item ${key} could not be updated, so ${story_id} was not published onto it"
207+ fi
208+
209+ # --- the status: move the item's column ---------------------------------
210+ # Reached only where a status was given, and the project was established above,
211+ # so everything from here is the board work itself. Every way of not reaching
212+ # the board — the item, the project, the field or the option — is said on
213+ # stderr and exits non-zero: an item that was not moved must not be reported as
214+ # one that was.
215+ if [ -n " $status " ]; then
216+ url=" $( gh issue view " $key " --json url --jq ' .url // ""' 2> /dev/null) " \
217+ || fail " the item ${key} could not be read, so it was not moved to ${option} "
218+ [ -n " $url " ] || fail " the item ${key} named no URL, so it was not moved to ${option} "
219+
220+ # item-add for an issue already on the board reports the existing item rather
221+ # than adding a second one, so an item this project already carries is found
222+ # here and one it does not is put on it. The item's id is what item-edit
223+ # takes; it deals in ids and not in names.
224+ added=" $( gh project item-add " $PROJECT " --owner " $PROJECT_OWNER " --url " $url " \
225+ --format json 2> /dev/null) " \
226+ || fail " ${key} could not be added to project ${PROJECT} , so it was not moved to ${option} "
227+ item_id=" $( printf ' %s' " $added " | jq -r ' .id // ""' ) " \
228+ || fail " project ${PROJECT} named no item for ${key} , so it was not moved to ${option} "
229+ [ -n " $item_id " ] \
230+ || fail " project ${PROJECT} named no item for ${key} , so it was not moved to ${option} "
231+
232+ project_id=" $( gh project view " $PROJECT " --owner " $PROJECT_OWNER " --format json 2> /dev/null \
233+ | jq -r ' .id // ""' ) " \
234+ || fail " project ${PROJECT} could not be read, so ${key} was not moved to ${option} "
235+ [ -n " $project_id " ] \
236+ || fail " project ${PROJECT} named no id, so ${key} was not moved to ${option} "
237+
238+ fields=" $( gh project field-list " $PROJECT " --owner " $PROJECT_OWNER " --format json 2> /dev/null) " \
239+ || fail " the fields of project ${PROJECT} could not be read, so ${key} was not moved to ${option} "
240+
241+ field_id=" $( printf ' %s' " $fields " | jq -r --arg name " $STATUS_FIELD " \
242+ ' [.fields[]? | select(.name == $name) | .id] | .[0] // ""' ) " \
243+ || fail " the fields of project ${PROJECT} could not be read, so ${key} was not moved to ${option} "
244+ [ -n " $field_id " ] \
245+ || fail " project ${PROJECT} has no field named ${STATUS_FIELD} , so ${key} was not moved to ${option} "
246+
247+ option_id=" $( printf ' %s' " $fields " | jq -r --arg name " $STATUS_FIELD " --arg option " $option " \
248+ ' [.fields[]? | select(.name == $name) | .options[]? | select(.name == $option) | .id] | .[0] // ""' ) " \
249+ || fail " the options of ${STATUS_FIELD} could not be read, so ${key} was not moved to ${option} "
250+ [ -n " $option_id " ] \
251+ || fail " ${STATUS_FIELD} in project ${PROJECT} has no option named ${option} , so ${key} was not moved"
252+
253+ # Written over whatever the field already says. This is the movement the
254+ # three moments exist for, so unlike the sync script beside it there is no
255+ # only-where-empty rule: an item that is already in a column is exactly the
256+ # item a later moment has to move out of it.
257+ gh project item-edit --id " $item_id " --project-id " $project_id " \
258+ --field-id " $field_id " --single-select-option-id " $option_id " > /dev/null 2>&1 \
259+ || fail " the item is on project ${PROJECT} but its ${STATUS_FIELD} could not be set to ${option} "
260+ fi
131261
132262exit 0
0 commit comments