What: - resolve_wiki_repo 的型別白名單與檔頭註解加上 MONITOR,check-wiki-rules.sh 的 TYPES 一併加入。 - README 的兩處型別列舉加上 MONITOR,環境變數表補一列 JSC_WIKI_REPO_MONITOR。 - wiki 技能的 allowed types 與行為清單的呼叫端補上這個型別與它的擁有者。 - 三份 manifest 的版本一起提升。 Why: - 技能助理要把巡檢結果寫進 wiki,落點就是監控頁。型別不在白名單裡,wiki-repo 會直接回「unknown wiki type」而拒絕解析,助理連寫都寫不出去。 - 型別串散在五個檔案,只改一處會讓解析通過但檢核工具漏掉,或反過來。所以一次改齊。 How: - MONITOR 放在型別串尾端,接在 TOOLING 之後。既有順序是依用途分群,不是字母序,所以不重排。 - 環境變數的規則完全沿用既有型別:先讀 JSC_WIKI_REPO_MONITOR,再退回 JSC_WIKI_REPO,不得跨型別代用。這一條由 resolve_wiki_repo 統一處理,新型別自動繼承,不必另寫分支。 - 雜湊來源的規則不寫在這個存放庫。README 已載明雜湊規則的唯一來源是技能準則的命名總表,這裡不複述。 - wiki 技能的 description 原本逐一列舉呼叫端,已經接近長度上限。這次改成括號標注頁型的濃縮寫法,加了一個呼叫端之後整行反而變短,句數維持在上限內。 Who: 技能助理落地帶出來的頁型別需求,四個存放庫同一批改。
518 lines
25 KiB
Bash
Executable File
518 lines
25 KiB
Bash
Executable File
#!/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|MONITOR(即頁名前綴)
|
||
# 依序取 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|MONITOR) ;;
|
||
*) 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
|