#!/usr/bin/env sh # gitea.sh — Gitea API 工具(curl + GITEA_TOKEN)。 # 用法: # gitea.sh owners # 列出可讀取的 owner(自己 + 組織) # gitea.sh repos # 列出 owner 底下可讀取的 repo(全名) # gitea.sh default-branch / # 印出預設分支 # gitea.sh clone-url / # 印出 clone URL # gitea.sh hash-id # 產生 8 碼大寫 SHA-1 hash;首碼為 0-9/A/B/C 時改成 Hxxxxxxx # gitea.sh wiki-repo # 解析頁面類型的 wiki 位置: # TYPE = QUESTION|PLAN|ANALYZE|DELIVER|MAINTAIN|REPO|LOG|LEARN|ERROR(即頁名前綴) # 依序取 JSC_WIKI_REPO_{TYPE} > JSC_WIKI_REPO;不得跨類型代用;都未設定 exit 3 # gitea.sh wiki-list / # 列出 wiki 頁名 # gitea.sh wiki-get / # 印出 wiki 頁 markdown;不存在時 exit 4 # gitea.sh wiki-put / # 建立或更新 wiki 頁(內容取自檔案) # gitea.sh wiki-url / # 印出 wiki 頁絕對網址(取自 API 的 html_url);跨存取庫連結用 # gitea.sh pr-create / <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