Files
command-center/agent/managed-skills/gitea-pr-api-cycle/SKILL.md
T
Frank 8af40c402b chore(agent): commit the machine tier and the doctrine text the checkout was already running
Landing blocker for #61 and #63: the deployment checkout is 12 commits behind and
`merge --ff-only` refuses while tracked paths are dirty. Discard is not available here
(workspace policy: no `checkout`/`stash` on shared state), so the dirt is committed.

- `agent/APPEND_SYSTEM.md`: the point-of-use / one-work-log / PR-link doctrine, already
  byte-identical to `origin/main` (`cmp` against the trunk blob: no difference). No new
  prose — the commit exists so the trunk merge can proceed.
- `agent/config.yml` and `agent/managed-skills/**`: the machine-live tier the heartbeat
  owns (AGENTS.md § Live-sync tiers).

`ALLOW_SECRETS=1` was used, deliberately and after auditing the staged diff by hand: the
only credential-shaped strings in it are two copies of `AKIA0000000000000EXAMPLE` — the
inert all-zero AWS example id — and they appear inside the two skill pages that teach
that exact placeholder shape. This checkout is still running the pre-#61 hook; the merged
gate (value-scoped placeholder filter, `71601f7`) exempts this value, so the refusal
dies with this landing. No `--no-verify` was used: the gate ran and its verdict is
recorded here.

Refs #61
2026-10-21 01:44:51 -07:00

12 KiB
Raw Blame History

name, description
name description
gitea-pr-api-cycle "Open/update/comment/merge PRs and issues on the Dankclaw Gitea API (192.168.0.143:3000) with the token-safe sequence (POST /pulls rejects Basic with 'token is required'), numeric label ids, the form-encoded merge field, the async 405 'try again later' merge trap, the empty-token-returns-404 trap, and post-merge live-checkout fast-forward plus the plugin-package-name upgrade gotcha."

Gitea PR/issue cycle — Dankclaw API

