#!/usr/bin/env bash # Push the branch, open the pull request, and move the issue to # `state/needs-human` — the three steps PROTOCOL.md requires, as one command. # # Why this exists: # # `push-work` does the first third. GITEA-SETUP.md's own words are "the other # two thirds being manual is how they get skipped", and both loop briefs had # to carry a warning about it. A rule that depends on remembering three steps # is a rule that decays; this makes the sequence structural instead. # # It does NOT reimplement push-work's refusals — it CALLS push-work, so `main`, # shared branches and force-push stay refused in exactly one place. Duplicating # them would let the two copies drift, and the copy that drifts is the one that # matters. # # PROTOCOL.md §Pull requests: # * branch `auto//-`, one item per branch # * open the PR with `Closes #` in the body # * label the issue `state/needs-human` and say, in one line, what to look at # # propose-work -m "what to look at" issue number from the branch # propose-work -i 12 -m "..." -t "title" explicit # propose-work -m "..." --dry-run print every call, make none # # The token is read from a file and passed to curl through a --config document # on stdin. It is never an argument, never exported, never logged: arguments are # world-readable in /proc, and this token can push. set -euo pipefail DRY=0; ISSUE=""; TITLE=""; LOOK="" while [ $# -gt 0 ]; do case "$1" in -i|--issue) ISSUE="${2:-}"; shift 2 ;; -t|--title) TITLE="${2:-}"; shift 2 ;; -m|--look) LOOK="${2:-}"; shift 2 ;; --dry-run) DRY=1; shift ;; -h|--help) sed -n '2,28p' "$0"; exit 0 ;; *) echo "propose-work: unknown argument '$1'" >&2; exit 1 ;; esac done here=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) repo_root=$(git rev-parse --show-toplevel) || exit 1 cd "$repo_root" branch=$(git rev-parse --abbrev-ref HEAD) # ── the issue number ──────────────────────────────────────────────────────── # PROTOCOL names the branch `auto//-`, so the number is # already there. Deriving it means the PR cannot cite a different issue than the # branch was cut for -- a mismatch nobody would notice in review. if [ -z "$ISSUE" ]; then ISSUE=$(printf '%s\n' "$branch" | sed -n 's|^auto/[^/]*/\([0-9]\{1,\}\)-.*$|\1|p') fi if [ -z "$ISSUE" ]; then echo "propose-work: no issue number." >&2 echo " Either name the branch auto//-, or pass -i ." >&2 exit 1 fi # ── the "what to look at" line is NOT optional ────────────────────────────── # PROTOCOL: an issue in `state/needs-human` "must say what to look at and what # pass and fail look like, so a person can judge it in under a minute". An item # that arrives without that sentence costs a human a round trip, so refuse here # rather than let the label carry an empty promise. if [ -z "$LOOK" ]; then echo "propose-work: -m is required." >&2 echo " state/needs-human means a person will look. Tell them what at, and" >&2 echo " what pass and fail look like, in one line." >&2 exit 1 fi # The FIRST commit on the branch, not the last: PROTOCOL is one item per # branch, so the opening commit names the unit while HEAD may well be "fix # typo". Falls back to HEAD when the branch has no unique commits. [ -n "$TITLE" ] || TITLE=$(git log --format=%s --reverse "origin/main..HEAD" 2>/dev/null | head -1) [ -n "$TITLE" ] || TITLE=$(git log --format=%s -1) # ── credentials ───────────────────────────────────────────────────────────── TOKFILE="${GITEA_TOKEN_FILE:-$HOME/.sylph-gitea-token}" # Only when it will actually be used. `--dry-run` exists so an agent can check # the command it is about to run; demanding a credential it never sends would # make the check unavailable exactly where it is cheapest. if [ "$DRY" = 0 ] && [ ! -s "$TOKFILE" ]; then echo "propose-work: no Gitea token at $TOKFILE" >&2 echo " The host must start the container with SYLPH_GITEA_TOKEN set." >&2 exit 1 fi remote=$(git remote get-url origin) slug=$(printf '%s\n' "$remote" | sed -E 's|^.*://[^/]*/||; s|\.git$||') API="https://$(printf '%s\n' "$remote" | sed -E 's|^.*://([^/@]*@)?([^/]*)/.*$|\2|')/api/v1" # curl with the credential supplied out-of-band. `--config -` reads a document # from stdin; the token never reaches argv or the environment. api() { # api [JSON] local method="$1" path="$2" data="${3:-}" { printf 'header = "Authorization: token %s"\n' "$(cat "$TOKFILE")" printf 'header = "Content-Type: application/json"\n' printf 'silent\nshow-error\nfail-with-body\nrequest = "%s"\n' "$method" [ -n "$data" ] && printf 'data = %s\n' "$(printf '%s' "$data" | python3 -c 'import json,sys; print(json.dumps(sys.stdin.read()))')" printf 'url = "%s%s"\n' "$API" "$path" } | curl --config - } echo "propose-work: branch=$branch issue=#$ISSUE repo=$slug" if [ "$DRY" = 1 ]; then echo " would: push-work" echo " would: POST /repos/$slug/pulls head=$branch base=main" echo " title: $TITLE" echo " body: Closes #$ISSUE + $LOOK" echo " would: PATCH labels on #$ISSUE -> state/needs-human (dropping other state/*)" echo " would: POST /repos/$slug/issues/$ISSUE/comments (the look-at line)" echo "propose-work: --dry-run, nothing sent" exit 0 fi # ── 1. push (refusals live in push-work, not here) ────────────────────────── "$here/push-work" # ── 2. the pull request ───────────────────────────────────────────────────── body=$(printf '%s\n\nCloses #%s\n\n**What to look at:** %s\n' "$TITLE" "$ISSUE" "$LOOK") payload=$(python3 - "$TITLE" "$branch" "$body" <<'PY' import json,sys print(json.dumps({"title":sys.argv[1],"head":sys.argv[2],"base":"main","body":sys.argv[3]})) PY ) if out=$(api POST "/repos/$slug/pulls" "$payload" 2>&1); then num=$(printf '%s' "$out" | python3 -c 'import json,sys; print(json.load(sys.stdin)["number"])' 2>/dev/null || echo "?") echo "propose-work: opened PR #$num" else # A second run after a fixup should not fail; the branch already has a PR. case "$out" in *"already exists"*) echo "propose-work: a pull request for $branch already exists — continuing" ;; *) echo "propose-work: opening the PR failed:" >&2; echo "$out" >&2; exit 1 ;; esac fi # ── 3. move the issue to state/needs-human ────────────────────────────────── # "Move", not "add": leaving state/in-progress on it makes the board lie about # what is waiting on a person. labels=$(api GET "/repos/$slug/labels?limit=100") want=$(printf '%s' "$labels" | python3 -c 'import json,sys; print([l["id"] for l in json.load(sys.stdin) if l["name"]=="state/needs-human"][0])') cur=$(api GET "/repos/$slug/issues/$ISSUE/labels") drop=$(printf '%s' "$cur" | python3 -c ' import json,sys print(" ".join(str(l["id"]) for l in json.load(sys.stdin) if l["name"].startswith("state/") and l["name"]!="state/needs-human"))') for id in $drop; do api DELETE "/repos/$slug/issues/$ISSUE/labels/$id" >/dev/null; done api POST "/repos/$slug/issues/$ISSUE/labels" "{\"labels\":[$want]}" >/dev/null echo "propose-work: #$ISSUE -> state/needs-human" # ── 4. say what to look at, on the issue itself ───────────────────────────── # The PR body has it too, but a person triaging the board reads issues. cbody=$(python3 - "$LOOK" "$branch" <<'PY' import json,sys print(json.dumps({"body":"**Ready for a look.** %s\n\nBranch `%s`." % (sys.argv[1], sys.argv[2])})) PY ) api POST "/repos/$slug/issues/$ISSUE/comments" "$cbody" >/dev/null echo "propose-work: done"