Merge pull request #69 from JMR-dev/tools/file-issue-script
Check the shell, and stop one-off issues falling off the board
This commit was merged in pull request #69.
This commit is contained in:
@@ -156,7 +156,37 @@ jobs:
|
||||
key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle.kts', 'gradle/libs.versions.toml', 'gradle/wrapper/gradle-wrapper.properties') }}
|
||||
restore-keys: gradle-${{ runner.os }}-
|
||||
|
||||
# Shell is the other language in this repo -- four scripts, one of them the CI
|
||||
# entry point itself -- and nothing was checking it. `git ls-files` rather than a
|
||||
# fixed list, so a script added later is covered without editing this workflow.
|
||||
#
|
||||
# Full severity, `info` included. The findings it raises today are answered with
|
||||
# targeted `disable` directives carrying their reason, the same way
|
||||
# config/detekt/detekt.yml carries only the rules this codebase legitimately
|
||||
# breaks. A blanket --severity=warning would have hidden them and the next real
|
||||
# one alike.
|
||||
#
|
||||
# PINNED BY DIGEST, for the reason CLAUDE.md already gives for pinning ktlint,
|
||||
# detekt and JaCoCo: a new rule in a linter makes files nobody touched stop
|
||||
# passing, so CI goes red on a PR whose diff cannot explain it. That is not
|
||||
# hypothetical here. The first cut of this step used the runner's ambient
|
||||
# shellcheck, which is 0.9.0, and 0.9.0 reports a trap handler as seven
|
||||
# unreachable commands (SC2317) where 0.11.0 reports it once on the declaration
|
||||
# (SC2329) -- same script, same directive, different answer, and a red build on
|
||||
# the PR that introduced the step. The version is printed so a finding that
|
||||
# appears out of nowhere can be tied to a bump of this line.
|
||||
- name: shellcheck
|
||||
env:
|
||||
SHELLCHECK: koalaman/shellcheck@sha256:61862eba1fcf09a484ebcc6feea46f1782532571a34ed51fedf90dd25f925a8d
|
||||
run: |
|
||||
docker run --rm "$SHELLCHECK" --version
|
||||
git ls-files -z '*.sh' | xargs -0 -r docker run --rm -v "$PWD:/mnt" "$SHELLCHECK"
|
||||
|
||||
# `!cancelled()` rather than a plain sequence: a shellcheck failure above must not
|
||||
# cost the ktlint/detekt/lint lists. Same reason this step passes --continue -- one
|
||||
# round trip should produce every list, not stop at the first.
|
||||
- name: ktlint, detekt and Android lint
|
||||
if: '!cancelled()'
|
||||
run: ./gradlew :app:ktlintCheck :app:detekt :app:lintDebug --continue --stacktrace
|
||||
|
||||
# The XML matters as much as the HTML: it is the one that can be diffed between
|
||||
|
||||
@@ -123,6 +123,42 @@ install for code that can never run — and on API 37 the full APK does not fit
|
||||
|
||||
- `kotlin.code.style=official`. Gradle stays Kotlin DSL.
|
||||
|
||||
- **File one-off issues with `tools/github/file-issue.sh`, not `gh issue create`.** `gh issue
|
||||
create` does not touch the project board, so the issue exists, carries its labels, and is
|
||||
invisible in the Kanban — indistinguishable from never having been filed. Measured 2026-08-24:
|
||||
eight issues filed as a scripted batch all reached the board; one filed as a one-off minutes
|
||||
later did not. A batch carries the board step in its loop; **one-offs are where it slips**, which
|
||||
is what the script is for. It resolves the project and Status ids by name rather than caching
|
||||
them, and it **reads the item back** — a mutation returning 200 is not evidence the board shows
|
||||
what was asked for. Exit 3 means the issue was created but did not reach the board, and prints
|
||||
the number so it cannot be lost quietly.
|
||||
|
||||
`above-cut` and `backlog` are **labels from the 2026-08-22 triage pass** — "worked autonomously
|
||||
overnight" and "held for manual review". They are not board columns. Status carries board state;
|
||||
do not put a cut label on a newly filed ticket.
|
||||
|
||||
- **shellcheck runs in CI**, inside the Static analysis job, over `git ls-files '*.sh'` so a new
|
||||
script is covered without editing the workflow. It runs at full severity, `info` included: the
|
||||
two findings that raises today are answered with targeted `disable` directives carrying their
|
||||
reason, exactly as `config/detekt/detekt.yml` carries only the rules this codebase legitimately
|
||||
breaks. Do not silence it with `--severity=warning` — that hides the next real finding too.
|
||||
**It is pinned by image digest, and joins ktlint/detekt/JaCoCo in the "Dependency versions"
|
||||
rule above** — for exactly the reason stated there, demonstrated the day it was added. The first
|
||||
cut used the runner's ambient shellcheck. That is **0.9.0**, while the container used to check
|
||||
locally was 0.11.0, and the two disagree about how to report a trap handler: 0.11.0 says
|
||||
`SC2329` once on the declaration, 0.9.0 says `SC2317` on each of seven lines in the body. Same
|
||||
script, same directive, one green and one red. Directives that must survive both name both codes.
|
||||
|
||||
Locally, use the same pin rather than whatever is installed:
|
||||
`podman run --rm -v "$PWD:/mnt:z" docker.io/koalaman/shellcheck@sha256:61862eba... <files>`
|
||||
(the digest is in `status_check.yml`; there is no shellcheck system package on this host).
|
||||
|
||||
**It does not cover inline `run:` blocks in the workflows**, and a good deal of this repo's bash
|
||||
lives there. `actionlint` does cover them — it runs shellcheck over each `run:` — and reports one
|
||||
pre-existing `info` finding in `build.yml`. It is not wired in because every action here is
|
||||
pinned by SHA, and actionlint's usual installer is a `curl | bash` off a moving branch; doing it
|
||||
properly means pinning a container digest. Tracked separately rather than bolted on.
|
||||
|
||||
## Dependency versions
|
||||
|
||||
Libraries **float on minor + patch** (`coreKtx = "1.+"`). Three groups deliberately do not:
|
||||
|
||||
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,10 @@ 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.
|
||||
# Invoked indirectly -- installed as the INT and TERM trap a few lines below. Both codes,
|
||||
# because shellcheck 0.9.0 reports this as unreachable commands (SC2317) and 0.11.0 as an
|
||||
# uninvoked function (SC2329); CI pins 0.11.0 but a local install may be either.
|
||||
# shellcheck disable=SC2317,SC2329
|
||||
on_signal() {
|
||||
echo
|
||||
echo "interrupted (SIG$1) -- stopping the emulator and removing the AVDs this run created"
|
||||
|
||||
Reference in New Issue
Block a user