Friction-earned 2026-10-18 (PRs #15–#20, issue #18), re-earned 2026-10-19 on dank/dankvoice PRs #4–#6 (the merge payload claim was wrong; an empty token masqueraded as a missing repo), and re-earned 2026-10-20 on dank/command-center PRs #55…#57 (the 405 recalculation trap). Re-earned again 2026-10-20 on dank/dankvoice #25 (merge gate loop worked; suite verdict delivered as an issue comment). Re-earned 2026-10-21 on dank/command-center issue #58 / PR #59 (issue labels are numeric ids; the <type>/<issue#>-<slug> branch name has to be right before the first push). Same day on dank/serverAppConfigs #64: the Basic-vs-token split below cost two wasted POSTs.

Where

  • API base: http://192.168.0.143:3000/api/v1/repos/dank/<repo>/{pulls,issues} — repo varies (command-center, dankvoice, serverAppConfigs); numbering is per-repo, so a footer citing #36 means nothing without the repo.
  • Web UI pages under /dank/<repo>/pulls/N are session-auth private: a plain URL fetch answers 404 even when the API and xdg-open work. Merge state questions go through the API (GET /pulls/N → state, merged, merge_commit_sha) — never conclude from a failed page fetch.
  • Basic vs token split: the username=/password= fields of ~/.config/secrets/gitea as Authorization: Basic … work for reads and issue/PR comment writes, but POST /pulls refuses them with 403 token is required. Pull-request lifecycle writes need the token header. Use the token for everything PR-shaped; never conclude "API is broken" from a Basic 403 on /pulls.
  • Token: never inline, never in argv (-H "Authorization: token …" lands in /proc/<pid>/cmdline, world-readable). Extract into a 600-perm curl config and hand it to curl with -K:
    tok=$(sed -nE 's#^https?://[^:/@]+:([^@]+)@192\.168\.0\.143.*#\1#p' ~/.config/secrets/gitea | head -1)
    echo "tok len ${#tok}"   # 40 — assert before use
    cfg=$(mktemp); chmod 600 "$cfg"; printf 'header = "Authorization: token %s"\n' "$tok" > "$cfg"
    curl -sS -K "$cfg" http://192.168.0.143:3000/api/v1/repos/dank/<repo>/labels
    rm -f "$cfg"
    
  • Empty token answers 404 {"message":"not found"}, NOT 401 — Gitea hides the private repo. A 404 on a path you have used before means auth, not a typo'd repo. The trap that produces the empty token: awk -F'/' … '{print $NF}' on the store line (scheme://user:token@host/) — the host is in $3, the match never fires, you get a silent empty variable. Always assert the length before POSTing.
  • Multi-line bodies: build JSON in python (json.dumps({"title":…, "body":open(f).read(),…})) or jq -n --arg b "$(cat b.txt)". NEVER pass --- as a bare jq argument (parsed as a flag). Escaped quotes inside python3 -c '…' break on \" — write the script to a file or use a heredoc.
  • Always capture status: -o r.json -w '%{http_code}'; a bare -o hides failures. A 201 response piped straight into python3 -c that prints None None means you parsed an error body (missing number) — print message too.

Create

POST /pulls with {title, body, base:"main", head:<branch>} — JSON is accepted here (unlike merge). Plain <branch> works; the owner:branch fork form is unnecessary on same-repo branches.

  • 409 pull request already exists for these targets [issue_id: N, …] — a PR for that head/base exists already. Common cause: the push message printed /pulls/new/<branch> and someone (Frank, or an xdg-open you ran) clicked Create. GET /pulls?state=all, find it, then PATCH /pulls/N with your title+body instead of re-posting.
  • GET /pulls?state=all on this Gitea omits head/base per entry — read /pulls/N for those, or key off number/user.
  • Issues: same shape, POST /issues {title, body}; PATCH /issues/N fixes a body retroactively (works for PRs too, since a PR is an issue) — used to correct a false verification claim in a PR that had already been merged. Body update returns 201, not 200; confirm by reading body back, not by status code.
  • Issue labels are numeric ids, not names. POST /issues with "labels":["ready-for-human"] answers 422 {"message":"[]: json: cannot unmarshal JSON string into Go int64 within \"/labels/0\""}. Resolve first: GET /labels → pick id by name → post "labels":[20]. (PRs take no labels field on create; label afterwards via /issues/N/labels with {"labels":[id]}.)
  • Comments (the channel for late-arriving evidence, e.g. a test-suite verdict posted after PR creation): POST /issues/N/comments with JSON {body} — works for PRs too, returns 201.
  • Open the issue before the first commit, so Refs #N fits the message, and get the branch name right before the first push. git branch -m <new> + git push -u origin <new> + git push origin --delete <old> is a clean rename with no PR yet; do it rather than ship a branch that violates <type>/<issue#>-<slug> (a docs/… branch carrying a fix: commit is the common slip, and git grep on the old name still shows it).

Merge (the 422 trap) — corrected

POST /pulls/N/merge needs form-encoded fields; the merge form field name is lowercase do:

curl -sS -X POST -K "$cfg" \
  --data-urlencode 'do=merge' --data-urlencode 'merge_style=no-ff' \
  http://192.168.0.143:3000/api/v1/repos/dank/command-center/pulls/N/merge

JSON {"Do":"merge"} (what this skill used to claim) and header variants all return 422 {"message":"[Do]: Required"}. merge_style ∈ no-ff|merge|rebase|squash. Success returns 200 with an empty body; verify by landing a checkout rather than re-reading the PR. Merge only with Frank's explicit per-PR OK.

The async 405 when several PRs merge in a row

Merge #55, then #56 and #57 back-to-back, and #56/#57 answered:

405 {"message":"Please try again later","url":"http://dankclaw:3000/api/swagger"}

That is not a failure and not rate limiting: merging one PR moves main, which invalidates every other PR's cached mergeability, and Gitea recalculates it asynchronously. A merge attempt during recalculation is refused. Remedy — bounded poll on the PR, merge when the flag is true:

for attempt in 1 2 3 4 5 6 7 8 9 10; do
  m=$(curl -sS -K "$cfg" "$api/pulls/$n" | python3 -c \
      'import json,sys;d=json.load(sys.stdin);print(d.get("mergeable"),d.get("merged"))')
  case "$m" in *True*) <merge POST>; break ;; *) sleep 4 ;; esac
done

All three landed within ~5 s of retrying. Treat mergeable as a readiness gate, not a report. Match *True* on the pair defensively: False True means already merged — stop, don't merge again.

Verifying a merge without touching the deployment

GET /pulls/N → state: closed, merged: true, merge_commit_sha. Then prove content from the bare repo, not the working tree: git fetch -q origin && git show origin/main:<path> | grep -n <key line> and git log --oneline --graph -4 origin/main (two parents = the --no-ff Frank asks for). A number you deliberately destroyed can legitimately reappear once — quoted in omp-docs/log.md as the wrong value; git grep -c <badvalue> origin/main naming only the log page is the pass condition, not a leftover. Closes #N in the PR body auto-closes the issue; confirm with GET /issues/N → state.

