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

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

現在失敗成因分開回報:找不到、金鑰失效或權限不足、其他 API 失敗,各給
一個結束碼。每條管線先接進變數,先看結束碼,再解析內容。議題工具的同一
類缺陷一併修掉。wiki 技能也把「只有找不到才可以建新頁」寫成獨立規則,
涵蓋每一條「不存在就建立」的路徑。
2026-08-31 11:11:55 +08:00

518 lines
25 KiB
Bash
Executable File
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env sh
# gitea.sh — Gitea API 工具(curl + GITEA_TOKEN)。
# 用法:
# gitea.sh owners # 列出可讀取的 owner(自己 + 組織)
# gitea.sh repos <owner> # 列出 owner 底下可讀取的 repo(全名)
# gitea.sh default-branch <owner>/<repo> # 印出預設分支
# 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|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
# gitea.sh wiki-put <owner>/<repo> <page> <file> # 建立或更新 wiki 頁(內容取自檔案)
# gitea.sh wiki-url <owner>/<repo> <page> # 印出 wiki 頁絕對網址(取自 API 的 html_url);跨存取庫連結用
# 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>
# 回覆本輪處理過的 PR 留言;inline 走 review comment reply,其餘補一則 PR 留言
# gitea.sh pr-depend <owner>/<repo> <pr-index> <dep-owner>/<dep-repo> <dep-index>
# 把 PR 掛上前置 PR 依賴;依賴未關閉前 Gitea 會阻擋合併
# gitea.sh repo-set <owner>/<repo> <description> [website] # 設定 repo 描述與網頁
# gitea.sh markdown <file> # markdown 檔渲染成 HTML 片段(走 /markdown/raw)
# gitea.sh api <METHOD> <path> [json-file] # 原始 API 呼叫(path 以 /repos/... 起始)
# 環境變數: 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)
confirm_write() {
sh "$script_dir/write-confirm.sh" "$1" "$2"
}
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|TOOLING) ;;
*) echo "unknown wiki type: $type" >&2; exit 2 ;;
esac
eval "v=\${JSC_WIKI_REPO_${type}:-}"
[ -n "$v" ] || v="${JSC_WIKI_REPO:-}"
if [ -n "$v" ]; then printf '%s\n' "$v"; else
echo "no wiki repo configured for $type (set JSC_WIKI_REPO_$type or JSC_WIKI_REPO)" >&2; exit 3
fi
}
cmd="${1:?usage: gitea.sh <command> ...}"; shift
case "$cmd" in
hash-id)
exec "$script_dir/hash-id" "$@" ;;
wiki-repo)
resolve_wiki_repo "${1-}"
exit 0 ;;
esac
tea_token() { # 取 tea CLI 設定檔的 token。$1=host-only 時,只回傳主機相符的登入
# 優先序:url 與 $GITEA_HOST 同主機的登入 > default: true 的登入 > 第一個登入。
# 主機比對必須排最前面:tea 可以同時登入多個站台,只看 default 會把 A 站的
# token 送去 B 站——那是憑證外洩,不是單純的取錯值。
cfg="${HOME}/.config/tea/config.yml"
[ -f "$cfg" ] || return 1
only_host="${1:-}"
want=$(printf '%s' "${GITEA_HOST:-}" | sed 's#^https\{0,1\}://##; s#/.*##')
t=$(awk -v want="$want" -v only_host="$only_host" '
function flush() {
if (tok == "") return
if (first == "") first = tok
if (want != "" && host == want) { match_tok = tok }
if (def) def_tok = tok
}
/^ *- / { flush(); tok=""; host=""; def=0 }
/^ *token: */ { line=$0; sub(/^ *token: */,"",line); gsub(/"/,"",line); tok=line }
/^ *url: */ { line=$0; sub(/^ *url: */,"",line); gsub(/"/,"",line)
sub(/^https?:\/\//,"",line); sub(/\/.*$/,"",line); host=line }
/^ *default: *true/ { def=1 }
END {
flush()
if (match_tok != "") { print match_tok; exit }
if (only_host == "host-only") exit
if (def_tok != "") print def_tok
else print first
}
' "$cfg")
[ -n "$t" ] && printf '%s\n' "$t"
}
: "${GITEA_HOST:?GITEA_HOST is required}"
TEA_TOKEN=$(tea_token 2>/dev/null || true)
TEA_HOST_TOKEN=$(tea_token host-only 2>/dev/null || true)
if [ -n "$TEA_HOST_TOKEN" ]; then
# tea 有這個主機的專用登入就優先用它。$GITEA_TOKEN 是通用變數,未必屬於
# 這次要連的主機;把它送去別的站等於憑證外洩,比取錯值嚴重得多。
GITEA_TOKEN="$TEA_HOST_TOKEN"
elif [ -z "${GITEA_TOKEN:-}" ]; then
if [ -n "$TEA_TOKEN" ]; then GITEA_TOKEN="$TEA_TOKEN"; else
: "${GITEA_TOKEN:?GITEA_TOKEN is required}"
fi
fi
case "$GITEA_HOST" in http://*|https://*) HOST="$GITEA_HOST" ;; *) HOST="https://$GITEA_HOST" ;; esac
API="${HOST%/}/api/v1"
# 最後一次請求的 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:-}"
ctype="${REQ_CONTENT_TYPE:-application/json}"
retried=0
while :; do
if [ -n "$body_file" ]; then
out=$(curl -sS -w '\n%{http_code}' -X "$method" \
-H "Authorization: token $GITEA_TOKEN" -H "Content-Type: $ctype" \
--data-binary "@$body_file" "$API$path")
else
out=$(curl -sS -w '\n%{http_code}' -X "$method" \
-H "Authorization: token $GITEA_TOKEN" "$API$path")
fi
code=$(printf '%s' "$out" | tail -n1)
if [ "$retried" -eq 0 ] && { [ "$code" = 401 ] || [ "$code" = 403 ]; } \
&& [ -n "$TEA_TOKEN" ] && [ "$TEA_TOKEN" != "$GITEA_TOKEN" ]; then
GITEA_TOKEN="$TEA_TOKEN"; retried=1; continue
fi
break
done
printf '%s' "$code" > "$REQ_CODE_FILE"
printf '%s\n' "$out" | sed '$d'
[ "$code" -lt 400 ]
}
json_field() { # stdin JSON -> 每個物件的指定欄位一行(陣列或單一物件皆可)
python3 -c '
import json,sys
d=json.load(sys.stdin); k=sys.argv[1]
items=d if isinstance(d,list) else [d]
for i in items:
v=i.get(k,"")
if v!="" and v is not None: print(v)
' "$1"
}
case "$cmd" in
owners)
# 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
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"
page=$((page+1))
done ;;
default-branch)
or="${1:?owner/repo required}"
out=$(req GET "/repos/$or") || api_fail "讀不到存取庫 $or"
printf '%s' "$out" | json_field default_branch ;;
clone-url)
or="${1:?owner/repo required}"
out=$(req GET "/repos/$or") || api_fail "讀不到存取庫 $or"
printf '%s' "$out" | json_field clone_url ;;
wiki-list)
or="${1:?owner/repo required}"
# 先接變數、先看 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
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
d=json.load(sys.stdin)
sys.stdout.write(base64.b64decode(d.get("content_base64","")).decode("utf-8"))
' ;;
wiki-url)
# 印出 wiki 頁的絕對網址,取自 API 回應的 html_url,不自行組路徑。
# 跨存取庫連結(例如 LOG 頁連到 PLAN 頁,而兩者的 JSC_WIKI_REPO_{TYPE} 不同)
# 只有絕對網址會通:[[頁名]] 與 markdown 相對連結都只在同一個 wiki 內解析。
or="${1:?owner/repo required}"; page="${2:?page required}"
if ! out=$(req GET "/repos/$or/wiki/page/$page" 2>/dev/null); then
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
sys.stdout.write(json.load(sys.stdin).get("html_url") or "")
')
if [ -z "$url" ]; then
echo "wiki page has no html_url: $page" >&2; exit 5
fi
printf '%s\n' "$url" ;;
wiki-put)
or="${1:?owner/repo required}"; page="${2:?page required}"; file="${3:?content file required}"
confirm_write "寫入 wiki 頁" "$page"
tmp=$(mktemp)
python3 -c '
import json,sys,base64
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 \
|| { rm -f "$tmp"; api_fail "更新 wiki 頁 $or/$page 失敗"; }
else
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 '
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"
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}"
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()))
' ;;
pr-get)
# 印出 PR 的標題、base 分支與描述,供呼叫端比對本機資料是否已經跟 PR 不一致。
# 格式固定三段,描述放最後一段,因為只有它會多行:
# 第 1 行 title<TAB>{標題}
# 第 2 行 base<TAB>{base 分支}
# 第 3 行 body (單獨一個字,當描述的起始標記)
# 第 4 行起 描述原文,一直到檔尾
# 呼叫端取標題用 head -n1 | cut -f2-,取 base 用 sed -n 2p | cut -f2-,
# 取描述用 tail -n +4,全程不必解析 JSON。
or="${1:?owner/repo required}"; idx="${2:?pr index required}"
if ! out=$(req GET "/repos/$or/pulls/$idx"); then
printf '%s\n' "$out" >&2; exit 4
fi
printf '%s' "$out" | python3 -c '
import json,sys
p=json.load(sys.stdin)
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 "")
' ;;
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}"
title="${3:?title required}"; body_file="${4:?body file required}"
[ -f "$body_file" ] || { echo "找不到描述檔: $body_file" >&2; exit 2; }
confirm_write "更新 PR" "$or#$idx"
tmp=$(mktemp)
python3 -c '
import json,sys
print(json.dumps({"title":sys.argv[1],"body":open(sys.argv[2],encoding="utf-8").read()}))
' "$title" "$body_file" > "$tmp"
if ! out=$(req PATCH "/repos/$or/pulls/$idx" "$tmp"); then
rm -f "$tmp"; printf '%s\n' "$out" >&2; exit 4
fi
rm -f "$tmp"
echo "OK $or#$idx" ;;
pr-comments)
# 印出 PR 的所有留言,每行「{時間}<TAB>{作者}<TAB>{類型}#{id}<TAB>{內容單行化}」。
# 三個來源都要讀: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}"
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))
'
printf '%s' "$reviews" | python3 -c '
import json,sys
for r in json.load(sys.stdin):
body=" ".join((r.get("body") or "").split())
st=r.get("state","")
# 沒有評語的審查照樣要印:APPROVED 代表可以合併,REQUEST_CHANGES 代表被要求修改,
# 兩者都是呼叫端要據以決策的事實,過濾掉會看不見 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 "(無評語)"))
'
} > "$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
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))
' >> "$lines"
done
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}"
[ -f "$body_file" ] || { echo "找不到回覆檔: $body_file" >&2; exit 2; }
case "$kind" in issue|review|inline) ;; *) echo "kind must be issue, review, or inline" >&2; exit 2 ;; esac
confirm_write "回覆留言" "$or#$idx/$kind#$cid"
tmp=$(mktemp)
python3 -c '
import json,sys
print(json.dumps({"body":open(sys.argv[1],encoding="utf-8").read()}))
' "$body_file" > "$tmp"
case "$kind" in
inline)
path="/repos/$or/pulls/$idx/comments/$cid/replies" ;;
issue|review)
path="/repos/$or/issues/$idx/comments" ;;
esac
if ! out=$(req POST "$path" "$tmp"); then
rm -f "$tmp"; printf '%s\n' "$out" >&2; exit 4
fi
rm -f "$tmp"
printf '%s' "$out" | json_field html_url ;;
pr-depend)
or="${1:?owner/repo required}"; idx="${2:?pr index required}"
dep_or="${3:?dep owner/repo required}"; dep_idx="${4:?dep index required}"
confirm_write "設定 PR 依賴" "$or#$idx -> $dep_or#$dep_idx"
tmp=$(mktemp)
python3 -c '
import json,sys
o,r=sys.argv[1].split("/",1)
print(json.dumps({"owner":o,"repo":r,"index":int(sys.argv[2])}))
' "$dep_or" "$dep_idx" > "$tmp"
if ! out=$(req POST "/repos/$or/issues/$idx/dependencies" "$tmp"); then
rm -f "$tmp"; printf '%s\n' "$out" >&2; exit 4
fi
rm -f "$tmp"
echo "OK $or#$idx depends on $dep_or#$dep_idx" ;;
repo-set)
or="${1:?owner/repo required}"; desc="${2:?description required}"; site="${3:-}"
confirm_write "更新 repo 資訊" "$or"
tmp=$(mktemp)
python3 -c '
import json,sys
b={"description":sys.argv[1]}
if len(sys.argv)>2 and sys.argv[2]: b["website"]=sys.argv[2]
print(json.dumps(b))
' "$desc" "$site" > "$tmp"
if ! out=$(req PATCH "/repos/$or" "$tmp"); then
rm -f "$tmp"; printf '%s\n' "$out" >&2; exit 4
fi
rm -f "$tmp"
echo "OK $or" ;;
markdown)
# markdown 檔 -> HTML 片段。走 /markdown/raw(吃純文字)而不是 /markdown:
# 後者在 Gitea 1.27 回 200 但內容是空的,看起來像成功,其實什麼都沒渲染。
# 代價:raw 端點不吃 context,wiki 的 [[頁名]] 內部連結不會變成連結,
# 呼叫端要先把它換成絕對網址。
file="${1:?markdown file}"
[ -f "$file" ] || { echo "找不到 markdown 檔: $file" >&2; exit 2; }
REQ_CONTENT_TYPE='text/plain'
req POST "/markdown/raw" "$file" || api_fail "markdown 渲染失敗($file)" ;;
api)
method="${1:?METHOD}"; path="${2:?path}"; body="${3:-}"
req "$method" "$path" $body ;;
*)
echo "unknown command: $cmd" >&2; exit 2 ;;
esac