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.
254 lines
10 KiB
Bash
Executable File
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"
|