Stacked PRs (no rebase)

When two open branches edit the same section of the same file, merge the sibling branch into the later one (git merge docs/other-branch) instead of rebasing — Frank rejects the history rewrite. Consequences to state in the PR body: merging the lower PR first reduces the upper diff to just its own work; merging the upper first makes the lower an already-contained no-op. Either order is clean; git branch -d on the lower branch refuses until its commits reach main, because -d checks against the current HEAD.

Post-merge landing

  • Command-center: the checkout IS the deployment — git -C ~/.omp fetch -q origin && git -C ~/.omp merge --ff-only origin/main. This refuses if any incoming path is dirty in the worktree — see land-merged-pr-with-blocked-tree-ops for the shared-workspace case where checkout and stash are both policy-blocked.
  • serverAppConfigs: deploy.sh pushes the dev checkout's branch first — if the checkout's main is behind a merge you just made (via the API), the deploy dies with error: failed to push some refs. git merge --ff-only origin/main in the deploy checkout BEFORE running deploy.sh after any externally-merged PR.
  • A PR that changes a path you edited live adds that path to the discard list. Tonight: the live dotfile fix was dirty and incoming, so landing needed git -C ~/.omp checkout -- <that file> as well as the usual file. Discard is content-free only when you proved byte identity first (cmp the branch file against the live one before merging) — say so in the PR body, and note that if live-sync.sh commits the dirty file first the merge stays clean because both sides hold identical bytes.
  • Package repos (~/Projects/<pkg>): same --ff-only, then reinstall (omp plugin upgrade <name>) and cmp the installed dist/index.js against the repo dist/index.js to prove the live artifact is the merged one. Check ls -ld node_modules after landing anything that touched it.
  • omp plugin upgrade takes the npm package name, not the short name. omp plugin upgrade dankvoice fails with Failed to upgrade dankvoice: dankvoice is not installed while the plugin is plainly installed; the package is omp-dankvoice. Find the truth with ls ~/.omp/plugins/node_modules or find ~/.omp -path '*<name>/dist/index.js' -not -path '*/Projects/*' — the installed tree is unscoped (~/.omp/plugins/node_modules/omp-dankvoice/dist/index.js), not @dank/dankvoice.
  • cmp exiting 1 BEFORE the upgrade is the expected state (installed bundle is the pre-merge build, diff reported at some char/line). Do not treat it as a failed deploy and do not "fix" the build; run the upgrade, then cmp again for byte identity, and grep the new bundle for a string only the merged code contains (e.g. grep -c "Heckle instead of summarizing") as a second, semantic confirmation.
  • Prune: git worktree remove --force <path> (force: untracked deps link), git branch -D, git push origin --delete <branch> (allowed: local Gitea). Before removing a worktree, check no live process has a cwd inside it (ls -l /proc/*/cwd | grep -c "wt/<name>") — same guard live-sync.sh uses — and copy any file you moved into a gitignored .AGENTTRASH/ there to its permanent home first, or the removal destroys the only copy.

Every issue/PR body ends with ---, then cwd: <session cwd> · session: <session id>. Read the real values, never invent an id.

  • The session id is the UUID suffix of the LIVE transcript .jsonl, not the session directory name. Transcript dirs are keyed by cwd (e.g. -.omp-wt-wt-20261019-020309-6d1fc1f) and ONE cwd can hold several sessions (resume / continuation forks), so ls -t ~/.omp/agent/sessions/ cannot tell you which is yours and ls -t <dir> is equally blind. Pick the file being appended to right now: ls -l --time-style=+%H:%M:%S <dir>/*.jsonl beside date +%H:%M:%S — the mtime that equals the current second is the live session. The id is the part after _ in <timestamp>_<uuid>.jsonl.
  • A history.db query for session_id filtered by cwd usually agrees, but it is not authoritative for WHICH of several sessions in this cwd is live — use the jsonl mtime test.
  • local/ does NOT disambiguate: a forked transcript dir carries copies of the same local:// artifacts, so matching artifact names proves nothing about which session is live.
  • cwd is that session's own working directory (the path the harness states), not the build worktree you committed in — when they differ, name the build worktree inside the body and keep cwd the session's.
  • Resolve local://<name>.md with read local://<name>.md (its header prints the absolute path) instead of guessing the directory.