Merge pull request 'feat: propose-work — push, open the PR, and move the issue, in one command' (#21) from feat/propose-work into main
Reviewed-on: #21
This commit was merged in pull request #21.
This commit is contained in:
162
docker/decoder/bin/propose-work
Executable file
162
docker/decoder/bin/propose-work
Executable file
@@ -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/<agent>/<issue#>-<topic>`, one item per branch
|
||||
# * open the PR with `Closes #<issue>` 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/<agent>/<issue#>-<topic>`, 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/<agent>/<issue#>-<topic>, or pass -i <n>." >&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 <METHOD> <PATH> [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"
|
||||
162
docker/port/bin/propose-work
Executable file
162
docker/port/bin/propose-work
Executable file
@@ -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/<agent>/<issue#>-<topic>`, one item per branch
|
||||
# * open the PR with `Closes #<issue>` 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/<agent>/<issue#>-<topic>`, 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/<agent>/<issue#>-<topic>, or pass -i <n>." >&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 <METHOD> <PATH> [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"
|
||||
@@ -363,10 +363,12 @@ docker/port/sylph-port
|
||||
|
||||
Not blockers for Phase 7, but the workflow is not finished without them:
|
||||
|
||||
* **`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. Its
|
||||
existing refusals stay: no `main`, no force-push.
|
||||
* ~~**`propose-work`**~~ — done, as `docker/{decoder,port}/bin/propose-work`:
|
||||
pushes through `push-work` (so its refusals stay in one place — no `main`, no
|
||||
force-push), opens the PR with `Closes #N` taken from the branch name, and moves
|
||||
the issue to `state/needs-human`. What is still missing is an *end-to-end* run:
|
||||
everything before the network calls is exercised, the `POST`s are not, because
|
||||
only the agent box holds a token that can make them. Its first real use is its test.
|
||||
* **an attachment uploader** — the MCP exposes `attachment_read` only, so putting
|
||||
a screenshot on an issue needs a direct `POST /repos/{owner}/{repo}/issues/{index}/assets`.
|
||||
* ~~**`gitea-verify`**~~ — done, as `tools/gitea-protect --verify`: asserts every
|
||||
|
||||
Reference in New Issue
Block a user