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
+177 -31
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
done
} | sort ;;
' >> "$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}"
@@ -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 ""))