From b924d1e9cba529432159eee8bd31251caa131308 Mon Sep 17 00:00:00 2001 From: Fabian Hamm Date: Thu, 10 Sep 2026 19:17:41 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20propose-work=20=E2=80=94=20push,=20open?= =?UTF-8?q?=20the=20PR,=20and=20move=20the=20issue,=20in=20one=20command?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GITEA-SETUP.md has listed this under "Still to build" since 2026-09-04: `propose-work`, superseding `push-work` -- push the branch AND open the PR with `Closes #N` AND set the label, in one step. Today `push-work` does the first third; the other two thirds being manual is how they get skipped. PROTOCOL.md 145-149 requires all three: * 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 Until now those were three things to remember, and PR #20 had to add a warning to `port-loop.md` about the two that get forgotten. A rule enforced by memory decays; this makes the sequence structural. WHAT IT DOES NOT DO is 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 copies drift, and the copy that drifts is the one that matters. Three design choices worth stating: * THE ISSUE NUMBER IS DERIVED FROM THE BRANCH NAME, which PROTOCOL already specifies as `auto//-`. So a PR cannot cite a different issue than the branch was cut for -- a mismatch no reviewer would catch. `-i` overrides. * `-m` IS MANDATORY. PROTOCOL says 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". Refusing without that line is cheaper than letting the label carry an empty promise and costing a human a round trip. * IT MOVES THE LABEL RATHER THAN ADDING IT -- other `state/*` labels are removed. Leaving `state/in-progress` attached makes the board lie about what is waiting on a person. The token is read from a file and handed to curl through a `--config` document on stdin: never an argument, never exported. Arguments are world-readable in /proc and this token can push. Verified that the mechanism actually delivers the header rather than silently dropping it -- with a bogus token the API answers "invalid username, password or token", while the same URL with no header returns the list anonymously, so the header is demonstrably being read. Also verified: issue derived from the branch (#42 from `auto/decoder/42-widget-census`), refusal without `-m`, refusal when the branch carries no number, `--dry-run` sends nothing, and repo/API derivation from the remote. `--dry-run` deliberately does NOT require a credential -- it exists so an agent can check the command it is about to run, and demanding a token it never sends would make the check unavailable exactly where it is cheapest. Identical in both agents' bin/ on purpose: the agent name comes from the branch, so there is nothing per-agent to diverge. UNVERIFIED, and stated as such: no end-to-end run. That needs a real token and a real issue, which this desktop does not have -- it is the second machine, and the agent credentials live on the agent box. Everything above the network call is exercised; the POSTs are not. Refs #11 Co-Authored-By: Claude Opus 5 --- docker/decoder/bin/propose-work | 162 ++++++++++++++++++++++++++++++++ docker/port/bin/propose-work | 162 ++++++++++++++++++++++++++++++++ 2 files changed, 324 insertions(+) create mode 100755 docker/decoder/bin/propose-work create mode 100755 docker/port/bin/propose-work diff --git a/docker/decoder/bin/propose-work b/docker/decoder/bin/propose-work new file mode 100755 index 00000000..7ded18f2 --- /dev/null +++ b/docker/decoder/bin/propose-work @@ -0,0 +1,162 @@ +#!/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" diff --git a/docker/port/bin/propose-work b/docker/port/bin/propose-work new file mode 100755 index 00000000..7ded18f2 --- /dev/null +++ b/docker/port/bin/propose-work @@ -0,0 +1,162 @@ +#!/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"