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
|
||||
}
|
||||
|
||||
# --- 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" ;;
|
||||
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