From f8123e829b61995e3c10cfbed16b41ceb17f6700 Mon Sep 17 00:00:00 2001 From: Jeffery Date: Wed, 26 Aug 2026 09:19:08 +0800 Subject: [PATCH] =?UTF-8?q?feat(gitea):=20=E6=96=B0=E5=A2=9E=20wiki=20?= =?UTF-8?q?=E8=BD=89=E8=AD=B0=E9=A1=8C=E6=8A=80=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit What:新增 wiki-to-issue 技能,把一頁 wiki 轉成同一個存取庫的議題;配套新增 tools/gitea-link.sh 解析連結、tools/issue.sh 讀寫議題。 Why:wiki 頁要變成可追蹤的工作時,原本得自己拼 API:組 JSON、查標籤 id、挑回應欄位,跳脫一錯就把對外的議題內容寫壞。 How:連結是唯一入口,gitea-link.sh 解不出 wiki 頁就中止,不猜存取庫也不猜頁名;標籤只從既有的挑;站台沒有專案看板 API 時據實回報請使用者手動拖,不假裝關聯成功。 Who:使用 jsc 技能組、需要把 wiki 內容交辦出去的人。 Co-Authored-By: Claude Opus 5 --- skills/wiki-to-issue/SKILL.md | 23 +++++ tools/gitea-link.sh | 77 +++++++++++++++++ tools/issue.sh | 157 ++++++++++++++++++++++++++++++++++ 3 files changed, 257 insertions(+) create mode 100644 skills/wiki-to-issue/SKILL.md create mode 100755 tools/gitea-link.sh create mode 100755 tools/issue.sh diff --git a/skills/wiki-to-issue/SKILL.md b/skills/wiki-to-issue/SKILL.md new file mode 100644 index 0000000..24dc6b0 --- /dev/null +++ b/skills/wiki-to-issue/SKILL.md @@ -0,0 +1,23 @@ +--- +name: wiki-to-issue +description: Turn one Gitea wiki page into an issue in the same repository. Parse the link with tools/gitea-link.sh, read the page through jsc-gitea:wiki, draft title and body as a sub agent, then create the issue with tools/issue.sh - labels picked from the repository's existing set per the jsc-ask decision tree, project board attached or reported as manual when the site has no board API. A request without a wiki link stops the skill immediately: never guess the repository or the page. Use when a wiki page has to become trackable work; not for issues drafted from scratch, and not for syncing an issue that already exists. +--- + +# wiki-to-issue — a wiki page becomes an issue + +The wiki link is the only input. Everything else — repository, page name, host — comes out of that link. + +## Steps + +1. **Link gate.** Run `tools/gitea-link.sh parse {url}` on the link the user gave. Exit 3, no link in the request, or `kind=issue` (this skill reads wiki pages, not issues) all mean the same thing: **stop and report which one it was**. Never ask for a repository name instead, and never fall back to the working directory's remote — a page written into the wrong repository's issue tracker is public and hard to take back. Completion condition: `kind=wiki`, and `repo`, `page` and `host` are known. +2. Read the page with `jsc-gitea:wiki` (`wiki-get {repo} {page}`). Exit 4 means the page does not exist — stop and report the page name. Take the page's absolute URL from `wiki-url` in the same pass; it goes into the issue body. Completion condition: the page's markdown and its absolute URL are both in hand. +3. **Draft the issue — this step MUST run as a sub agent.** Title: the page's first heading, or the page name when it has none. Body: the page content in Traditional Chinese, opening with a 「來源:{絕對網址}」 line so the issue points back at the wiki. Convert `[[display|page]]` links to absolute URLs (`wiki-url`), because `[[...]]` resolves only inside a wiki. Drop personal data — an issue is read by more people than a wiki page. Completion condition: title and body file exist, the body carries the source line, and no `[[...]]` link is left in it. +4. **Labels come from what the repository already has.** Run `tools/issue.sh labels {repo}`, propose the fitting ones with a reason each, and confirm per `jsc-ask:ask` rules — every option states its impact scope (a label drives filters and board rules, so a wrong one routes the work to the wrong queue). Turn the confirmed names into ids with `tools/issue.sh label-ids`. An empty label list, or nothing fitting: ask whether to create the issue with no label, and record that answer. **Never invent a label that the repository does not have.** Completion condition: the user has confirmed a label set — possibly empty — and its ids are resolved. +5. **Project board.** Run `tools/issue.sh projects {repo}`. Exit 3 means this Gitea has no board API: say so plainly, and hand the user the board URL the script printed so they can drag the issue in themselves. A board list comes back: let the user pick one per `jsc-ask:ask` rules, attach it, and report the failure verbatim if the attach call is refused. Completion condition: the issue is either attached to a board, or the report states in one line that the board link is still outstanding and who has to do it. +6. Create the issue: `tools/issue.sh create {repo} {title} {body-file} [--labels {ids}]`. Report the `index=` and `url=` it prints. Completion condition: the issue URL is reported to the user, together with the labels applied and the board status from step 5. + +## Rules + +- One wiki page, one issue. Splitting a page into several issues is analysis work, not conversion — hand that to `jsc-sdlc:analyze`. +- The issue body stays Traditional Chinese per the STE100 rule, and keeps the source line at the top. +- Creating an issue is an outward-facing action: the title, body, labels and board pick are confirmed with the user before the create call, never after. diff --git a/tools/gitea-link.sh b/tools/gitea-link.sh new file mode 100755 index 0000000..c5e0cc4 --- /dev/null +++ b/tools/gitea-link.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env sh +# gitea-link.sh — 解析 Gitea 的 wiki 頁或議題連結(供 jsc-gitea:wiki-to-issue、html-export 使用)。 +# +# 為什麼要有這支腳本:兩支技能都以「一條連結」為唯一入口,沒有連結就中斷。連結長什麼樣、 +# 哪一段是存取庫、哪一段是頁名,是固定的字串規則,交給模型每次自己拆,拆錯就寫到別的存取庫去。 +# +# 用法: +# gitea-link.sh parse 解析連結,印出可直接 eval 的欄位 +# +# parse 輸出(每行一個 key=value,值已加單引號): +# kind='wiki' repo='owner/repo' page='PAGE_NAME' host='https://gitea.example' +# kind='issue' repo='owner/repo' index='12' host='https://gitea.example' +# +# 認得的形式: +# https://host/{owner}/{repo}/wiki/{page} (page 可含 %XX 編碼,會還原) +# https://host/{owner}/{repo}/wiki/{page}/_edit (尾巴的 _edit、_new 會去掉) +# https://host/{owner}/{repo}/issues/{index} +# https://host/{owner}/{repo}/issues/{index}#issuecomment-123 +# +# 結束碼: 0=解析成功 2=用法錯誤 3=不是認得的 wiki 或議題連結 +# +# 陷阱: +# - 結束碼 3 由呼叫端當成「沒有連結」處理,直接中斷流程,不要退回去猜存取庫或頁名。 +# - 只解析連結本身,不打網路,也不檢查頁面存不存在;那是 wiki-get 與 issue.sh 的事。 +set -eu + +usage() { + echo 'usage: gitea-link.sh parse ' >&2 + exit 2 +} + +[ "$#" -eq 2 ] || usage +[ "$1" = parse ] || usage +url="$2" +[ -n "$url" ] || usage + +python3 - "$url" <<'PY' +import re, sys +from urllib.parse import urlsplit, unquote + +url = sys.argv[1].strip() +if not re.match(r'^https?://', url): + print('[jsc][連結解析][ERR]:連結要以 http:// 或 https:// 開頭。', file=sys.stderr) + sys.exit(3) + +u = urlsplit(url) +host = f'{u.scheme}://{u.netloc}' +parts = [p for p in u.path.split('/') if p] +if len(parts) < 4: + print('[jsc][連結解析][ERR]:認不出存取庫與頁面,需要 /{owner}/{repo}/wiki/{page} 或 /{owner}/{repo}/issues/{index}。', file=sys.stderr) + sys.exit(3) + +owner, repo, kind = parts[0], parts[1], parts[2] +rest = parts[3:] + +def out(**kv): + for k, v in kv.items(): + print(f"{k}='{str(v)}'") + +if kind == 'wiki': + while rest and rest[-1] in ('_edit', '_new', '_pages'): + rest.pop() + if not rest: + print('[jsc][連結解析][ERR]:wiki 連結少了頁名。', file=sys.stderr) + sys.exit(3) + page = unquote('/'.join(rest)) + out(kind='wiki', repo=f'{owner}/{repo}', page=page, host=host) +elif kind == 'issues': + idx = rest[0].split('#')[0] + if not idx.isdigit(): + print('[jsc][連結解析][ERR]:議題編號不是數字。', file=sys.stderr) + sys.exit(3) + out(kind='issue', repo=f'{owner}/{repo}', index=idx, host=host) +else: + print(f'[jsc][連結解析][ERR]:只認得 wiki 與 issues 連結,收到「{kind}」。', file=sys.stderr) + sys.exit(3) +PY diff --git a/tools/issue.sh b/tools/issue.sh new file mode 100755 index 0000000..2fab5cd --- /dev/null +++ b/tools/issue.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env sh +# issue.sh — Gitea 議題的讀取與建立(供 jsc-gitea:wiki-to-issue、html-export 使用)。 +# +# 為什麼要有這支腳本:建議題要組 JSON、要把標籤名換成標籤 id、要把回應裡的編號與網址挑出來, +# 全是固定的輸入輸出。交給模型每次自己拼 curl,跳脫字元一錯就把議題內容寫壞,而議題是對外的。 +# +# 用法: +# issue.sh labels / 列出既有標籤:idname +# issue.sh label-ids / [,] 標籤名換成 id(逗號分隔);有名字對不到就 exit 4 +# issue.sh projects / 列出專案看板:idtitle;站台沒有這個 API 就 exit 3 +# issue.sh title / 印出議題標題 +# issue.sh body / 印出議題正文(markdown) +# issue.sh labels-of / 印出議題目前的標籤名,一行一個 +# issue.sh create / <body-file> [--labels <id>[,<id>]] [--milestone <id>] +# 建立議題。輸出兩行: index=<編號>、url=<議題網址> +# +# 環境變數: 同 gitea.sh(GITEA_HOST、GITEA_TOKEN;token 缺或被拒時退回 tea 登入金鑰)。 +# 結束碼: 0=成功 1=API 失敗 2=用法錯誤 3=站台不支援該 API 4=名稱對不到 id +# +# 陷阱: +# - 正文一律用檔案傳進來,內容維持 UTF-8 與真實換行;不要在參數裡塞 \n。 +# - Gitea 1.27 沒有公開的專案看板 API,projects 會 exit 3。呼叫端要據實回報「請手動把議題拖到看板」, +# 不要假裝已經關聯好——關聯不上跟關聯成功看起來一樣,是最容易被當成完成的一種失敗。 +set -eu + +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +GITEA="$script_dir/gitea.sh" + +usage() { + cat >&2 <<'EOF' +用法: + 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 create <owner>/<repo> <title> <body-file> [--labels <id>[,<id>]] [--milestone <id>] +結束碼: 0=成功 1=API 失敗 2=用法錯誤 3=站台不支援 4=名稱對不到 id +EOF + exit 2 +} + +[ -f "$GITEA" ] || { echo "[jsc][議題][ERR]:找不到 $GITEA。" >&2; exit 1; } + +valid_repo() { + case "${1:-}" in + */*/*|/*|*/) return 1 ;; + */*) return 0 ;; + *) return 1 ;; + esac +} + +cmd="${1:-}"; [ -n "$cmd" ] || usage +shift || true +repo="${1:-}" +valid_repo "$repo" || { echo "[jsc][議題][ERR]:存取庫須為 {owner}/{repo},收到「${repo:-空值}」。" >&2; exit 2; } +shift + +case "$cmd" in + labels) + "$GITEA" api GET "/repos/$repo/labels?limit=100" | python3 -c ' +import json,sys +for l in json.load(sys.stdin): + print("%s\t%s" % (l["id"], l["name"])) +' ;; + + label-ids) + names="${1:-}"; [ -n "$names" ] || usage + "$GITEA" api GET "/repos/$repo/labels?limit=100" | 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)} +miss=[n for n in want if n not in have] +if miss: + sys.stderr.write("[jsc][議題][ERR]:這些標籤在存取庫裡找不到:" + "、".join(miss) + "\n") + sys.exit(4) +print(",".join(str(have[n]) for n in want)) +' "$names" ;; + + projects) + if out=$("$GITEA" api GET "/repos/$repo/projects" 2>/dev/null); then + printf '%s' "$out" | python3 -c ' +import json,sys +try: + d=json.load(sys.stdin) +except Exception: + sys.exit(3) +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 + else + host="${GITEA_HOST:-}" + case "$host" in http://*|https://*) : ;; *) host="https://$host" ;; esac + echo "[jsc][議題][WARN]:這個 Gitea 站台沒有專案看板 API,程式關聯不了。請開 ${host%/}/$repo/projects 手動把議題拖進看板。" >&2 + exit 3 + fi ;; + + title) + idx="${1:-}"; [ -n "$idx" ] || usage + "$GITEA" api GET "/repos/$repo/issues/$idx" | 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 ' +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 ' +import json,sys +for l in json.load(sys.stdin).get("labels") or []: + print(l.get("name","")) +' ;; + + create) + title="${1:-}"; body_file="${2:-}" + [ -n "$title" ] || usage + [ -n "$body_file" ] || usage + [ -f "$body_file" ] || { echo "[jsc][議題][ERR]:找不到正文檔「$body_file」。" >&2; exit 2; } + shift 2 + labels='' + milestone='' + while [ "$#" -gt 0 ]; do + case "$1" in + --labels) [ "$#" -ge 2 ] || usage; labels="$2"; shift 2 ;; + --milestone) [ "$#" -ge 2 ] || usage; milestone="$2"; shift 2 ;; + *) echo "[jsc][議題][ERR]:不認得的選項「$1」。" >&2; usage ;; + esac + done + payload=$(mktemp) + trap 'rm -f "$payload"' EXIT + python3 -c ' +import json,sys +title, body_file, labels, milestone, out = sys.argv[1:6] +d={"title": title, "body": open(body_file, encoding="utf-8").read()} +if labels: + d["labels"]=[int(x) for x in labels.split(",") if x.strip()] +if milestone: + d["milestone"]=int(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 ' +import json,sys +d=json.load(sys.stdin) +print("index=%s" % (d.get("number") or d.get("index") or "")) +print("url=%s" % (d.get("html_url") or "")) +' ;; + + *) usage ;; +esac