Check the shell, and stop one-off issues falling off the board
Two gaps, both found the same way -- by something going wrong quietly.
`gh issue create` does not touch the project board. The issue is created, carries its
labels, and is invisible in the Kanban, which looks exactly like a ticket nobody filed.
On 2026-08-24 eight issues filed as a scripted batch all reached the board and one filed
as a one-off minutes later did not; it surfaced only because someone went looking for it.
A batch carries the board step inside its loop. One-offs are where it slips, so
tools/github/file-issue.sh is for one-offs.
Three things it does that a two-command shell snippet would not:
- Resolves the project, Status field and option ids BY NAME, every run. Caching them
is the obvious optimisation and the wrong one -- a renamed or reordered column would
then have this writing a stale id into the board with no error anywhere.
- Reads the item back. A mutation returning 200 says the request was accepted, not that
the board shows what was asked for; the read-back is the only step that checks the
claim this script exists to make. It is a GraphQL query because REST cannot do it --
the `fields` array REST returns on a project item carries Title and nothing else, so
a REST-only check reports every item's Status as unset.
- Exits 3, loudly, with the issue number on a line of its own, when the issue was
created but the board step failed. That exact combination is the failure being
prevented; it must never be the quiet path.
Shell was the other language here with nothing checking it -- four scripts, one of them
the CI entry point. shellcheck now runs in the Static analysis job over
`git ls-files '*.sh'`, so a script added later is covered without editing the workflow,
and it runs at full severity with `info` included.
That raises two findings today and both are the tool being wrong, so both are answered
with a targeted `disable` carrying its reason rather than by lowering the severity:
run-e2e.sh's `on_signal` is reported as never invoked when it is installed as the INT and
TERM trap eleven lines below it, and the `$names` inside file-issue.sh's queries are
GraphQL variables that must not expand -- expanding them would send the shell's idea of
$owner to the API instead of declaring a parameter. A blanket --severity=warning would
have hidden both, and the next real finding with them.
The gradle step gains `if: !cancelled()` so a shellcheck failure cannot cost the
ktlint/detekt/lint lists -- the same reason that step already passes --continue.
Not covered, deliberately: shellcheck here reads .sh files, not the inline `run:` blocks
in the workflows, where a good deal of this repo's bash actually lives. actionlint does
read them, and finds one pre-existing info-level issue in build.yml. Wiring it in means
pinning a container digest, because every action here is pinned by SHA and actionlint's
usual installer is a curl-pipe-bash off a moving branch. Its own ticket, not this commit.
This commit is contained in:
Executable
+253
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Files a GitHub issue AND puts it on the project board, as one operation.
|
||||
#
|
||||
# Usage: tools/github/file-issue.sh --title TITLE (--body TEXT | --body-file PATH) [options]
|
||||
#
|
||||
# --status NAME board column, matched case-insensitively against the board's own
|
||||
# options; a miss lists what is available. Default: Backlog
|
||||
# --label NAME repeatable. Passed through to `gh issue create` unchanged.
|
||||
# --project N project number. Default: $ISSUE_PROJECT_NUMBER, else 6
|
||||
# --repo OWNER/NAME default: whatever `gh repo view` resolves in the working directory
|
||||
# --dry-run resolve and validate everything, create nothing
|
||||
#
|
||||
# EXIT CODE: 0 only when the issue exists, is on the board, AND reads back carrying the
|
||||
# Status that was asked for. 2 for a usage or validation error, before anything is created.
|
||||
# **3 means the issue was created but did not reach the board** -- the number is printed on
|
||||
# a line of its own, because that combination is the entire failure this script exists to
|
||||
# prevent and it must never be quiet.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
#
|
||||
# `gh issue create` does not touch the project board. The issue is created, carries its
|
||||
# labels, and is invisible in the Kanban -- which looks exactly like a ticket nobody filed.
|
||||
# Measured 2026-08-24: eight issues filed as a scripted batch all reached the board; one
|
||||
# filed as a one-off a few minutes later did not, and was caught only because someone went
|
||||
# looking. A batch carries the board step inside its loop. One-offs are where it slips, so
|
||||
# one-offs are what this is for.
|
||||
#
|
||||
# Adding an item and setting a field value are GraphQL-only. REST can list project items
|
||||
# and field definitions, but the `fields` array it returns on an item carries Title and
|
||||
# nothing else -- a REST-only check reports every item's Status as unset, which is why the
|
||||
# read-back at the end is a GraphQL query rather than the cheaper REST one.
|
||||
#
|
||||
# WHAT IT DELIBERATELY DOES NOT DO
|
||||
#
|
||||
# It does not cache the project, field or option ids. Resolving them by name costs one
|
||||
# GraphQL query per run, and it means a renamed or reordered column cannot make this write
|
||||
# a stale id. The ids are the fragile part; the names are what people actually use.
|
||||
#
|
||||
# It does not apply triage labels for you. `above-cut` and `backlog` are labels from one
|
||||
# specific 2026-08-22 triage pass -- they mean "worked autonomously overnight" and "held for
|
||||
# manual review", not "this is in the Backlog column". Status carries board state. Pass
|
||||
# --label only for things that are true about the issue itself.
|
||||
#
|
||||
# It does not create the project, the Status field, or a missing option. Anything absent is
|
||||
# an error to report, not to invent.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
readonly EXIT_USAGE=2
|
||||
readonly EXIT_ORPHANED=3
|
||||
|
||||
die() {
|
||||
printf 'file-issue: %s\n' "$1" >&2
|
||||
exit "${2:-$EXIT_USAGE}"
|
||||
}
|
||||
|
||||
title=""
|
||||
body=""
|
||||
body_file=""
|
||||
status="Backlog"
|
||||
project="${ISSUE_PROJECT_NUMBER:-6}"
|
||||
repo=""
|
||||
dry_run=0
|
||||
labels=()
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--title) [ $# -ge 2 ] || die "--title needs a value"; title="$2"; shift 2 ;;
|
||||
--body) [ $# -ge 2 ] || die "--body needs a value"; body="$2"; shift 2 ;;
|
||||
--body-file) [ $# -ge 2 ] || die "--body-file needs a path"; body_file="$2"; shift 2 ;;
|
||||
--status) [ $# -ge 2 ] || die "--status needs a value"; status="$2"; shift 2 ;;
|
||||
--label) [ $# -ge 2 ] || die "--label needs a value"; labels+=("$2"); shift 2 ;;
|
||||
--project) [ $# -ge 2 ] || die "--project needs a number"; project="$2"; shift 2 ;;
|
||||
--repo) [ $# -ge 2 ] || die "--repo needs OWNER/NAME"; repo="$2"; shift 2 ;;
|
||||
--dry-run) dry_run=1; shift ;;
|
||||
-h|--help) awk 'NR > 1 && /^#/ { sub(/^# ?/, ""); print; next } NR > 1 { exit }' "$0"
|
||||
exit 0 ;;
|
||||
*) die "unknown argument: $1" ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[ -n "$title" ] || die "--title is required"
|
||||
if [ -n "$body" ] && [ -n "$body_file" ]; then
|
||||
die "pass --body or --body-file, not both"
|
||||
fi
|
||||
[ -n "$body" ] || [ -n "$body_file" ] || die "one of --body or --body-file is required"
|
||||
if [ -n "$body_file" ] && [ ! -r "$body_file" ]; then
|
||||
die "--body-file is not readable: $body_file"
|
||||
fi
|
||||
case "$project" in
|
||||
''|*[!0-9]*) die "--project must be a number, got: $project" ;;
|
||||
esac
|
||||
|
||||
command -v gh >/dev/null 2>&1 || die "gh is not on PATH"
|
||||
|
||||
if [ -z "$repo" ]; then
|
||||
repo=$(gh repo view --json nameWithOwner --jq '.nameWithOwner') \
|
||||
|| die "could not resolve the repository; pass --repo OWNER/NAME"
|
||||
fi
|
||||
owner="${repo%%/*}"
|
||||
[ -n "$owner" ] || die "could not read an owner out of: $repo"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Resolve the board by NAME. Every id below is read fresh; none is hardcoded.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The $names in the query are GraphQL variables, declared by the query and bound by the
|
||||
# -f flags. Expanding them in the shell would send this shell's idea of $owner to the
|
||||
# API instead of declaring a parameter -- which is why every query here is single-quoted.
|
||||
# shellcheck disable=SC2016
|
||||
board=$(gh api graphql \
|
||||
-f query='
|
||||
query($owner: String!, $number: Int!) {
|
||||
user(login: $owner) {
|
||||
projectV2(number: $number) {
|
||||
id
|
||||
title
|
||||
field(name: "Status") {
|
||||
... on ProjectV2SingleSelectField { id options { id name } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}' \
|
||||
-f owner="$owner" -F number="$project" 2>&1) \
|
||||
|| die "could not read project $project for $owner. A 403 naming scopes means gh is
|
||||
missing 'project'; a 403 naming a rate limit is the GraphQL budget, not permissions. The
|
||||
API said: $board"
|
||||
|
||||
project_id=$(printf '%s' "$board" | jq -r '.data.user.projectV2.id // empty')
|
||||
field_id=$(printf '%s' "$board" | jq -r '.data.user.projectV2.field.id // empty')
|
||||
project_title=$(printf '%s' "$board" | jq -r '.data.user.projectV2.title // empty')
|
||||
|
||||
[ -n "$project_id" ] || die "no project number $project under user $owner"
|
||||
[ -n "$field_id" ] || die "project $project has no single-select field named 'Status'"
|
||||
|
||||
# Case-insensitive match, so "backlog" and "Backlog" both work. The canonical name is
|
||||
# what gets reported back, so a sloppy argument still produces an exact log line.
|
||||
option=$(printf '%s' "$board" | jq -r --arg want "$status" '
|
||||
.data.user.projectV2.field.options[]
|
||||
| select((.name | ascii_downcase) == ($want | ascii_downcase))
|
||||
| "\(.id)\t\(.name)"' | head -n 1)
|
||||
|
||||
if [ -z "$option" ]; then
|
||||
printf 'file-issue: no Status option named %s. Available:\n' "$status" >&2
|
||||
printf '%s' "$board" | jq -r '.data.user.projectV2.field.options[] | " " + .name' >&2
|
||||
exit "$EXIT_USAGE"
|
||||
fi
|
||||
option_id="${option%%$'\t'*}"
|
||||
status_canonical="${option#*$'\t'}"
|
||||
|
||||
printf 'repo %s\n' "$repo"
|
||||
printf 'board %s (project %s)\n' "$project_title" "$project"
|
||||
printf 'status %s\n' "$status_canonical"
|
||||
printf 'labels %s\n' "${labels[*]:-(none)}"
|
||||
printf 'title %s\n' "$title"
|
||||
|
||||
if [ "$dry_run" -eq 1 ]; then
|
||||
printf '\ndry run: everything above resolved; nothing was created.\n'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Create. Past this line a failure can leave an issue off the board, so every
|
||||
# error path prints the number.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
create_args=(--repo "$repo" --title "$title")
|
||||
if [ -n "$body_file" ]; then
|
||||
create_args+=(--body-file "$body_file")
|
||||
else
|
||||
create_args+=(--body "$body")
|
||||
fi
|
||||
for label in ${labels[@]+"${labels[@]}"}; do
|
||||
create_args+=(--label "$label")
|
||||
done
|
||||
|
||||
issue_url=$(gh issue create "${create_args[@]}") || die "gh issue create failed; nothing was filed"
|
||||
issue_number="${issue_url##*/}"
|
||||
case "$issue_number" in
|
||||
''|*[!0-9]*) die "could not read an issue number out of: $issue_url" ;;
|
||||
esac
|
||||
|
||||
orphaned() {
|
||||
printf 'file-issue: %s\n' "$1" >&2
|
||||
printf 'file-issue: THE ISSUE EXISTS BUT IS NOT ON THE BOARD. Fix it by hand:\n' >&2
|
||||
printf '%s\n' "$issue_url" >&2
|
||||
exit "$EXIT_ORPHANED"
|
||||
}
|
||||
|
||||
content_id=$(gh api "/repos/$repo/issues/$issue_number" --jq '.node_id') \
|
||||
|| orphaned "could not read the node id for #$issue_number"
|
||||
|
||||
# shellcheck disable=SC2016 # GraphQL variables, as above
|
||||
item_id=$(gh api graphql \
|
||||
-f query='
|
||||
mutation($project: ID!, $content: ID!) {
|
||||
addProjectV2ItemById(input: {projectId: $project, contentId: $content}) {
|
||||
item { id }
|
||||
}
|
||||
}' \
|
||||
-f project="$project_id" -f content="$content_id" \
|
||||
--jq '.data.addProjectV2ItemById.item.id') \
|
||||
|| orphaned "could not add #$issue_number to the board"
|
||||
[ -n "$item_id" ] || orphaned "the board add returned no item id for #$issue_number"
|
||||
|
||||
# shellcheck disable=SC2016 # GraphQL variables, as above
|
||||
gh api graphql \
|
||||
-f query='
|
||||
mutation($project: ID!, $item: ID!, $field: ID!, $option: String!) {
|
||||
updateProjectV2ItemFieldValue(input: {
|
||||
projectId: $project, itemId: $item, fieldId: $field,
|
||||
value: {singleSelectOptionId: $option}
|
||||
}) { projectV2Item { id } }
|
||||
}' \
|
||||
-f project="$project_id" -f item="$item_id" -f field="$field_id" -f option="$option_id" \
|
||||
>/dev/null \
|
||||
|| orphaned "#$issue_number is on the board but its Status could not be set"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Read back. A mutation returning 200 is not evidence the board shows what was
|
||||
# asked for -- this is the only check that is.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# shellcheck disable=SC2016 # GraphQL variables, as above
|
||||
readback=$(gh api graphql \
|
||||
-f query='
|
||||
query($item: ID!) {
|
||||
node(id: $item) {
|
||||
... on ProjectV2Item {
|
||||
content { ... on Issue { number } }
|
||||
fieldValueByName(name: "Status") {
|
||||
... on ProjectV2ItemFieldSingleSelectValue { name }
|
||||
}
|
||||
}
|
||||
}
|
||||
}' \
|
||||
-f item="$item_id") \
|
||||
|| orphaned "#$issue_number was written but could not be read back"
|
||||
|
||||
seen_number=$(printf '%s' "$readback" | jq -r '.data.node.content.number // empty')
|
||||
seen_status=$(printf '%s' "$readback" | jq -r '.data.node.fieldValueByName.name // empty')
|
||||
|
||||
if [ "$seen_number" != "$issue_number" ]; then
|
||||
orphaned "read-back names issue #${seen_number:-<none>}, expected #$issue_number"
|
||||
fi
|
||||
if [ "$seen_status" != "$status_canonical" ]; then
|
||||
orphaned "read-back Status is ${seen_status:-<unset>}, expected $status_canonical"
|
||||
fi
|
||||
|
||||
printf '\n#%s on %s as %s -- verified by read-back\n' \
|
||||
"$issue_number" "$project_title" "$seen_status"
|
||||
printf '%s\n' "$issue_url"
|
||||
@@ -456,6 +456,7 @@ cleanup() {
|
||||
# bash runs a trap only between commands, so this starts when whatever was in the foreground
|
||||
# returns -- which for Ctrl-C is immediately, because the same interrupt reached that command
|
||||
# too. `kill -INT` aimed at this script alone waits for the foreground command to finish.
|
||||
# shellcheck disable=SC2329 # invoked indirectly -- installed as the INT and TERM trap a few lines below.
|
||||
on_signal() {
|
||||
echo
|
||||
echo "interrupted (SIG$1) -- stopping the emulator and removing the AVDs this run created"
|
||||
|
||||
Reference in New Issue
Block a user