fix(gitea): 認證失敗不再被讀成頁面不存在

金鑰失效以前會偽裝成別的結果。指令把請求直接接進管線,管線的結束狀態
取自後段的解析程式,前段的失敗就被吃掉。wiki 頁清單因此看起來是空的,
PR 留言看起來像沒有任何審查意見。wiki 讀取更把每一種失敗都翻成
「頁面不存在」。

技能組寫 wiki 的語意是附加、不覆蓋,判斷依據是先把舊內容讀回來。呼叫端
一旦把認證失敗當成一張新頁,就會整份蓋上去,舊紀錄直接消失。

現在失敗成因分開回報:找不到、金鑰失效或權限不足、其他 API 失敗,各給
一個結束碼。每條管線先接進變數,先看結束碼,再解析內容。議題工具的同一
類缺陷一併修掉。wiki 技能也把「只有找不到才可以建新頁」寫成獨立規則,
涵蓋每一條「不存在就建立」的路徑。
This commit is contained in:
2026-08-31 11:11:55 +08:00
parent dfb9557992
commit 28a52c9f0c
3 changed files with 277 additions and 55 deletions
+30 -12
View File
@@ -1,6 +1,6 @@
---
name: wiki
description: Read or write a Gitea wiki page through tools/gitea.sh and tools/hash-id. Resolve the wiki repo per page type with JSC_WIKI_REPO_{TYPE} first, then JSC_WIKI_REPO, and ask only when neither is set. Page content is chart-first - prefer mermaid diagrams and markdown tables over plain prose. Callers are jsc-ask, jsc-sdlc, jsc-log, jsc-hooks for its ERROR pages, jsc-cli for its CHECK pages, and jsc-meta for its SKILLSET pages. Use for any wiki page in the skill set; not for repo code files.
description: Read or write a Gitea wiki page through tools/gitea.sh and tools/hash-id. Resolve the wiki repo per page type with JSC_WIKI_REPO_{TYPE} first, then JSC_WIKI_REPO, and ask only when neither is set. Page content is chart-first - prefer mermaid diagrams and markdown tables over plain prose. Callers are jsc-ask, jsc-sdlc, jsc-log, jsc-hooks for its ERROR pages, jsc-cli for its CHECK pages, and jsc-meta for its SKILLSET and TOOLING pages. Use for any wiki page in the skill set; not for repo code files.
---
# wiki — read and write Gitea wiki pages
@@ -11,26 +11,44 @@ Every wiki operation in the jsc skill set goes through this skill. One entry poi
Different page types can live in different `{owner}/{repo}` repos, classified by the page-name prefix.
1. Before asking the user, inspect the current shell environment for the needed repo variables and Gitea connection variables: `JSC_WIKI_REPO_{TYPE}`, `JSC_WIKI_REPO`, `GITEA_HOST`, and `GITEA_TOKEN` (also check `tea login list` for a usable login token when `GITEA_TOKEN` is unset). Use inherited shell values first; only ask when the needed repo or connection value cannot be resolved after that check. Done when every one of those variables is either resolved from the environment or listed as missing.
2. Run `tools/gitea.sh wiki-repo {TYPE}` (TYPE = the page-name prefix). Allowed types are `QUESTION`, `PLAN`, `ANALYZE`, `DELIVER`, `MAINTAIN`, `REPO`, `LOG`, `LEARN`, `ERROR`, `CHECK`, `REPORT`, and `SKILLSET`. Resolution order is `JSC_WIKI_REPO_{TYPE}` first, then `JSC_WIKI_REPO`. Never borrow another type's repo. Done when the command has printed exactly one `{owner}/{repo}`, or exited 3 and sent this page type to step 3.
3. On exit 3 (neither is set after env inspection), ask the user for that page type's `{owner}/{repo}` per the `jsc-ask:ask` rules, and suggest setting `JSC_WIKI_REPO_{TYPE}` (can differ per type) or `JSC_WIKI_REPO` (shared default). Done when the user has supplied one `{owner}/{repo}` for that page type.
1. **Host gate.** Confirm `GITEA_HOST` holds a value in the current shell. When it is missing, ask for it per the `jsc-ask:ask` rules before any `tools/gitea.sh` call that reaches the API; otherwise the first thing the user sees is the script's `GITEA_HOST is required` line instead of a decision-tree question. `GITEA_TOKEN` needs no inventory here — the script resolves it, retries once with the tea CLI login token, and exits 7 when neither works. Done when `GITEA_HOST` holds a value.
2. Run `tools/gitea.sh wiki-repo {TYPE}` (TYPE = the page-name prefix). Allowed types are `QUESTION`, `PLAN`, `ANALYZE`, `DELIVER`, `MAINTAIN`, `REPO`, `LOG`, `LEARN`, `ERROR`, `CHECK`, `REPORT`, `SKILLSET`, and `TOOLING`. The script reads `JSC_WIKI_REPO_{TYPE}` first and `JSC_WIKI_REPO` second, straight from the inherited environment, so take no separate inventory of those two variables. Never borrow another type's repo. Done when the command has printed exactly one `{owner}/{repo}`, or exited 3 and sent this page type to step 3, or exited 2 on a type outside the list above and stopped the run.
3. On exit 3 (neither variable is set), ask the user for that page type's `{owner}/{repo}` per the `jsc-ask:ask` rules, and suggest setting `JSC_WIKI_REPO_{TYPE}` (can differ per type) or `JSC_WIKI_REPO` (shared default). Done when the user has supplied one `{owner}/{repo}` for that page type.
## Operations
| Action | Command |
| --- | --- |
| list pages | `tools/gitea.sh wiki-list {owner}/{repo}` |
| read page | `tools/gitea.sh wiki-get {owner}/{repo} {page}` (exit 4 when missing) |
| write page | 先寫入暫存檔,再呼叫 `tools/gitea.sh wiki-put {owner}/{repo} {page} {file}`(先確認,再建立或更新) |
| page URL | `tools/gitea.sh wiki-url {owner}/{repo} {page}` — the page's absolute URL, taken from the API's `html_url` (exit 4 when the page is missing) |
| read page | `tools/gitea.sh wiki-get {owner}/{repo} {page}` |
| write page | write the content to a temp file first, then `tools/gitea.sh wiki-put {owner}/{repo} {page} {file}` (asks for confirmation first, then creates or updates) |
| page URL | `tools/gitea.sh wiki-url {owner}/{repo} {page}` — the page's absolute URL, taken from the API's `html_url` |
When writing a page, link same-type pages with the `[[display|page]]` form (display text on the LEFT) and cross-type pages with the absolute URL from `wiki-url`, because `[[...]]` resolves only inside one wiki. Full rules and the direction trap: `references/wiki-links.md`.
## Exit codes
Route every `tools/gitea.sh` call in this skill on its exit code. A code with no branch below stops the run and gets reported as it is.
| Code | Meaning | What this skill does |
| --- | --- | --- |
| 0 | success | use the output |
| 2 | usage error, or a page type outside the allowed list | fix the arguments, then call again; never repeat the same call unchanged |
| 3 | `wiki-repo`: neither `JSC_WIKI_REPO_{TYPE}` nor `JSC_WIKI_REPO` is set | go to step 3 and ask |
| 4 | HTTP 404: `wiki-get` and `wiki-url` found no such page | for a read the caller expects to succeed, stop and report the page name; this is the **only** code that opens the create path of rule 4 — write the page from the template instead of appending |
| 5 | `wiki-url`: the page exists but the API returned no `html_url` | stop and report it. Link inside the same wiki with `[[display\|page]]`; a cross-repo link has no absolute URL to point at, so do not fabricate one |
| 7 | HTTP 401 or 403 after the tea-token retry: the key is invalid or lacks permission | **stop the whole operation and report the key problem.** Never read this as an empty or missing page, and never take the create path of rule 4: writing a fresh page over one you could not read destroys the record that is still there |
| 8 | any other API failure, HTTP status in the message | stop and report that status; call again only after the cause is fixed |
## Rules
1. Page names must follow the wiki naming table in the skill guidelines (see `jsc-meta/references/guidelines.md`).
2. Use `tools/hash-id` for `{HASH}` values. It returns the first 8 uppercase SHA-1 hex chars, or `H` plus the first 7 chars when the raw hash starts with `0-9`, `A`, `B`, or `C`.
3. To update a contents page (`*_CONTENTS`): `wiki-get` it first, apply the template to append or modify, then `wiki-put` the whole page back(寫入前會先確認)。不要覆寫別人的列。
4. Write all wiki content in UTF-8 Traditional Chinese, per the STE100 output rule.
5. Prefer visual forms for page content: use mermaid diagrams (flowchart, sequence, gantt, pie) and markdown tables wherever the information allows. Prose is capped at 3 sentences per section, and a sentence stays only when neither a mermaid diagram nor a markdown table can carry the same information.
6. `tools/gitea.sh` retries once with the tea CLI login token when `GITEA_TOKEN` is missing or the response is 401/403. Report a failure only after that retry also fails.
2. Use `tools/hash-id` for `{HASH}` values. It returns the first 8 uppercase SHA-1 hex chars, or `H` plus the first 7 chars when the raw hash starts with `0-9`, `A`, `B`, or `C`. Exit 1 means this machine has neither `sha1sum` nor `shasum`: stop, report that one of them has to be installed, and compute no hash by hand — a hand-made page name lands the content on a page nobody else reads.
3. To update a contents page (`*_CONTENTS`): `wiki-get` it first, apply the template to append or modify, then `wiki-put` the whole page back — the write asks for confirmation before it goes out. Never overwrite entries owned by others. Whether the page may be created from the template instead is decided by rule 4, and by nothing else.
4. **Only exit 4 means the page is not there yet — this rule binds every "create it if it does not exist" path, without exception.** It is not limited to contents pages: a content page (`*_{HASH}`), a work log, an error page, a report, any page at all, follows the same branch.
- **Correct branch.** Read the page with `wiki-get`. Exit 0 means the page exists, so append or modify the content that came back and `wiki-put` the whole page. Exit 4 (HTTP 404) is the one and only code that permits creating a new page from the template.
- **Exit 7 and exit 8 abort.** Exit 7 (HTTP 401 or 403) and exit 8 (any other API failure) both mean the old content is unknown, never that the page is missing. Stop the operation and report the exit code with its cause. Create no page, write nothing, and do not retry the same call unchanged.
- **Why.** Wiki writes in this skill set are append-not-overwrite, and that semantics rests entirely on reading the old page back first. Reading a 401 as a 404 makes the caller believe it holds a brand-new page and `wiki-put` a fresh template over a live one, and the whole earlier record is gone — the write carries no merge and no backup.
5. Write all wiki content in UTF-8 Traditional Chinese, per the STE100 output rule.
6. Prefer visual forms for page content: use mermaid diagrams (flowchart, sequence, gantt, pie) and markdown tables wherever the information allows. Prose is capped at 3 sentences per section, and a sentence stays only when neither a mermaid diagram nor a markdown table can carry the same information.
7. `tools/gitea.sh` retries once with the tea CLI login token when `GITEA_TOKEN` is missing or the response is 401/403. Report a failure only after that retry also fails.
+176 -30
View File
@@ -7,7 +7,7 @@
# gitea.sh clone-url <owner>/<repo> # 印出 clone URL
# gitea.sh hash-id <text> # 產生 8 碼大寫 SHA-1 hash;首碼為 0-9/A/B/C 時改成 Hxxxxxxx
# gitea.sh wiki-repo <TYPE> # 解析頁面類型的 wiki 位置:
# TYPE = QUESTION|PLAN|ANALYZE|DELIVER|MAINTAIN|REPO|LOG|LEARN|ERROR|CHECK|REPORT|SKILLSET(即頁名前綴)
# TYPE = QUESTION|PLAN|ANALYZE|DELIVER|MAINTAIN|REPO|LOG|LEARN|ERROR|CHECK|REPORT|SKILLSET|TOOLING(即頁名前綴)
# 依序取 JSC_WIKI_REPO_{TYPE} > JSC_WIKI_REPO;不得跨類型代用;都未設定 exit 3
# gitea.sh wiki-list <owner>/<repo> # 列出 wiki 頁名
# gitea.sh wiki-get <owner>/<repo> <page> # 印出 wiki 頁 markdown;不存在時 exit 4
@@ -16,6 +16,18 @@
# gitea.sh pr-create <owner>/<repo> <head> <base> <title> <body-file> # 建立 PR,印出 PR URL
# gitea.sh pr-status <owner>/<repo> <pr-index> # 印出「{state} {merged} {mergeable}」
# gitea.sh pr-get <owner>/<repo> <pr-index> # 印出 PR 的標題、base 分支與描述,供比對用
# gitea.sh pr-of-branch <owner>/<repo> <branch> # 印出該分支目前開啟中的 PR(比對 head.ref)
# 輸出格式固定四段,描述放最後,因為只有它會多行:
# 第 1 行 number<TAB>{PR 編號}
# 第 2 行 title<TAB>{標題}
# 第 3 行 base<TAB>{base 分支}
# 第 4 行 body (單獨一個字,當描述的起始標記)
# 第 5 行起 描述原文,一直到檔尾
# 後三段與 pr-get 完全一致,只在最前面多一行 number。呼叫端取編號用 head -n1 | cut -f2-,
# 取描述用 tail -n +5,全程不必解析 JSON,也不必再打一次 pr-get。
# 結束碼: 0=找到 2=用法錯誤 3=該分支沒有開啟中的 PR 4=API 失敗
# 「沒有 PR」必須是 3,不能借用通用的 API 失敗碼:兩者混用會把金鑰失效讀成
# 「這個分支還沒開 PR」,呼叫端接著就開出第二支重複的 PR。
# gitea.sh pr-edit <owner>/<repo> <pr-index> <title> <body-file> # 更新 PR 的標題與描述
# gitea.sh pr-comments <owner>/<repo> <pr-index> # 印出所有留言(issue 留言、審查評語、行內留言),第三欄帶 #id
# gitea.sh comment-reply <owner>/<repo> <pr-index> <issue|review|inline> <comment-id> <body-file>
@@ -28,6 +40,15 @@
# 環境變數: GITEA_HOST(例 https://gitea.jsc.idv.tw)、GITEA_TOKEN
# GITEA_TOKEN 未設定,或請求遇 401/403 時,自動退回 tea CLI 的登入 token
# (~/.config/tea/config.yml,優先取 url 與 GITEA_HOST 同主機的登入,其次 default: true);兩者皆無才失敗。
# 結束碼: 0=成功 1=api 子命令的請求失敗 2=用法錯誤或不認得的指令
# 3=wiki-repo 的該類型沒有設定存取庫、pr-of-branch 的該分支沒有開啟中的 PR
# 4=找不到(HTTP 404,含 wiki 頁不存在)、PR 系列子命令的 API 失敗,
# 以及 pr-of-branch 翻過 50 頁上限仍沒結束(分頁沒有前進)
# 5=wiki 頁沒有 html_url
# 7=Gitea 金鑰失效或權限不足(HTTP 401/403)
# 8=其他 API 失敗,訊息帶 HTTP 狀態
# 7 與 8 是 2026-08 實測補上的:原本認證失敗、伺服器錯誤全部被歸成「頁面不存在」,
# 而 wiki 寫入的「附加不覆蓋」判斷就靠讀得到舊頁,誤判會直接蓋掉舊紀錄。
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
@@ -38,7 +59,7 @@ confirm_write() {
resolve_wiki_repo() { # TYPE -> JSC_WIKI_REPO_{TYPE} -> JSC_WIKI_REPO
type=$(printf '%s' "${1:?TYPE required}" | tr a-z A-Z)
case "$type" in
QUESTION|PLAN|ANALYZE|DELIVER|MAINTAIN|REPO|LOG|LEARN|ERROR|CHECK|REPORT|SKILLSET) ;;
QUESTION|PLAN|ANALYZE|DELIVER|MAINTAIN|REPO|LOG|LEARN|ERROR|CHECK|REPORT|SKILLSET|TOOLING) ;;
*) echo "unknown wiki type: $type" >&2; exit 2 ;;
esac
eval "v=\${JSC_WIKI_REPO_${type}:-}"
@@ -103,7 +124,31 @@ fi
case "$GITEA_HOST" in http://*|https://*) HOST="$GITEA_HOST" ;; *) HOST="https://$GITEA_HOST" ;; esac
API="${HOST%/}/api/v1"
req() { # METHOD path [body-file] -> body(HTTP >= 400 時 exit 4;401/403 以 tea token 重試一次)
# 最後一次請求的 HTTP 狀態碼寫進檔案,不是變數:req 幾乎都在 $(...) 裡跑,
# 子行程設的變數回不到主行程,狀態碼會在回來的路上不見。
REQ_CODE_FILE=$(mktemp)
trap 'rm -f "$REQ_CODE_FILE"' EXIT
req_code() { cat "$REQ_CODE_FILE" 2>/dev/null || true; }
api_fail() { # $1=情境說明 -> 依最後一次的 HTTP 狀態分流退出
# 失敗成因一定要分開回報。全部歸成「找不到」是最危險的一種簡化:
# 認證失敗看起來就會像頁面不存在,呼叫端接著就用新頁的邏輯往上蓋。
_c=$(req_code)
case "$_c" in
401|403)
echo "[jsc][gitea][ERR]:$1 —— Gitea 金鑰失效或權限不足(HTTP $_c)。請換一支有效的 GITEA_TOKEN,或重新 tea login 之後再跑一次。" >&2
exit 7 ;;
404)
echo "[jsc][gitea][ERR]:$1 —— 找不到(HTTP 404)。" >&2
exit 4 ;;
*)
echo "[jsc][gitea][ERR]:$1 —— API 失敗(HTTP ${_c:-無回應})。" >&2
exit 8 ;;
esac
}
req() { # METHOD path [body-file] -> body(HTTP >= 400 時回非 0;401/403 以 tea token 重試一次)
# 送出的 Content-Type 由 REQ_CONTENT_TYPE 決定,預設 application/json;
# /markdown/raw 這種吃純文字的端點要先改成 text/plain 再呼叫。
method="$1"; path="$2"; body_file="${3:-}"
@@ -125,6 +170,7 @@ req() { # METHOD path [body-file] -> body(HTTP >= 400 時 exit 4;401/403 以
fi
break
done
printf '%s' "$code" > "$REQ_CODE_FILE"
printf '%s\n' "$out" | sed '$d'
[ "$code" -lt 400 ]
}
@@ -142,15 +188,21 @@ for i in items:
case "$cmd" in
owners)
{ req GET "/user" | json_field login
req GET "/user/orgs?limit=50" | json_field username; } | sort -u ;;
# req 不直接接管線:管線的結束狀態取自 json_field,會把 req 的失敗整個吃掉,
# 金鑰失效看起來就變成「查詢成功,一個 owner 都沒有」。
me=$(req GET "/user") || api_fail "讀不到目前登入的使用者"
orgs=$(req GET "/user/orgs?limit=50") || api_fail "讀不到組織清單"
{ printf '%s' "$me" | json_field login
printf '%s' "$orgs" | json_field username; } | sort -u ;;
repos)
owner="${1:?owner required}"
# 分頁抓 owner 的 repo(org 與 user 端點擇一成功)
page=1
while :; do
out=$(req GET "/orgs/$owner/repos?limit=50&page=$page" 2>/dev/null) \
|| out=$(req GET "/users/$owner/repos?limit=50&page=$page")
if ! out=$(req GET "/orgs/$owner/repos?limit=50&page=$page" 2>/dev/null); then
out=$(req GET "/users/$owner/repos?limit=50&page=$page") \
|| api_fail "列不出 $owner 的存取庫"
fi
names=$(printf '%s' "$out" | json_field full_name)
[ -n "$names" ] || break
printf '%s\n' "$names"
@@ -158,17 +210,30 @@ case "$cmd" in
done ;;
default-branch)
or="${1:?owner/repo required}"
req GET "/repos/$or" | json_field default_branch ;;
out=$(req GET "/repos/$or") || api_fail "讀不到存取庫 $or"
printf '%s' "$out" | json_field default_branch ;;
clone-url)
or="${1:?owner/repo required}"
req GET "/repos/$or" | json_field clone_url ;;
out=$(req GET "/repos/$or") || api_fail "讀不到存取庫 $or"
printf '%s' "$out" | json_field clone_url ;;
wiki-list)
or="${1:?owner/repo required}"
req GET "/repos/$or/wiki/pages?limit=200" | json_field title ;;
# 先接變數、先看 req 自己的結束碼,再餵給 json_field。直接接管線的話,
# 結束狀態會變成 json_field 的:金鑰失效回 401 時,這裡看起來像「列出成功,
# 一頁都沒有」,呼叫端就把整個 wiki 判成空的。
out=$(req GET "/repos/$or/wiki/pages?limit=200") || api_fail "列不出 $or 的 wiki 頁"
printf '%s' "$out" | json_field title ;;
wiki-get)
# 失敗成因一定要分開:技能組寫 wiki 的語意是「附加一節、不覆蓋舊紀錄」,
# 判斷依據就是先把舊內容讀回來。金鑰失效回 401 若被翻譯成「頁面不存在」,
# 呼叫端會把它當成一張新頁整份蓋上去,舊紀錄就沒了。
# 只有真的 404 才回 4;401/403 回 7,其他失敗回 8 並帶 HTTP 狀態。
or="${1:?owner/repo required}"; page="${2:?page required}"
if ! out=$(req GET "/repos/$or/wiki/page/$page" 2>/dev/null); then
echo "wiki page not found: $page" >&2; exit 4
case "$(req_code)" in
404) echo "wiki page not found: $page" >&2; exit 4 ;;
*) api_fail "讀不到 wiki 頁 $or/$page" ;;
esac
fi
printf '%s' "$out" | python3 -c '
import json,sys,base64
@@ -181,7 +246,10 @@ sys.stdout.write(base64.b64decode(d.get("content_base64","")).decode("utf-8"))
# 只有絕對網址會通:[[頁名]] 與 markdown 相對連結都只在同一個 wiki 內解析。
or="${1:?owner/repo required}"; page="${2:?page required}"
if ! out=$(req GET "/repos/$or/wiki/page/$page" 2>/dev/null); then
echo "wiki page not found: $page" >&2; exit 4
case "$(req_code)" in
404) echo "wiki page not found: $page" >&2; exit 4 ;;
*) api_fail "讀不到 wiki 頁 $or/$page" ;;
esac
fi
url=$(printf '%s' "$out" | python3 -c '
import json,sys
@@ -201,16 +269,24 @@ title,path=sys.argv[1],sys.argv[2]
content=open(path,"rb").read()
print(json.dumps({"title":title,"content_base64":base64.b64encode(content).decode()}))
' "$page" "$file" > "$tmp"
# 先探路只為了決定新建或更新。探路失敗的成因若是認證問題,接下來的 POST 也會失敗,
# 由 api_fail 照實回報,不會靜靜蓋掉既有頁面。
if req GET "/repos/$or/wiki/page/$page" >/dev/null 2>&1; then
req PATCH "/repos/$or/wiki/page/$page" "$tmp" >/dev/null
req PATCH "/repos/$or/wiki/page/$page" "$tmp" >/dev/null \
|| { rm -f "$tmp"; api_fail "更新 wiki 頁 $or/$page 失敗"; }
else
req POST "/repos/$or/wiki/new" "$tmp" >/dev/null
req POST "/repos/$or/wiki/new" "$tmp" >/dev/null \
|| { rm -f "$tmp"; api_fail "建立 wiki 頁 $or/$page 失敗"; }
fi
rm -f "$tmp"
echo "OK $page" ;;
pr-create)
or="${1:?owner/repo required}"; head="${2:?head required}"; base="${3:?base required}"
title="${4:?title required}"; body_file="${5:?body file required}"
# 描述檔缺席要回 2「用法錯誤」,跟 pr-edit、comment-reply 一致。少了這道檢查,
# 缺檔會變成 python3 的 open() 例外加 exit 1,呼叫端讀成「API 失敗」而去重試。
# 這道檢查排在確認之前:先擋掉自己打錯的參數,才不會問完使用者又失敗。
[ -f "$body_file" ] || { echo "找不到描述檔: $body_file" >&2; exit 2; }
confirm_write "建立 PR" "$or#$title"
tmp=$(mktemp)
python3 -c '
@@ -218,12 +294,23 @@ import json,sys
print(json.dumps({"head":sys.argv[1],"base":sys.argv[2],"title":sys.argv[3],
"body":open(sys.argv[4],encoding="utf-8").read()}))
' "$head" "$base" "$title" "$body_file" > "$tmp"
req POST "/repos/$or/pulls" "$tmp" | json_field html_url
rm -f "$tmp" ;;
if ! out=$(req POST "/repos/$or/pulls" "$tmp"); then
rm -f "$tmp"; printf '%s\n' "$out" >&2; exit 4
fi
rm -f "$tmp"
printf '%s' "$out" | json_field html_url ;;
pr-status)
# 印出「{state} {merged} {mergeable}」,供呼叫端判斷 PR 是否已合併。
# 查不到 PR(404)維持印「? none none」並 exit 0:pr-watch.sh 的白名單靠這個字串
# 判成 exit 3「查不到該 PR」。認證或連線失敗改回非 0,不再混進同一個字串裡。
or="${1:?owner/repo required}"; idx="${2:?pr index required}"
req GET "/repos/$or/pulls/$idx" | python3 -c '
if ! out=$(req GET "/repos/$or/pulls/$idx" 2>/dev/null); then
case "$(req_code)" in
404) echo '? none none'; exit 0 ;;
*) api_fail "讀不到 PR $or#$idx" ;;
esac
fi
printf '%s' "$out" | python3 -c '
import json,sys
p=json.load(sys.stdin)
print("%s %s %s" % (p.get("state","?"), str(p.get("merged")).lower(), str(p.get("mergeable")).lower()))
@@ -249,6 +336,52 @@ print("base\t%s" % ((p.get("base") or {}).get("ref") or ""))
print("body")
sys.stdout.write(p.get("body") or "")
' ;;
pr-of-branch)
# 找出某條分支目前開啟中的 PR。比對的是 head.ref,不是分支名的字串包含:
# feat/報表 與 feat/報表/main 只差一段,用包含比對會回錯的那一支。
# 輸出格式與 pr-get 對齊並多帶 index 與 url,呼叫端(jsc-git:commit、jsc-git:pr)
# 拿到就能直接比對標題與描述,不必再打一次 pr-get。
or="${1:?owner/repo required}"; branch="${2:?branch required}"
# 頁數上限。只靠「回空陣列」收尾的迴圈,遇上忽略 page 參數的站台或代理會一直
# 拿到同一頁而永遠停不下來——沒有輸出、也沒有結束碼,呼叫端只看得到卡住。
page=1
max_page=50
while :; do
if [ "$page" -gt "$max_page" ]; then
echo "[jsc][gitea][ERR]:列 $or 的開啟中 PR 超過 $max_page 頁仍沒有結束,分頁沒有前進(站台或代理可能忽略 page 參數)。" >&2
exit 4
fi
# req 的輸出先接進變數再解析。寫成 req ... | python3 的話,管線的結束狀態
# 取自 python,401 會變成「解不到相符的 PR」而回 3,正好踩中重複開 PR 那個坑。
if ! out=$(req GET "/repos/$or/pulls?state=open&limit=50&page=$page"); then
echo "[jsc][gitea][ERR]:列不出 $or 的開啟中 PR(HTTP $(req_code))。" >&2
exit 4
fi
res=$(printf '%s' "$out" | python3 -c '
import json,sys
want=sys.argv[1]
d=json.load(sys.stdin)
if not isinstance(d,list) or not d:
print("__EMPTY__"); raise SystemExit
for p in d:
if ((p.get("head") or {}).get("ref") or "") == want:
print("number\t%s" % (p.get("number") or p.get("index") or ""))
print("title\t%s" % (p.get("title") or ""))
print("base\t%s" % ((p.get("base") or {}).get("ref") or ""))
print("body")
sys.stdout.write(p.get("body") or "")
raise SystemExit
print("__NONE__")
' "$branch") || { echo "[jsc][gitea][ERR]:$or 的 PR 清單解不開。" >&2; exit 4; }
case "$res" in
__NONE__) page=$((page+1)); continue ;;
__EMPTY__) break ;;
esac
printf '%s\n' "$res"
exit 0
done
echo "[jsc][gitea][ERR]:分支 $branch 沒有開啟中的 PR。" >&2
exit 3 ;;
pr-edit)
# 更新 PR 的標題與描述。描述從檔案讀,才裝得下多行內容與全形標點。
or="${1:?owner/repo required}"; idx="${2:?pr index required}"
@@ -270,14 +403,28 @@ print(json.dumps({"title":sys.argv[1],"body":open(sys.argv[2],encoding="utf-8").
# 三個來源都要讀:issue 留言、review 本體的評語、review 內逐行的程式碼留言。
# 只讀 issue 留言會漏掉真正的審查意見,那正是需要修正的部分。
# 類型欄保留在第三欄,僅追加 id;呼叫端用第一欄做 since 比對時不受影響。
# req 一律先接進變數、先看它自己的結束碼,再餵給 python3。寫成 req … | python3
# 的話,管線的結束狀態取自 python:金鑰失效回 401 時,這裡會印出一份空的留言清單
# 並回 0,呼叫端(jsc-git:pr)就判成「這支 PR 沒有任何審查意見」,整輪留言修正被跳過。
or="${1:?owner/repo required}"; idx="${2:?pr index required}"
{ req GET "/repos/$or/issues/$idx/comments?limit=100" | python3 -c '
issues=$(req GET "/repos/$or/issues/$idx/comments?limit=100") \
|| api_fail "讀不到 PR $or#$idx 的留言"
reviews=$(req GET "/repos/$or/pulls/$idx/reviews?limit=100") \
|| api_fail "讀不到 PR $or#$idx 的審查清單"
rids=$(printf '%s' "$reviews" | python3 -c '
import json,sys
for r in json.load(sys.stdin):
if r.get("comments_count", 0) or r.get("id"): print(r["id"])
')
# 行內留言逐則抓,抓失敗一樣要當成失敗。收集在 $lines 裡最後才排序:
# 把 api_fail 放進管線的話,它只結束子行程,主流程照樣往下印出殘缺清單。
lines=$(mktemp)
{ printf '%s' "$issues" | python3 -c '
import json,sys
for c in json.load(sys.stdin):
body=" ".join((c.get("body") or "").split())
if body: print("%s\t%s\t留言#%s\t%s" % (c.get("created_at",""), (c.get("user") or {}).get("login","?"), c.get("id","?"), body))
'
reviews=$(req GET "/repos/$or/pulls/$idx/reviews?limit=100")
printf '%s' "$reviews" | python3 -c '
import json,sys
for r in json.load(sys.stdin):
@@ -287,21 +434,20 @@ for r in json.load(sys.stdin):
# 兩者都是呼叫端要據以決策的事實,過濾掉會看不見 PR 真正的狀態。
print("%s\t%s\t審查#%s(%s)\t%s" % (r.get("submitted_at",""), (r.get("user") or {}).get("login","?"), r.get("id","?"), st, body or "(無評語)"))
'
for rid in $(printf '%s' "$reviews" | python3 -c '
} > "$lines"
for rid in $rids; do
rc=$(req GET "/repos/$or/pulls/$idx/reviews/$rid/comments") \
|| { rm -f "$lines"; api_fail "讀不到 PR $or#$idx 審查 $rid 的行內留言"; }
printf '%s' "$rc" | python3 -c '
import json,sys
for r in json.load(sys.stdin):
if r.get("comments_count", 0) or r.get("id"): print(r["id"])
'); do
req GET "/repos/$or/pulls/$idx/reviews/$rid/comments" 2>/dev/null | python3 -c '
import json,sys
try: d=json.load(sys.stdin)
except Exception: raise SystemExit
d=json.load(sys.stdin)
for c in d:
body=" ".join((c.get("body") or "").split())
if body: print("%s\t%s\t行內#%s(%s:%s)\t%s" % (c.get("created_at",""), (c.get("user") or {}).get("login","?"), c.get("id","?"), c.get("path",""), c.get("original_position") or c.get("position") or "", body))
' || true
' >> "$lines"
done
} | sort ;;
sort "$lines"
rm -f "$lines" ;;
comment-reply)
or="${1:?owner/repo required}"; idx="${2:?pr index required}"
kind="${3:?kind required}"; cid="${4:?comment id required}"; body_file="${5:?body file required}"
@@ -362,7 +508,7 @@ print(json.dumps(b))
file="${1:?markdown file}"
[ -f "$file" ] || { echo "找不到 markdown 檔: $file" >&2; exit 2; }
REQ_CONTENT_TYPE='text/plain'
req POST "/markdown/raw" "$file" ;;
req POST "/markdown/raw" "$file" || api_fail "markdown 渲染失敗($file)" ;;
api)
method="${1:?METHOD}"; path="${2:?path}"; body="${3:-}"
req "$method" "$path" $body ;;
+70 -12
View File
@@ -11,6 +11,14 @@
# issue.sh title <owner>/<repo> <index> 印出議題標題
# issue.sh body <owner>/<repo> <index> 印出議題正文(markdown)
# issue.sh labels-of <owner>/<repo> <index> 印出議題目前的標籤名,一行一個
# issue.sh show <owner>/<repo> <index> 一次印出標題、標籤與正文(只打一次 API)
# 輸出格式固定四段,正文放最後,因為只有它會多行:
# 第 1 行 title<TAB>{標題}
# 第 2 行 labels<TAB>{標籤名,逗號分隔;沒有標籤時這一欄留空}
# 第 3 行 body (單獨一個字,當正文的起始標記)
# 第 4 行起 正文原文,一直到檔尾
# 呼叫端取標題用 head -n1 | cut -f2-,取標籤用 sed -n 2p | cut -f2-,取正文用 tail -n +4。
# 同一筆議題要標題、正文又要標籤時一律用 show,不要連打 title、body、labels-of 三次。
# issue.sh create <owner>/<repo> <title> <body-file> [--labels <id>[,<id>]] [--milestone <id>]
# 建立議題。輸出兩行: index=<編號>、url=<議題網址>
#
@@ -21,6 +29,7 @@
# - 正文一律用檔案傳進來,內容維持 UTF-8 與真實換行;不要在參數裡塞 \n。
# - Gitea 1.27 沒有公開的專案看板 API,projects 會 exit 3。呼叫端要據實回報「請手動把議題拖到看板」,
# 不要假裝已經關聯好——關聯不上跟關聯成功看起來一樣,是最容易被當成完成的一種失敗。
# exit 3 的兩條路徑(端點回錯、端點回了但不是清單)都會印出看板網址,呼叫端一定拿得到那條連結。
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
@@ -32,7 +41,7 @@ usage() {
issue.sh labels <owner>/<repo> 列出既有標籤:id<TAB>name
issue.sh label-ids <owner>/<repo> <name>[,<name>] 標籤名換成 id
issue.sh projects <owner>/<repo> 列出專案看板;站台不支援就 exit 3
issue.sh title|body|labels-of <owner>/<repo> <index>
issue.sh title|body|labels-of|show <owner>/<repo> <index>
issue.sh create <owner>/<repo> <title> <body-file> [--labels <id>[,<id>]] [--milestone <id>]
結束碼: 0=成功 1=API 失敗 2=用法錯誤 3=站台不支援 4=名稱對不到 id
EOF
@@ -49,15 +58,30 @@ valid_repo() {
esac
}
board_url() { # 看板頁的人工網址。站台沒有看板 API 時,呼叫端要靠這條連結手動處理
_host="${GITEA_HOST:-}"
case "$_host" in http://*|https://*) : ;; *) _host="https://$_host" ;; esac
printf '%s/%s/projects\n' "${_host%/}" "$repo"
}
cmd="${1:-}"; [ -n "$cmd" ] || usage
shift || true
repo="${1:-}"
valid_repo "$repo" || { echo "[jsc][議題][ERR]:存取庫須為 {owner}/{repo},收到「${repo:-空值}」。" >&2; exit 2; }
shift
# gitea.sh 的輸出一律先接進變數再解析,不要寫成 "$GITEA" api ... | python3。
# 管線的結束狀態取自 python,會把 gitea.sh 的失敗整個吃掉:金鑰失效時,回應是一份
# 錯誤 JSON,python 從裡面挑不到 labels 就印出空清單並回 0,看起來像「這個議題沒有標籤」。
api_get() { # $1=API 路徑 -> 回應內容;失敗回 1
"$GITEA" api GET "$1"
}
case "$cmd" in
labels)
"$GITEA" api GET "/repos/$repo/labels?limit=100" | python3 -c '
out=$(api_get "/repos/$repo/labels?limit=100") \
|| { echo "[jsc][議題][ERR]:讀不到 $repo 的標籤清單。" >&2; exit 1; }
printf '%s' "$out" | python3 -c '
import json,sys
for l in json.load(sys.stdin):
print("%s\t%s" % (l["id"], l["name"]))
@@ -65,7 +89,9 @@ for l in json.load(sys.stdin):
label-ids)
names="${1:-}"; [ -n "$names" ] || usage
"$GITEA" api GET "/repos/$repo/labels?limit=100" | python3 -c '
out=$(api_get "/repos/$repo/labels?limit=100") \
|| { echo "[jsc][議題][ERR]:讀不到 $repo 的標籤清單。" >&2; exit 1; }
printf '%s' "$out" | python3 -c '
import json,sys
want=[n.strip() for n in sys.argv[1].split(",") if n.strip()]
have={l["name"]: l["id"] for l in json.load(sys.stdin)}
@@ -77,7 +103,8 @@ print(",".join(str(have[n]) for n in want))
' "$names" ;;
projects)
if out=$("$GITEA" api GET "/repos/$repo/projects" 2>/dev/null); then
manual=0
if out=$(api_get "/repos/$repo/projects" 2>/dev/null); then
printf '%s' "$out" | python3 -c '
import json,sys
try:
@@ -88,36 +115,65 @@ if not isinstance(d, list):
sys.exit(3)
for p in d:
print("%s\t%s" % (p.get("id",""), p.get("title") or p.get("name","")))
' || exit 3
' || manual=1
else
host="${GITEA_HOST:-}"
case "$host" in http://*|https://*) : ;; *) host="https://$host" ;; esac
echo "[jsc][議題][WARN]:這個 Gitea 站台沒有專案看板 API,程式關聯不了。請開 ${host%/}/$repo/projects 手動把議題拖進看板。" >&2
manual=1
fi
if [ "$manual" = 1 ]; then
# 兩條 exit 3 的路徑都要印出看板網址:呼叫端的技能被要求把這條連結交給使用者,
# 少印一次,使用者就只收到「關聯不上」而不知道要去哪裡手動拖。
echo "[jsc][議題][WARN]:這個 Gitea 站台沒有專案看板 API,程式關聯不了。請開 $(board_url) 手動把議題拖進看板。" >&2
exit 3
fi ;;
title)
idx="${1:-}"; [ -n "$idx" ] || usage
"$GITEA" api GET "/repos/$repo/issues/$idx" | python3 -c '
out=$(api_get "/repos/$repo/issues/$idx") \
|| { echo "[jsc][議題][ERR]:讀不到議題 $repo#$idx。" >&2; exit 1; }
printf '%s' "$out" | python3 -c '
import json,sys
print(json.load(sys.stdin).get("title",""))
' ;;
body)
idx="${1:-}"; [ -n "$idx" ] || usage
"$GITEA" api GET "/repos/$repo/issues/$idx" | python3 -c '
out=$(api_get "/repos/$repo/issues/$idx") \
|| { echo "[jsc][議題][ERR]:讀不到議題 $repo#$idx。" >&2; exit 1; }
printf '%s' "$out" | python3 -c '
import json,sys
sys.stdout.write(json.load(sys.stdin).get("body","") or "")
' ;;
labels-of)
idx="${1:-}"; [ -n "$idx" ] || usage
"$GITEA" api GET "/repos/$repo/issues/$idx" | python3 -c '
out=$(api_get "/repos/$repo/issues/$idx") \
|| { echo "[jsc][議題][ERR]:讀不到議題 $repo#$idx 的標籤。" >&2; exit 1; }
printf '%s' "$out" | python3 -c '
import json,sys
for l in json.load(sys.stdin).get("labels") or []:
print(l.get("name",""))
' ;;
show)
# 標題、標籤、正文都在同一份 API 回應裡。分成 title、body、labels-of 三次呼叫,
# 打的是同一支端點三遍,還可能取到三個不同時間點的版本。
idx="${1:-}"; [ -n "$idx" ] || usage
out=$(api_get "/repos/$repo/issues/$idx") \
|| { echo "[jsc][議題][ERR]:讀不到議題 $repo#$idx。" >&2; exit 1; }
printf '%s' "$out" | python3 -c '
import json,sys
try:
d=json.load(sys.stdin)
except Exception:
sys.exit(1)
if not isinstance(d, dict) or not d.get("title"):
sys.exit(1)
print("title\t%s" % d.get("title"))
print("labels\t%s" % ",".join((l.get("name") or "") for l in (d.get("labels") or [])))
print("body")
sys.stdout.write(d.get("body") or "")
' || { echo "[jsc][議題][ERR]:議題 $repo#$idx 的回應解不出標題。" >&2; exit 1; } ;;
create)
title="${1:-}"; body_file="${2:-}"
[ -n "$title" ] || usage
@@ -150,7 +206,9 @@ if milestone:
with open(out, "w", encoding="utf-8") as f:
json.dump(d, f, ensure_ascii=False)
' "$title" "$body_file" "$labels" "$milestone" "$payload"
"$GITEA" api POST "/repos/$repo/issues" "$payload" | python3 -c '
out=$("$GITEA" api POST "/repos/$repo/issues" "$payload") \
|| { echo "[jsc][議題][ERR]:建不出議題到 $repo。" >&2; exit 1; }
printf '%s' "$out" | python3 -c '
import json,sys
d=json.load(sys.stdin)
print("index=%s" % (d.get("number") or d.get("index") or ""))