Files
LibreMediaConverter/tools/github/file-issue.sh
JMR-dev baaaa934e0 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.
2026-08-24 16:03:14 -05:00

254 lines
10 KiB
Bash
Executable File

#!/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"