Files
ISEP/scripts/wiki-panorama.sh
T
Leo daa84b583f ISEP#47: source Gitea token from env/.env, not remote URL (9 scripts + credential helper)
- 6 hooks + gitea-labels-sync/wiki-panorama/gitea-bootstrap 全改用 gitea_token()
- wiki-panorama clone/fetch 改乾淨 URL + credential helper
- gitea-bootstrap 冪等清 remote URL + 設 credential.helper
- 驗證:bootstrap/labels-sync API 認證通過、clone/push 經 helper 無 token 洩漏

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Kxf68FcYGqiuLAPiKKU2U3
2026-09-18 12:36:30 +00:00

357 lines
17 KiB
Bash
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.
#!/bin/bash
# wiki-panorama.sh — 產生「各 repo 的 wiki 有哪些檔」的全景 index`system-dev/wiki/PANORAMA.md`
#
# 解的問題(leo 2026-08-12):
# 「我在 AR-Mira 看到所有 Gitea Repo 的 wiki,又可以看到所有票現況,
# 有一個總圖用 md 一次看到全景。」
# 開場總圖(Arcrun 工作流 `global_index`)的「票」那段已經好用,
# 但「知識」那段接的是 KBDB 藏書地圖,而那張表 10 個庫有 8 個是空的(Leo/Arcrun#87 在修)
# ⇒ **各 repo 真正的 wikisystem-dev/wiki/*.md)一份都沒進總圖。** 這支補的就是那一半。
#
# 為什麼是本機腳本、不是加進雲端的 `global_index` 工作流:
# 工作流跑在 Cloudflare 上,拿 repo 樹只有一條路=Gitea 的 contents API 列檔,
# 而 principles 紅線寫死「**讀 repo 走 git clone/fetch,不走 API 列檔**」。
# ⇒ 這件事的正解是在「有 git 的地方」算,算完存成 repo 裡的一份 md,開場 hook 直接印。
# 票留在雲端現算(狀態必須即時),知識走這條(可以稍舊,且 D71 已寫明兩者不對稱)。
#
# 產出**一份檔、兩個用途**(中間用 `<!-- panorama:inject-end -->` 切開):
# ① 標記以上=**開場注入**的摘要(hook 只印到這裡)。控制在幾 kB,不撐爆 context。
# ② 標記以下=**給 grep 用的完整清單**(每個 repo 每一張卡的名字)。
# 「某件事有沒有記過」就 `grep -i <關鍵字> system-dev/wiki/PANORAMA.md`。
#
# 紅線:
# - **不輪詢**。這支只在人/本機發起時跑(改完 wiki 順手跑一次、或開場 hook 提醒你過期了)。
# - **只讀 git**clone/fetchblobless + sparse,只抓 wiki 目錄。不打任何列檔 API。
# - **只產 index 不搬內容**:一檔一行(檔名+最後更新日+大小+一句話),不倒全文。
#
# 用法:
# bash scripts/wiki-panorama.sh # 算出來印到 stdout(不寫檔)
# bash scripts/wiki-panorama.sh --write # 同時寫進 system-dev/wiki/PANORAMA.md
# bash scripts/wiki-panorama.sh --no-fetch # 用快取算,完全不碰網路
# bash scripts/wiki-panorama.sh --only mira # 只處理某幾個 repo(除錯用)
#
# 快取:$WIKI_PANORAMA_CACHE,預設 ~/.cache/inkstone-wiki-panorama(不落在 repo 裡)
#
# 配套:開場注入要靠 `.claude/hooks/session-start-recall.sh` 的 push 2/5。
# 還沒套的話跑:`git apply scripts/patches/session-start-recall--wiki-panorama.patch`
set -euo pipefail
ROOT=$(git rev-parse --show-toplevel)
ROSTER="$ROOT/system-dev/wiki/.panorama-repos.txt"
OUT="$ROOT/system-dev/wiki/PANORAMA.md"
CACHE="${WIKI_PANORAMA_CACHE:-$HOME/.cache/inkstone-wiki-panorama}"
WIKI_DIRS="system-dev/wiki .claude/wiki" # 第二個是舊慣例,順手收
GITEA_TOTAL_REPOS=24 # 總管 2026-08-12 用 Gitea API 實查的總數
GITEA_TOTAL_ASOF=2026-08-12
DO_WRITE=0
DO_FETCH=1
ONLY=""
while [ $# -gt 0 ]; do
case "$1" in
--write) DO_WRITE=1 ;;
--no-fetch) DO_FETCH=0 ;;
--only) shift; ONLY="${ONLY} $1" ;;
-h|--help) sed -n '1,40p' "$0"; exit 0 ;;
*) echo "不認得的參數:$1" >&2; exit 2 ;;
esac
shift
done
[ -f "$ROSTER" ] || { echo "找不到 roster$ROSTER" >&2; exit 1; }
# ── Gitea base(乾淨網址;token 來源=env.env,不從 URL 抽——inkstone/ISEP#47)──────────
_GT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
[ -f "$_GT_DIR/lib/gitea-token.sh" ] && . "$_GT_DIR/lib/gitea-token.sh"
REMOTE=$(git -C "$ROOT" remote get-url gitea 2>/dev/null || git -C "$ROOT" remote get-url origin 2>/dev/null || true)
[ -n "$REMOTE" ] || { echo "本 repo 沒有 gitea/origin remote,無法取 repo。" >&2; exit 1; }
# 去掉網址裡可能還嵌著的舊憑證(遷移期相容)→ 乾淨 baseclone/fetch 靠 credential helper 認證
REMOTE=$(printf '%s' "$REMOTE" | sed -E 's#//[^/@]*@#//#')
BASE=${REMOTE%/InkStoneCo.git}
BASE=${BASE%/InkStoneCo}
SELF=$(basename "$REMOTE" .git)
# scrub 用的 SECRETtoken 從 env.env 取(不從 URL 抽),用來遮掉萬一夾帶在錯誤訊息裡的 token
SECRET=""; command -v gitea_token >/dev/null 2>&1 && SECRET=$(gitea_token || true)
scrub() { if [ -n "$SECRET" ]; then sed "s|$SECRET|***|g"; else cat; fi; }
# clone/fetch 的認證:乾淨 URL + credential helpertoken 不進 URL、不洩進 log
GIT_CRED=""; [ -f "$_GT_DIR/git-credential-gitea.sh" ] && GIT_CRED="-c credential.helper=$_GT_DIR/git-credential-gitea.sh"
mkdir -p "$CACHE"
# 自己這個 repo 一律讀工作副本 ⇒ 快取裡若留著一份舊的自己,是誤導來源(會被人拿去讀)
if [ -d "$CACHE/$SELF" ]; then rm -rf "$CACHE/$SELF"; fi
REPOS=$(grep -vE '^[[:space:]]*(#|$)' "$ROSTER" | tr -d '\r')
if [ -n "$ONLY" ]; then
REPOS=$(printf '%s\n' "$REPOS" | grep -Fx -f <(printf '%s\n' "$ONLY" | tr ' ' '\n' | grep -v '^$'))
fi
# Gitea 網址大小寫不敏感 ⇒ 同一個 repo 用兩種拼法會被算成兩個。去重。
REPOS=$(printf '%s\n' "$REPOS" | awk '{k=tolower($0)} !seen[k]++')
STATUS_TSV=$(mktemp)
trap 'rm -f "$STATUS_TSV"' EXIT
for repo in $REPOS; do
# 自己這個 repo 讀工作副本,不繞一圈回 Gitea——
# 剛寫完還沒推的 wiki 也要看得見(人就站在這裡改)
if [ "$repo" = "$SELF" ]; then
printf '%s\tok\t%s\n' "$repo" "$ROOT" >> "$STATUS_TSV"; continue
fi
dst="$CACHE/$repo"
if [ -d "$dst/.git" ]; then
if [ "$DO_FETCH" = 1 ]; then
br=$(git -C "$dst" symbolic-ref --short HEAD 2>/dev/null || echo main)
git $GIT_CRED -C "$dst" fetch --quiet origin "$br" 2>&1 | scrub >&2 || true
git -C "$dst" reset --quiet --hard FETCH_HEAD 2>/dev/null || true
fi
else
if [ "$DO_FETCH" = 0 ]; then
printf '%s\tno-cache\t\n' "$repo" >> "$STATUS_TSV"; continue
fi
# blobless + sparse:只下載 wiki 目錄的內容,但保留完整 commit 歷史
#(要歷史才算得出「每個檔最後更新是哪天」——淺 clone 會讓所有檔同一天)
if ! err=$(git $GIT_CRED clone --quiet --filter=blob:none --sparse "$BASE/$repo.git" "$dst" 2>&1 | scrub); then
printf '%s\tclone-failed\t%s\n' "$repo" "$(printf '%s' "$err" | tr '\n' ' ')" >> "$STATUS_TSV"
rm -rf "$dst"; continue
fi
git -C "$dst" sparse-checkout set $WIKI_DIRS >/dev/null 2>&1 || true
fi
printf '%s\tok\t%s\n' "$repo" "$dst" >> "$STATUS_TSV"
done
# ── 掃檔 + 排版(python3:BSD/GNU 工具差異多,交給它比較穩)────────────
MD=$(WIKI_DIRS="$WIKI_DIRS" SELF="$SELF" CACHE="$CACHE" \
TOTAL="$GITEA_TOTAL_REPOS" ASOF="$GITEA_TOTAL_ASOF" \
python3 - "$STATUS_TSV" <<'PY'
import os, re, subprocess, sys, datetime
status_tsv = sys.argv[1]
wiki_dirs = os.environ["WIKI_DIRS"].split()
SELF = os.environ["SELF"]
CACHE = os.environ["CACHE"].replace(os.path.expanduser("~"), "~")
TOTAL = int(os.environ["TOTAL"])
ASOF = os.environ["ASOF"]
OUT_NAME = "PANORAMA.md" # 產生物自己不列進全景
# 每個 repo 裝 system-dev-template 就會有的骨架檔。全部都有 ⇒ 逐一列出來只是雜訊,
# 對其他 repo 只報「新鮮度 + 最肥的那份 + 多出來的非骨架檔」。
SKELETON = {"INDEX", "TAXONOMY", "decisions-summary", "mistakes", "principles", "status"}
def run(cwd, *args):
try:
return subprocess.run(args, cwd=cwd, capture_output=True, text=True, timeout=60).stdout
except Exception:
return ""
def dates_for(repo_dir, wdir):
"""一次 git log 走完整個目錄,取每個檔第一次出現(=最後一次被改)的日期。"""
out = run(repo_dir, "git", "log", "--date=short", "--format=@%ad", "--name-only", "--", wdir)
seen, cur = {}, None
for line in out.splitlines():
if line.startswith("@"):
cur = line[1:].strip()
elif line.strip() and cur:
seen.setdefault(line.strip(), cur)
return seen
WIDE = re.compile(r'[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]')
def width(s): return sum(2 if WIDE.match(c) else 1 for c in s)
def clip(s, w):
out, acc = "", 0
for c in s:
cw = 2 if WIDE.match(c) else 1
if acc + cw > w: return out.rstrip(" 、,·-—") + "…"
out, acc = out + c, acc + cw
return out
def clean(s):
s = re.sub(r'<!--.*?-->', '', s)
s = re.sub(r'\[\[([^\]]+)\]\]', r'\1', s)
s = re.sub(r'\[([^\]]*)\]\([^)]*\)', r'\1', s)
s = s.replace('**', '').replace('`', '').replace('~~', '')
s = re.sub(r'^[>#\-\*\s]+', '', s)
return re.sub(r'\s+', ' ', s).strip()
SKIP = ('---', '===', '|', '```', '<!--', '<')
def title_and_hook(path):
try:
text = open(path, encoding="utf-8", errors="replace").read(9000)
except OSError:
return "", ""
lines = text.splitlines()
title, rest = "", lines
for i, ln in enumerate(lines):
if ln.startswith("# "):
title, rest = clean(ln), lines[i+1:]
break
hook = ""
for ln in rest[:60]:
s = ln.strip()
if not s or s.startswith(SKIP) or s.startswith("#"):
continue
c = clean(s)
if len(c) < 4:
continue
# hook 跟標題講同一件事就不重複佔位,往下找一句真的有增量的
if title and (c[:10] in title or title[:10] in c):
continue
hook = c
break
return title, hook
def one_line(title, hook, fallback):
"""一檔一行的那句話:標題優先,標題太短就補一句 hook。"""
t = re.sub(r'^[\W_]*', '', title or "") or fallback
if hook and width(t) < 46:
t = t + "" + hook
return clip(t, 78)
# ── 掃 ────────────────────────────────────────────────────────────────
repos, rows, cards, misses = [], {}, {}, []
for line in open(status_tsv, encoding="utf-8"):
parts = (line.rstrip("\n") + "\t\t").split("\t")
repo, st, dirpath = parts[0], parts[1], parts[2]
if not repo:
continue
repos.append(repo)
if st != "ok":
misses.append((repo, {"clone-failed": "clone 失敗",
"no-cache": "沒有快取且指定了 --no-fetch"}.get(st, st)))
continue
found = False
for wdir in wiki_dirs:
full = os.path.join(dirpath, wdir)
if not os.path.isdir(full):
continue
dmap = dates_for(dirpath, wdir)
for name in sorted(os.listdir(full)):
p = os.path.join(full, name)
if not (name.endswith(".md") and os.path.isfile(p)) or name == OUT_NAME:
continue
rel = f"{wdir}/{name}"
t, h = title_and_hook(p)
rows.setdefault(repo, []).append(
dict(rel=rel, name=name, stem=name[:-3], date=dmap.get(rel, "?"),
size=os.path.getsize(p), line=one_line(t, h, name[:-3])))
found = True
cdir = os.path.join(full, "cards")
if os.path.isdir(cdir):
for bucket in sorted(os.listdir(cdir)):
bp = os.path.join(cdir, bucket)
if not os.path.isdir(bp):
continue
for n in sorted(os.listdir(bp)):
if n.endswith(".md") and not n.startswith("00-INDEX"):
cards.setdefault(repo, []).append(n[:-3]); found = True
if not found:
misses.append((repo, "沒有 wiki"))
def kb_of(n): return max(1, round(n / 1024))
def newest(rs): return max([r["date"] for r in rs if r["date"] != "?"] or ["?"])
today = datetime.date.today().isoformat()
n_files = sum(len(v) for v in rows.values())
n_cards = sum(len(v) for v in cards.values())
have = [r for r in repos if r in rows or r in cards]
nowiki = [r for r, why in misses if why == "沒有 wiki"]
broken = [(r, why) for r, why in misses if why != "沒有 wiki"]
o = []; w = o.append
# ── 標記以上:開場注入的那一段 ────────────────────────────────────────
w("# 全景:各 repo 的 wiki 裡有哪些檔(index,不是內容)")
w("")
w(f"> **{today} 產生**`bash scripts/wiki-panorama.sh --write`,人發起、不輪詢) · "
f"來源=`git clone/fetch` Gitea `Leo/*` 各 repo 的預設分支,不走 API 列檔。")
w(f"> {SELF} 這段讀的是**本機工作副本**(剛寫完還沒推的也算數)。**改這個檔沒用**,要改去改那個 repo 的 wiki。")
w(">")
w("> 🔴 **這裡沒有內容,只有「有這件事、在哪個 repo 的哪個檔」。**")
w(f"> - 「某件事 wiki 記過沒有」→ `grep -i <關鍵字> system-dev/wiki/{OUT_NAME}`"
f"(本檔下半部有全部 {n_cards} 張卡的名字)")
w(f"> - 要讀內容 → 本機有那個 repo 就直接讀;沒有就看快取 `{CACHE}/<repo>/system-dev/wiki/`")
w("")
w(f"**{n_files} 份主檔 {n_cards} 張卡,散在 {len(have)} 個 repo**"
f"(點名 {len(repos)} 個 repo,其中 {len(nowiki)} 個掃過確定沒有 wiki")
w("")
# 自己這個 repo:逐檔一行(這是你最可能真的去讀的那份)
if SELF in rows:
rs = rows[SELF]
head = f"## {SELF}(你現在站的地方)— {len(rs)} 份主檔"
if cards.get(SELF): head += f"、{len(cards[SELF])} 張卡"
w(head)
for r in rs:
w(f"- `{r['rel']}` · {r['date']} · {kb_of(r['size'])}kB — {r['line']}")
if cards.get(SELF):
w(f"- `system-dev/wiki/cards/` {len(cards[SELF])} 張:" + "、".join(sorted(cards[SELF])))
w("")
# 其他 repo:一 repo 一行。骨架六檔每個 repo 都有,逐一列是雜訊——
# 只報「新鮮度 + 最肥的那份 + 多出來的非骨架檔 + 卡數」。
others = [r for r in have if r != SELF]
if others:
w("## 其他 repo — 一 repo 一行")
w("")
w("> 每個 repo 都有 `INDEX``TAXONOMY``decisions-summary``mistakes``principles``status` "
"這套骨架(裝 system-dev-template 就有),所以只標**新鮮度、最肥的那份、多出來的檔**。")
for repo in others:
rs = rows.get(repo, [])
extra = [r["name"] for r in rs if r["stem"] not in SKELETON]
big = max(rs, key=lambda r: r["size"]) if rs else None
seg = [f"**{repo}** — {len(rs)} 份"]
if cards.get(repo): seg.append(f"{len(cards[repo])} 張卡")
if rs: seg.append(f"最近改 {newest(rs)}")
if big: seg.append(f"最肥 `{big['name']}` {kb_of(big['size'])}kB")
if extra: seg.append("多出來的:" + "、".join(f"`{e}`" for e in extra))
w("- " + " · ".join(seg))
w("")
w("## 涵蓋範圍——**沒掃到的也要看得見**")
w("")
if nowiki:
w(f"- **掃過、確定沒有 wiki 的 {len(nowiki)} 個**" + "、".join(f"`{r}`" for r in nowiki))
if broken:
w("- ⚠️ **這次沒抓到的**" + "、".join(f"`{r}`{why}" for r, why in broken))
w(f"- 名單=`system-dev/wiki/.panorama-repos.txt`{len(repos)} 個,逐一 `git ls-remote` 驗過存在)。")
gap = TOTAL - len(repos)
if gap > 0:
w(f"- ⚠️ **本圖點不到「名單上沒有的 repo」**:git 只能驗名字、不能枚舉。"
f"Gitea `Leo/*` 在 {ASOF} 實查是 **{TOTAL} 個**,名單 {len(repos)} 個 ⇒ **還有 {gap} 個沒被點名**。")
w(" 補法(人發起,一次呼叫,不排程)——拿到完整清單、把缺的名字加進名單再重跑:")
w(" ```")
w(" TOKEN=$GITEA_TOKEN_CLAUDE_CODE # 來源=env.env,不從 remote URL 抽(inkstone/ISEP#47")
w(" curl -s -H \"Authorization: token $TOKEN\" \\")
w(" 'https://git.uncle6.me/api/v1/orgs/Leo/repos?limit=100' | python3 -c \\")
w(" 'import sys,json;[print(r[\"name\"]) for r in json.load(sys.stdin)]'")
w(" ```")
w("")
# ── 標記以下:不進注入,給 grep ────────────────────────────────────────
w("<!-- panorama:inject-end —— 開場注入只印到這一行為止。以下是給 grep 的完整清單 -->")
w("")
w("## 完整清單(不進開場注入,給 `grep` 用)")
w("")
for repo in have:
rs = rows.get(repo, [])
if repo != SELF and rs:
w(f"### {repo} — 主檔")
for r in rs:
w(f"- `{r['rel']}` · {r['date']} · {kb_of(r['size'])}kB — {r['line']}")
w("")
if cards.get(repo):
w(f"### {repo} — cards{len(cards[repo])} 張)")
for c in sorted(cards[repo]):
w(f"- {c}")
w("")
print("\n".join(o))
PY
)
if [ "$DO_WRITE" = 1 ]; then
printf '%s\n' "$MD" > "$OUT"
TOTAL_B=$(printf '%s\n' "$MD" | wc -c | tr -d ' ')
INJ_B=$(printf '%s\n' "$MD" | awk '/panorama:inject-end/{exit} {print}' | wc -c | tr -d ' ')
echo "已寫入 $OUT(全檔 ${TOTAL_B} bytes,其中開場注入的那段 ${INJ_B} bytes" >&2
else
printf '%s\n' "$MD"
fi