Files
gitea/tools/gitea.sh
T
jiantw83andClaude Opus 5 dc17907554 chore(gitea): markdown 渲染子命令與文件、manifest 同步
What:gitea.sh 新增 markdown 子命令與可切換的 Content-Type,README 補上新工具、新技能與 HTML 範本說明,三份 manifest 同步升版到 0.1.3。

Why:markdown 轉 HTML 要交給 Gitea 自己渲染,排版才跟 wiki、議題頁一致;但 /markdown 端點在 Gitea 1.27 回 200 卻是空內容,看起來像成功。

How:改走吃純文字的 /markdown/raw,req 送出的 Content-Type 改由 REQ_CONTENT_TYPE 決定,預設仍是 application/json。代價寫進註解:raw 端點不吃 wiki 情境,[[頁名]] 要由呼叫端先換成絕對網址。

Who:所有透過 jsc-gitea 產生文件的技能。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-26 09:19:25 +08:00

301 lines
13 KiB
Bash
Executable File
Raw 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(即頁名前綴)
# 依序取 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-comments <owner>/<repo> <pr-index> # 印出所有留言(issue 留言、審查評語、行內留言),依時間排序
# 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);兩者皆無才失敗。
set -eu
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
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) ;;
*) 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"
req() { # METHOD path [body-file] -> body(HTTP >= 400 時 exit 4;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\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 GET "/user" | json_field login
req GET "/user/orgs?limit=50" | 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")
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}"
req GET "/repos/$or" | json_field default_branch ;;
clone-url)
or="${1:?owner/repo required}"
req GET "/repos/$or" | json_field clone_url ;;
wiki-list)
or="${1:?owner/repo required}"
req GET "/repos/$or/wiki/pages?limit=200" | json_field title ;;
wiki-get)
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
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
echo "wiki page not found: $page" >&2; exit 4
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}"
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"
if req GET "/repos/$or/wiki/page/$page" >/dev/null 2>&1; then
req PATCH "/repos/$or/wiki/page/$page" "$tmp" >/dev/null
else
req POST "/repos/$or/wiki/new" "$tmp" >/dev/null
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}"
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"
req POST "/repos/$or/pulls" "$tmp" | json_field html_url
rm -f "$tmp" ;;
pr-status)
# 印出「{state} {merged} {mergeable}」,供呼叫端判斷 PR 是否已合併。
or="${1:?owner/repo required}"; idx="${2:?pr index required}"
req GET "/repos/$or/pulls/$idx" | 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-comments)
# 印出 PR 的所有留言,每行「{時間}<TAB>{作者}<TAB>{類型}<TAB>{內容單行化}」。
# 三個來源都要讀:issue 留言、review 本體的評語、review 內逐行的程式碼留言。
# 只讀 issue 留言會漏掉真正的審查意見,那正是需要修正的部分。
or="${1:?owner/repo required}"; idx="${2:?pr index required}"
{ req GET "/repos/$or/issues/$idx/comments?limit=100" | 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留言\t%s" % (c.get("created_at",""), (c.get("user") or {}).get("login","?"), 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):
body=" ".join((r.get("body") or "").split())
st=r.get("state","")
# 沒有評語的審查照樣要印:APPROVED 代表可以合併,REQUEST_CHANGES 代表被要求修改,
# 兩者都是呼叫端要據以決策的事實,過濾掉會看不見 PR 真正的狀態。
print("%s\t%s\t審查(%s)\t%s" % (r.get("submitted_at",""), (r.get("user") or {}).get("login","?"), st, body or "(無評語)"))
'
for rid in $(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"])
'); 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
for c in d:
body=" ".join((c.get("body") or "").split())
if body: print("%s\t%s\t行內(%s:%s)\t%s" % (c.get("created_at",""), (c.get("user") or {}).get("login","?"), c.get("path",""), c.get("original_position") or c.get("position") or "", body))
' || true
done
} | sort ;;
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}"
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:-}"
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)
method="${1:?METHOD}"; path="${2:?path}"; body="${3:-}"
req "$method" "$path" $body ;;
*)
echo "unknown command: $cmd" >&2; exit 2 ;;
esac