feat(forge): the reconciler verb surface on both backends
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
Some checks failed
CI / test (pull_request) Has been cancelled
CI / release-exercise (pull_request) Has been cancelled
CI / self-guards (pull_request) Has been cancelled
CI / action-exercise (pull_request) Has been cancelled
CI / docs-sync-exercise (pull_request) Has been cancelled
labels / labels (pull_request) Has been cancelled
github is the existing gh invocation extracted 1:1 (term 5). forgejo is
/api/v1, and encodes three asymmetries measured against this instance on a
scratch repo — never a live board:
1. Adding labels takes NAMES; removing one takes a numeric ID.
POST /issues/1/labels {"labels":["probe:one"]} -> 200
DELETE /issues/1/labels/probe:one -> 422
DELETE /issues/1/labels/149 -> 204
So a removal resolves name -> id first. gh hides this; the shim cannot.
2. Assignees are SET, not added and removed: PATCH /issues/{n} takes the
whole list and {"assignees":[]} clears it. --remove-assignee is therefore
a read-modify-write, not a delete.
3. There is no statusCheckRollup. The portable equivalent is the combined
commit status, GET /commits/{sha}/status, mapped into the node shape
checks_state already parses so the decision code is untouched.
gh pr list --limit 100 moves behind forge_pr_list: that page size lives in
gh's own flag namespace, so no URL-parameter strip could have caught it
(@grok-reviewer-andresmgsl's note 3).
Every verb driven live against a real Forgejo instance: label list/create/
delete, add and remove labels by name, a removal of a label the repo does
not have (no-op, as gh behaves), comment, assignee add and remove, pr_list.
Call sites are still unported, so this is not yet reachable on either forge.
Refs #188
This commit is contained in:
parent
66e20f12f0
commit
714a2e0413
2 changed files with 209 additions and 0 deletions
|
|
@ -213,3 +213,162 @@ forgejo_http_ok() {
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- the verbs the reconcilers use, over /api/v1 --------------------------
|
||||||
|
# Three asymmetries with gh, all measured against this instance on
|
||||||
|
# 2026-08-02 using a scratch repo (never a live board):
|
||||||
|
#
|
||||||
|
# 1. Adding labels takes NAMES POST /issues/{n}/labels {"labels":["x"]} -> 200
|
||||||
|
# Removing one takes a numeric ID DELETE /issues/{n}/labels/x -> 422
|
||||||
|
# DELETE /issues/{n}/labels/149 -> 204
|
||||||
|
# So a removal must resolve name -> id first. gh hides this; the shim
|
||||||
|
# cannot.
|
||||||
|
#
|
||||||
|
# 2. Assignees are SET, not added and removed. PATCH /issues/{n} takes the
|
||||||
|
# whole list ({"assignees":[]} clears it, 201), so --remove-assignee is
|
||||||
|
# a read-modify-write rather than a delete.
|
||||||
|
#
|
||||||
|
# 3. There is no statusCheckRollup. The portable equivalent is the
|
||||||
|
# combined commit status, GET /commits/{sha}/status, which returns
|
||||||
|
# {state, statuses[]}.
|
||||||
|
|
||||||
|
# forgejo_label_ids — name<TAB>id for every label in the repo, read once per
|
||||||
|
# call site that needs it. Paginated through forge_api, so a repo with more
|
||||||
|
# than one page of labels cannot silently lose the tail (#188).
|
||||||
|
forgejo_label_ids() {
|
||||||
|
forge_api --paginate "repos/$REPO/labels" --jq '.[] | "\(.name)\t\(.id)"'
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_issue_edit <n> [--add-label X]… [--remove-label X]… [--add-assignee U]… [--remove-assignee U]…
|
||||||
|
# gh's flag surface, translated. Accepts comma-separated values, as gh does.
|
||||||
|
forge_issue_edit() {
|
||||||
|
local n="${1:?forge_issue_edit: number required}"
|
||||||
|
shift
|
||||||
|
local add_labels=() rm_labels=() add_assignees=() rm_assignees=() v
|
||||||
|
while [ $# -gt 0 ]; do
|
||||||
|
case "$1" in
|
||||||
|
--add-label) IFS=, read -ra v <<<"$2"; add_labels+=("${v[@]}"); shift ;;
|
||||||
|
--remove-label) IFS=, read -ra v <<<"$2"; rm_labels+=("${v[@]}"); shift ;;
|
||||||
|
--add-assignee) IFS=, read -ra v <<<"$2"; add_assignees+=("${v[@]}"); shift ;;
|
||||||
|
--remove-assignee) IFS=, read -ra v <<<"$2"; rm_assignees+=("${v[@]}"); shift ;;
|
||||||
|
*) ;;
|
||||||
|
esac
|
||||||
|
shift
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ "${#add_labels[@]}" -gt 0 ]; then
|
||||||
|
local payload
|
||||||
|
payload="$(printf '%s\n' "${add_labels[@]}" | jq -R . | jq -s '{labels: .}')"
|
||||||
|
forgejo_write POST "repos/$REPO/issues/$n/labels" "$payload" >/dev/null || return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${#rm_labels[@]}" -gt 0 ]; then
|
||||||
|
local ids id name
|
||||||
|
ids="$(forgejo_label_ids)" || return 1
|
||||||
|
for name in "${rm_labels[@]}"; do
|
||||||
|
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
|
||||||
|
# A label the repo does not have is not an error: the reconcilers call
|
||||||
|
# --remove-label unconditionally to converge state, and gh's own
|
||||||
|
# behaviour there is a no-op.
|
||||||
|
[ -n "$id" ] || continue
|
||||||
|
forgejo_write DELETE "repos/$REPO/issues/$n/labels/$id" '' >/dev/null || return 1
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${#add_assignees[@]}" -gt 0 ] || [ "${#rm_assignees[@]}" -gt 0 ]; then
|
||||||
|
local current want payload
|
||||||
|
current="$(forge_api "repos/$REPO/issues/$n" --jq '[.assignees[]?.login] | join("\n")')" || return 1
|
||||||
|
want="$(
|
||||||
|
{
|
||||||
|
printf '%s\n' "$current"
|
||||||
|
[ "${#add_assignees[@]}" -gt 0 ] && printf '%s\n' "${add_assignees[@]}"
|
||||||
|
} | grep -v '^$' | sort -u
|
||||||
|
)"
|
||||||
|
if [ "${#rm_assignees[@]}" -gt 0 ]; then
|
||||||
|
want="$(grep -vxF -f <(printf '%s\n' "${rm_assignees[@]}") <<<"$want" || true)"
|
||||||
|
fi
|
||||||
|
payload="$(printf '%s' "$want" | jq -R . | jq -s '{assignees: [.[] | select(. != "")]}')"
|
||||||
|
forgejo_write PATCH "repos/$REPO/issues/$n" "$payload" >/dev/null || return 1
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
forge_issue_comment() {
|
||||||
|
local n="${1:?forge_issue_comment: number required}" body="${2?forge_issue_comment: body required}"
|
||||||
|
forgejo_write POST "repos/$REPO/issues/$n/comments" "$(jq -n --arg b "$body" '{body: $b}')" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
forge_pr_list() {
|
||||||
|
forge_api --paginate "repos/$REPO/pulls?state=open" --jq '.[].number'
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_pr_view <n> — the {mergeable, statusCheckRollup} shape the state
|
||||||
|
# machine reads, assembled from the two places Forgejo keeps it. The rollup
|
||||||
|
# is mapped into the node shape checks_state already parses, so the decision
|
||||||
|
# code is untouched.
|
||||||
|
forge_pr_view() {
|
||||||
|
local n="${1:?forge_pr_view: number required}" pr sha status
|
||||||
|
pr="$(forge_api "repos/$REPO/pulls/$n")" || return 1
|
||||||
|
sha="$(jq -r '.head.sha // ""' <<<"$pr")"
|
||||||
|
[ -n "$sha" ] || { echo "forge_pr_view: PR $n has no head sha" >&2; return 1; }
|
||||||
|
status="$(forge_api "repos/$REPO/commits/$sha/status")" || return 1
|
||||||
|
jq -n --argjson pr "$pr" --argjson st "$status" '
|
||||||
|
{
|
||||||
|
mergeable: (if $pr.mergeable == true then "MERGEABLE"
|
||||||
|
elif $pr.mergeable == false then "CONFLICTING"
|
||||||
|
else "UNKNOWN" end),
|
||||||
|
statusCheckRollup: [
|
||||||
|
$st.statuses[]? | {
|
||||||
|
__typename: "StatusContext",
|
||||||
|
context: .context,
|
||||||
|
state: (.status | ascii_upcase)
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}'
|
||||||
|
}
|
||||||
|
|
||||||
|
forge_label_list() { forge_api --paginate "repos/$REPO/labels" --jq '.[].name'; }
|
||||||
|
|
||||||
|
forge_label_create() {
|
||||||
|
local name="${1:?}" color="${2:?}" desc="${3:-}"
|
||||||
|
forgejo_write POST "repos/$REPO/labels" \
|
||||||
|
"$(jq -n --arg n "$name" --arg c "$color" --arg d "$desc" '{name:$n,color:$c,description:$d}')" >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
forge_label_delete() {
|
||||||
|
local name="${1:?}" ids id
|
||||||
|
ids="$(forgejo_label_ids)" || return 1
|
||||||
|
id="$(awk -F '\t' -v want="$name" '$1 == want { print $2; exit }' <<<"$ids")"
|
||||||
|
[ -n "$id" ] || return 0
|
||||||
|
forgejo_write DELETE "repos/$REPO/labels/$id" '' >/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
# forgejo_write <method> <endpoint> <json-body> — every mutation goes through
|
||||||
|
# here so a non-2xx is named rather than swallowed, the same contract
|
||||||
|
# forgejo_http_ok gives reads.
|
||||||
|
forgejo_write() {
|
||||||
|
local method="$1" endpoint="$2" payload="$3" base token hdr body rc
|
||||||
|
base="$(forgejo_api_base)" || return 1
|
||||||
|
token="${GH_TOKEN:-${GITHUB_TOKEN:-${FORGEJO_TOKEN:-}}}"
|
||||||
|
hdr="$(mktemp)"; body="$(mktemp)"
|
||||||
|
if [ -n "$payload" ]; then
|
||||||
|
curl -sS -X "$method" -D "$hdr" -o "$body" \
|
||||||
|
-H "Authorization: token $token" -H 'Content-Type: application/json' \
|
||||||
|
-d "$payload" "$base/$endpoint"
|
||||||
|
else
|
||||||
|
curl -sS -X "$method" -D "$hdr" -o "$body" \
|
||||||
|
-H "Authorization: token $token" "$base/$endpoint"
|
||||||
|
fi
|
||||||
|
rc=$?
|
||||||
|
if [ "$rc" -ne 0 ]; then
|
||||||
|
rm -f "$hdr" "$body"
|
||||||
|
echo "forge: $method $endpoint failed to send" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
if ! forgejo_http_ok "$hdr" "$method $endpoint"; then
|
||||||
|
head -c 300 "$body" >&2; echo >&2
|
||||||
|
rm -f "$hdr" "$body"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
cat "$body"
|
||||||
|
rm -f "$hdr" "$body"
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,3 +65,53 @@ github_page_url() {
|
||||||
*) printf '%s?per_page=100\n' "$clean" ;;
|
*) printf '%s?per_page=100\n' "$clean" ;;
|
||||||
esac
|
esac
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- the verbs the reconcilers use, extracted 1:1 -------------------------
|
||||||
|
# Every one of these is the exact `gh` invocation the call site carried
|
||||||
|
# before the port. Term 5 is kept by making this file boring.
|
||||||
|
|
||||||
|
# forge_issue_edit <n> <gh-style flags…> — labels and assignees on an issue
|
||||||
|
# or a PR (gh treats them interchangeably, and so do the call sites).
|
||||||
|
forge_issue_edit() {
|
||||||
|
local n="${1:?forge_issue_edit: number required}"
|
||||||
|
shift
|
||||||
|
gh issue edit "$n" -R "$REPO" "$@"
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_issue_comment <n> <body>
|
||||||
|
forge_issue_comment() {
|
||||||
|
local n="${1:?forge_issue_comment: number required}" body="${2?forge_issue_comment: body required}"
|
||||||
|
gh issue comment "$n" -R "$REPO" --body "$body"
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_pr_list — open PR numbers, one per line. Note this used
|
||||||
|
# `gh pr list --limit 100`: a page size in gh's OWN flag namespace, which no
|
||||||
|
# URL-parameter strip could have caught, so it moves behind the shim with
|
||||||
|
# the rest (#188).
|
||||||
|
forge_pr_list() {
|
||||||
|
gh pr list -R "$REPO" --state open --limit 100 --json number --jq '.[].number'
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_pr_view <n> — {mergeable, statusCheckRollup} as JSON, or non-zero
|
||||||
|
# with the reason on stderr. `gh pr view` rather than the REST PR object:
|
||||||
|
# the API's `mergeable` is a tri-state boolean GitHub computes lazily, while
|
||||||
|
# this returns the MERGEABLE/CONFLICTING/UNKNOWN string the UI shows.
|
||||||
|
forge_pr_view() {
|
||||||
|
local n="${1:?forge_pr_view: number required}"
|
||||||
|
gh pr view "$n" -R "$REPO" --json mergeable,statusCheckRollup
|
||||||
|
}
|
||||||
|
|
||||||
|
# forge_label_list — every label name in the repo.
|
||||||
|
forge_label_list() {
|
||||||
|
gh label list -R "$REPO" --limit 200 --json name --jq '.[].name'
|
||||||
|
}
|
||||||
|
|
||||||
|
forge_label_create() {
|
||||||
|
local name="${1:?}" color="${2:?}" desc="${3:-}"
|
||||||
|
gh label create "$name" -R "$REPO" --color "$color" --description "$desc" --force
|
||||||
|
}
|
||||||
|
|
||||||
|
forge_label_delete() {
|
||||||
|
local name="${1:?}"
|
||||||
|
gh label delete "$name" -R "$REPO" --yes
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue