refactor: statusbar、companion、herdr 外掛改名加上 jsc- 前綴

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
root
2026-10-13 07:43:50 +00:00
co-authored by Claude Sonnet 5.5
parent f10b168c28
commit 58e2b2d26a
53 changed files with 28 additions and 25 deletions
@@ -0,0 +1,24 @@
#!/usr/bin/env bash
# git credential helper:用 tea 預設 login 的帳號與 token 回應 HTTPS 認證,避免 git 要互動輸入帳密
# ("could not read Username ... No such device or address")。只處理 get,且只回應該 login 的主機。
[ "${1:-}" = get ] || exit 0
exec python3 -c '
import os, sys
req = dict(l.split("=", 1) for l in sys.stdin.read().splitlines() if "=" in l)
cfg = os.path.join(os.environ.get("XDG_CONFIG_HOME") or os.path.expanduser("~/.config"), "tea", "config.yml")
logins, cur = [], None
for line in open(cfg):
s = line.strip()
if s.startswith("- name:"):
cur = {"name": s.split(":", 1)[1].strip()}; logins.append(cur)
elif cur is not None and ":" in s:
k, v = s.split(":", 1)
if k in ("url", "token", "user", "default"): cur[k] = v.strip().strip("\"\x27")
want = os.environ.get("HERDR_ISSUE_LOGIN")
pick = next((l for l in logins if l["name"] == want), None) if want else None
pick = pick or next((l for l in logins if l.get("default") == "true"), None)
if not pick or not pick.get("token"): sys.exit(0)
host = pick["url"].split("://", 1)[-1].rstrip("/")
if req.get("host") != host: sys.exit(0)
print("username=" + pick["user"]); print("password=" + pick["token"])
'
+133
View File
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# gitea-issue 共用函式。每個腳本都 source 這個檔。
set -euo pipefail
LABEL="in-progress"
LABEL_COLOR="0e8a16"
LABEL_DESC="Herdr 的 agent 正在實作"
INTERVAL="${HERDR_ISSUE_INTERVAL:-300}"
MODEL="${HERDR_ISSUE_MODEL:-claude-sonnet-5-5}"
SCRIPTS="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
die() { echo "gitea-issue: $*" >&2; exit 1; }
# tea 的預設使用者。在 clone 裡 tea 會依 remote URL 挑第一個符合的 login(可能不是預設的),
# 所以明確取 default 那個 login,所有 tea 呼叫都加 --login。可用 HERDR_ISSUE_LOGIN 覆寫。
if [ -z "${HERDR_ISSUE_LOGIN:-}" ]; then
HERDR_ISSUE_LOGIN="$(tea login list --output json | python3 -c "import sys,json
print(next((l['name'] for l in json.load(sys.stdin) if str(l.get('default')).lower()=='true'),''))")"
fi
[ -n "$HERDR_ISSUE_LOGIN" ] || { echo "gitea-issue: tea 沒有預設 login,請先 tea login default <name>" >&2; exit 1; }
export HERDR_ISSUE_LOGIN
# 忽略所有 TLS 檢查:tea 的 insecure 只能寫在設定檔(沒有環境變數),所以把所有 login 設成 insecure: true(冪等);
# git 用 GIT_SSL_NO_VERIFY。agent 的 tea/git 也會吃到同一份設定。
TEA_CONFIG="${XDG_CONFIG_HOME:-$HOME/.config}/tea/config.yml"
if [ -f "$TEA_CONFIG" ] && grep -qE '^\s*insecure:\s*false' "$TEA_CONFIG"; then
sed -i -E 's/^(\s*insecure:\s*)false/\1true/' "$TEA_CONFIG"
fi
export GIT_SSL_NO_VERIFY=true
# git 走 HTTPS 時沒有帳密又不能互動輸入會失敗,所以腳本內的 git 一律用 tea 預設 login 的 token(git-credential.sh)。
export GIT_TERMINAL_PROMPT=0
git() { command git -c http.sslVerify=false -c credential.helper= -c "credential.helper=$SCRIPTS/git-credential.sh" "$@"; }
# TLS/憑證錯誤多半是暫時的(連線被重置、代理、時鐘),遇到就重試幾次,不要讓輪詢中的腳本因此中斷;
# 其他錯誤原樣回傳。(憑證驗證已關閉,仍保留重試以處理連線被重置等暫時性錯誤。)重試次數 HERDR_ISSUE_TLS_RETRIES(預設 5),間隔 3 秒。
tea() {
local out err rc=0 try max="${HERDR_ISSUE_TLS_RETRIES:-5}" ef; ef="$(mktemp)"
for ((try = 1; try <= max; try++)); do
out="$(command tea "$1" --login "$HERDR_ISSUE_LOGIN" "${@:2}" 2>"$ef")" && rc=0 || rc=$?
err="$(cat "$ef")"
if [ "$rc" -eq 0 ] || ! grep -qiE 'tls|x509|certificate|handshake' <<<"$out$err"; then break; fi
echo "gitea-issue: tea 遇到 TLS/憑證錯誤(第 $try/$max 次),3 秒後重試" >&2
rc=${rc/#0/1}; sleep 3
done
rm -f "$ef"
[ -z "$err" ] || printf '%s\n' "$err" >&2
printf '%s\n' "$out"
return "$rc"
}
# 從 stdin 讀 JSON,執行 python 片段,d 是解析後的物件。
jq_py() { python3 -c "import sys,json
def find(o,k):
if isinstance(o,dict):
if k in o: return o[k]
for v in o.values():
r=find(v,k)
if r is not None: return r
elif isinstance(o,list):
for v in o:
r=find(v,k)
if r is not None: return r
return None
d=json.load(sys.stdin)
$1"; }
# 議題的完整 JSON(state、labels、body、title、closedAt)。
issue_json() { tea issues "$2" --repo "$1" --output json; }
issue_state() { issue_json "$1" "$2" | jq_py 'print(d["state"])'; }
issue_labels() { issue_json "$1" "$2" | jq_py 'print("\n".join(l["name"] for l in d.get("labels") or []))'; }
# 某個標籤底下還有幾個 open 議題。
open_count_with_label() {
tea issues list --repo "$1" --state open --labels "$2" --limit 500 --output json --fields index \
| jq_py 'print(len(d))'
}
ensure_label() {
if ! tea labels list --repo "$1" --output json | jq_py "sys.exit(0 if any(l['name']=='$LABEL' for l in d) else 1)"; then
tea labels create --repo "$1" --name "$LABEL" --color "$LABEL_COLOR" --description "$LABEL_DESC" >/dev/null
fi
}
# agent 狀態:idle|working|blocked|done|unknown|missing
agent_status() {
herdr agent get "issue-$1" 2>/dev/null | jq_py 'print(find(d,"agent_status") or "unknown")' 2>/dev/null || echo missing
}
# 標籤為 #N 的 tab ID,沒有就空字串。
tab_for_issue() {
herdr tab list --workspace "${HERDR_WORKSPACE_ID:?}" \
| jq_py "print(next((t['tab_id'] for t in find(d,'tabs') or [] if t.get('label')=='#$1'),''))"
}
# worktree 路徑:clone 同層的 <repo>-issue-N
worktree_path() { echo "$(dirname "$2")/$(basename "$2")-issue-$1"; }
notify() { herdr notification show "$1" --body "$2" --sound "${3:-request}" >/dev/null 2>&1 || true; }
# 目前目錄必須是 owner/repo 的 clone;印出 clone 根目錄。
clone_root_for() {
local root url
root="$(git rev-parse --show-toplevel 2>/dev/null)" || die "目前目錄不是 git repo,請在 $1 的 clone 裡執行"
url="$(git -C "$root" remote get-url origin 2>/dev/null || true)"
case "$url" in
*"$1"|*"$1.git"|*"$1/") echo "$root" ;;
*) die "目前 clone 的 origin 是 $url,不是 $1" ;;
esac
}
# head 分支為 issue-N 的最新 PR:印出「index state」或空字串。
pr_for_issue() {
tea pulls list --repo "$1" --state all --limit 500 --output json --fields index,state,mergeable,head \
| jq_py "m=[p for p in d if p.get('head')=='issue-$2']
print(' '.join([m[0]['index'],m[0]['state'],str(m[0].get('mergeable'))]) if m else '')"
}
# 擋住議題 N 的 open 依賴(Gitea 原生 issue dependencies),一行一個:同 repo 印 #M,跨 repo 印 owner/repo#M。
# tea api 遇到 HTTP 錯誤仍 exit 0,所以用回應是否為 JSON 陣列判斷成敗;失敗就 die。
open_blockers() {
tea api --repo "$1" "/repos/{owner}/{repo}/issues/$2/dependencies" | python3 -c "import sys,json
raw=sys.stdin.read()
try: d=json.loads(raw)
except Exception: d=None
if not isinstance(d,list):
sys.stderr.write('gitea-issue: 讀取 #$2 的依賴失敗:'+raw.strip()[:300]+'\n'); sys.exit(1)
for i in d:
if i.get('state')=='open':
r=(i.get('repository') or {}).get('full_name','')
print(('#' if r in ('','$1') else r+'#')+str(i['number']))"
}
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# 用法:pr-wait.sh <owner/repo> <N>
# 阻塞到 head 為 issue-N 的 PR 有衝突、被關閉或合併,或議題被關閉。
# 最後一行印出原因:conflict | closed | merged | issue-closed
source "$(dirname "$0")/lib.sh"
REPO="${1:?}"; N="${2:?用法:pr-wait.sh <owner/repo> <N>}"
while :; do
read -r PR STATE MERGEABLE <<<"$(pr_for_issue "$REPO" "$N")" || true
if [ -z "${PR:-}" ]; then
echo "還沒有 head 為 issue-$N 的 PR,$INTERVAL 秒後再查"
else
case "$STATE" in
merged) echo "PR #$PR 已合併"; echo merged; exit 0 ;;
closed) echo "PR #$PR 被關閉(未合併)"; echo closed; exit 0 ;;
open)
if [ "$MERGEABLE" = "false" ]; then
sleep 60
read -r _ _ AGAIN <<<"$(pr_for_issue "$REPO" "$N")"
if [ "$AGAIN" = "false" ]; then echo "PR #$PR 與 main 有衝突"; echo conflict; exit 0; fi
fi ;;
esac
fi
if [ "$(issue_state "$REPO" "$N")" = "closed" ]; then echo "議題 #$N 已關閉"; echo issue-closed; exit 0; fi
sleep "$INTERVAL"
done
@@ -0,0 +1,18 @@
請實作這個 Gitea 議題:{LINK}(先用 tea issues 讀完整內文與留言,依 Acceptance criteria 逐項完成)。
你在分支 issue-{N} 的 worktree 裡,從 origin/main 建立,請在這裡實作,不要另外建 worktree。
{RESUME}
規則:
0. 一律以 tea 的預設使用者 {LOGIN} 操作 Gitea(tea 指令加 --login {LOGIN}),git commit 身分已設為同一人。
所有回覆、commit 訊息、PR 標題與描述、議題留言一律使用繁體中文(程式碼與指令除外)。
1. 議題的代辦項目是內文 Acceptance criteria 的核取方塊。每完成一項就執行
{S}/tick.sh {REPO} {N} <第幾項>(從第一個核取方塊數起,已勾的也算)
它會重新讀取最新內文再改,不要自己用 tea issues edit 重寫整段。
2. 全部完成後推送分支並建立 PR 回 main:
tea pulls create --login {LOGIN} --repo {REPO} --base main --head issue-{N} --title "<標題>" --description "<摘要>
Closes #{N}"
3. 然後在背景執行 {S}/pr-wait.sh {REPO} {N},它會在 PR 有衝突、被關閉或合併時結束並印出原因:
- conflict:git fetch origin && git merge origin/main,用 /mattpocock-skills:resolving-merge-conflicts 解決,跑測試,推送,再執行一次 pr-wait.sh。
- closed:tea issues close --login {LOGIN} --repo {REPO} {N},結束。
- merged 或 issue-closed:結束。
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""列出可執行的議題。輸入:open.json agents.json tabs.json owner/repo
阻擋關係只認 Gitea 原生 issue dependencies(tea api),不看議題內文。"""
import os, json, re, subprocess, sys
open_issues = json.load(open(sys.argv[1]))
agents = json.load(open(sys.argv[2]))
tabs = json.load(open(sys.argv[3]))
repo = sys.argv[4]
def find(o, k):
if isinstance(o, dict):
if k in o: return o[k]
for v in o.values():
r = find(v, k)
if r is not None: return r
elif isinstance(o, list):
for v in o:
r = find(v, k)
if r is not None: return r
def labels(i): return [l.strip() for l in (i.get("labels") or "").split() if l.strip()] if isinstance(i.get("labels"), str) else [l["name"] for l in i.get("labels") or []]
# Herdr 裡活著的 agent:名稱 issue-N,或掛在標籤 #N 的 tab 上
running = set()
for a in find(agents, "agents") or []:
m = re.fullmatch(r"issue-(\d+)", str(a.get("name") or ""))
if m: running.add(m.group(1))
agent_tabs = {a.get("tab_id") for a in find(agents, "agents") or []}
for t in find(tabs, "tabs") or []:
m = re.fullmatch(r"#(\d+)", str(t.get("label") or ""))
if m and t.get("tab_id") in agent_tabs: running.add(m.group(1))
def open_blockers(n):
"""擋住 #n 的 open 依賴。tea api 遇到 HTTP 錯誤仍 exit 0,所以回應不是陣列就當失敗,整個停下。"""
raw = subprocess.run(["tea", "api", "--login", os.environ["HERDR_ISSUE_LOGIN"], "--repo", repo, f"/repos/{{owner}}/{{repo}}/issues/{n}/dependencies"],
capture_output=True, text=True).stdout
try: d = json.loads(raw)
except Exception: d = None
if not isinstance(d, list):
sys.exit(f"gitea-issue: 讀取 #{n} 的依賴失敗:{raw.strip()[:300]}")
out = []
for i in d:
if i.get("state") != "open": continue
r = (i.get("repository") or {}).get("full_name", "")
out.append(("#" if r in ("", repo) else r + "#") + str(i["number"]))
return out
def spec(i):
return next((l for l in labels(i) if l.startswith("規格/#") or l.startswith("總覽/#")), "")
runnable, stale = [], []
for i in sorted(open_issues, key=lambda x: int(x["index"])):
ls = labels(i)
if "ready-for-agent" not in ls: continue
if "類型/規格" in ls or "類型/總覽" in ls: continue
n = i["index"]
if n in running: continue
if "in-progress" in ls:
stale.append(n); continue
if open_blockers(n): continue
runnable.append((n, spec(i), i["title"]))
if runnable:
print(f"可執行({len(runnable)}):編號\t規格\t標題")
for n, s, t in runnable:
print(f"#{n}\t{s}\t{t}")
else:
print("目前沒有可執行的議題。")
print()
print("Herdr 執行中:" + (" ".join(f"#{n}" for n in sorted(running, key=int)) or "無"))
if stale:
print("掛 in-progress 但沒有 agent(需處理,重跑 start 會接續):" + " ".join(f"#{n}" for n in stale))
+10
View File
@@ -0,0 +1,10 @@
#!/usr/bin/env bash
# 用法:ready.sh <owner/repo>
# 列出可執行的議題(open、ready-for-agent、Gitea 原生依賴全關、Herdr 沒在跑、沒掛 in-progress)。被阻擋的不列。
source "$(dirname "$0")/lib.sh"
REPO="${1:?用法:ready.sh <owner/repo>}"
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
tea issues list --repo "$REPO" --state open --limit 500 --output json --fields index,title,labels,body > "$T/open.json"
herdr agent list > "$T/agents.json"
herdr tab list --workspace "${HERDR_WORKSPACE_ID:?}" > "$T/tabs.json"
python3 "$SCRIPTS/ready.py" "$T/open.json" "$T/agents.json" "$T/tabs.json" "$REPO"
+92
View File
@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# 用法:start.sh <owner/repo> <N>
# 在目前 clone 旁建 worktree、開 Herdr tab、啟動 claude、送提示詞並確認 agent 有反應、掛 in-progress 標籤。
# 送出提示詞後 15 秒內沒觀察到 working 或 blocked 就視為失敗並回滾(關 tab、移除 worktree、刪本次新建的分支)。
# 最後一行印出 JSON:{"tab":..,"pane":..,"path":..,"resumed":..,"state":"working"|"blocked"}
source "$(dirname "$0")/lib.sh"
REPO="${1:?}"; N="${2:?用法:start.sh <owner/repo> <N>}"
LINK="https://gitea.jsc.idv.tw/$REPO/issues/$N"
test "${HERDR_ENV:-}" = 1 || die "不在 Herdr 裡(HERDR_ENV≠1)"
CLONE="$(clone_root_for "$REPO")"
BRANCH="issue-$N"
WT="$(worktree_path "$N" "$CLONE")"
if [ "$(agent_status "$N")" != missing ]; then
die "issue-$N 的 agent 還活著(tab $(tab_for_issue "$N")),不重複啟動"
fi
[ "$(issue_state "$REPO" "$N")" = open ] || die "#$N 不是 open"
BLOCKERS="$(open_blockers "$REPO" "$N")" || exit 1
[ -z "$BLOCKERS" ] || die "#$N 被還沒關閉的議題擋住:$(echo "$BLOCKERS" | tr '\n' ' ')(Gitea 依賴設定)。等它們關閉,或到 Gitea 拿掉依賴再啟動"
git -C "$CLONE" fetch --quiet origin
RESUMED=false
if git -C "$CLONE" show-ref --verify --quiet "refs/heads/$BRANCH"; then
RESUMED=true
if ! git -C "$CLONE" worktree list --porcelain | grep -qx "worktree $WT"; then
git -C "$CLONE" worktree add --quiet "$WT" "$BRANCH"
fi
else
git -C "$CLONE" worktree add --quiet -b "$BRANCH" "$WT" origin/main
fi
TAB=""
# 回滾到跟沒跑過一樣:關 tab、移除 worktree;分支只在本次新建時刪除。
rollback() {
[ -n "$TAB" ] && herdr tab close "$TAB" >/dev/null 2>&1 || true
if git -C "$CLONE" worktree list --porcelain | grep -qx "worktree $WT"; then
git -C "$CLONE" worktree remove --force "$WT" >/dev/null 2>&1 || true
fi
$RESUMED || git -C "$CLONE" branch -D "$BRANCH" >/dev/null 2>&1 || true
}
TAB_JSON="$(herdr tab create --workspace "${HERDR_WORKSPACE_ID:?}" --cwd "$WT" --label "#$N" --no-focus)"
TAB="$(jq_py 'print(find(d,"tab")["tab_id"])' <<<"$TAB_JSON")"
PANE="$(jq_py 'print(find(d,"root_pane")["pane_id"])' <<<"$TAB_JSON")"
START_ERR="$(mktemp)"
for _ in 1 2 3 4 5 6; do
if herdr agent start "issue-$N" --kind claude --pane "$PANE" --timeout 90000 -- --model "$MODEL" >/dev/null 2>"$START_ERR"; then
rm -f "$START_ERR"; break
fi
sleep 2
done
if [ -f "$START_ERR" ]; then
cat "$START_ERR" >&2; rm -f "$START_ERR"
rollback
die "agent 啟動失敗,已回滾"
fi
# git 身分:與 tea 預設使用者一致(只設在這個 worktree,不動 clone 的設定)
GIT_USER="$(command tea login list --output json | jq_py "print(next((l['user'] for l in d if l['name']=='$HERDR_ISSUE_LOGIN'),''))")"
GIT_EMAIL="$(tea api "/user" | jq_py 'print(d.get("email") or "")' 2>/dev/null || true)"
git -C "$CLONE" config extensions.worktreeConfig true
# agent 在 worktree 裡自己跑的 git(push、fetch)也要有帳密:把 helper 設到 clone 的本地設定(worktree 共用),只對這個主機生效
GIT_HOST_URL="$(command tea login list --output json | jq_py "print(next((l['url'] for l in d if l['name']=='$HERDR_ISSUE_LOGIN'),''))")"
if [ -n "$GIT_HOST_URL" ]; then
git -C "$CLONE" config --local --replace-all "credential.$GIT_HOST_URL.helper" "$SCRIPTS/git-credential.sh"
fi
git -C "$WT" config --worktree http.sslVerify false
git -C "$WT" config --worktree user.name "$GIT_USER"
[ -z "$GIT_EMAIL" ] || git -C "$WT" config --worktree user.email "$GIT_EMAIL"
if $RESUMED; then RESUME_LINE="分支已有先前進度,先看 git log 和議題核取方塊再接續。"; else RESUME_LINE=""; fi
PROMPT="$(sed -e "s|{LINK}|$LINK|g" -e "s|{REPO}|$REPO|g" -e "s|{N}|$N|g" -e "s|{S}|$SCRIPTS|g" -e "s|{LOGIN}|$HERDR_ISSUE_LOGIN|g" -e "s|{RESUME}|$RESUME_LINE|" "$SCRIPTS/prompt.txt")"
# 送出後必須觀察到 agent 進入 working 或 blocked;否則 Herdr 回非 0(agent_prompt_stalled、timeout、agent_blocked 等)。
PROMPT_ERR="$(mktemp)"
if PROMPT_OUT="$(herdr agent prompt "issue-$N" "$PROMPT" --wait --until working --until blocked --timeout 15000 2>"$PROMPT_ERR")"; then
rm -f "$PROMPT_ERR"
else
echo "gitea-issue: 送出提示詞後 agent 沒有反應,已回滾" >&2
cat "$PROMPT_ERR" >&2; rm -f "$PROMPT_ERR"
rollback
exit 1
fi
STATE="$(jq_py 'print(find(d,"agent_status") or "")' <<<"$PROMPT_OUT" 2>/dev/null || true)"
case "$STATE" in working|blocked) ;; *) STATE="$(agent_status "$N")" ;; esac
ensure_label "$REPO"
tea issues edit "$N" --repo "$REPO" --remove-labels "$LABEL" >/dev/null 2>&1 || true
tea issues edit "$N" --repo "$REPO" --add-labels "$LABEL" >/dev/null
printf '{"tab":"%s","pane":"%s","path":"%s","resumed":%s,"state":"%s"}\n' "$TAB" "$PANE" "$WT" "$RESUMED" "$STATE"
+18
View File
@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# 用法:tick.sh <owner/repo> <N> <第幾項>
# 把議題內文第 k 個核取方塊(已勾的也算在內)改成「[x]」。每次都重新讀取最新內文再改。
source "$(dirname "$0")/lib.sh"
REPO="${1:?}"; N="${2:?}"; K="${3:?用法:tick.sh <owner/repo> <N> <第幾項>}"
NEW="$(issue_json "$REPO" "$N" | python3 -c "
import sys,json,re
body=json.load(sys.stdin)['body']
k=int(sys.argv[1]); i=[0]
def rep(m):
i[0]+=1
return '[x]' if i[0]==k else m.group(0)
out=re.sub(r'\[[ xX]\]', rep, body)
if i[0]<k: sys.exit('只有 %d 個核取方塊,沒有第 %d 個' % (i[0],k))
sys.stdout.write(out)
" "$K")"
tea issues edit "$N" --repo "$REPO" --description "$NEW" >/dev/null
echo "#$N 第 $K 項已勾選,剩 $(grep -o '\[ \]' <<<"$NEW" | wc -l) 項"
@@ -0,0 +1,70 @@
#!/usr/bin/env bash
# 用法:watch-issue.sh <owner/repo> <N>(在 clone 裡以背景執行)
# 每 HERDR_ISSUE_WATCH_INTERVAL 秒(預設 5)查議題;agent blocked 超過 HERDR_ISSUE_BLOCKED_AFTER 秒(預設 60,只通知一次)或消失就彈 Herdr 通知;議題關閉時清理並往上檢查規格、總覽。
source "$(dirname "$0")/lib.sh"
REPO="${1:?}"; N="${2:?用法:watch-issue.sh <owner/repo> <N>}"
CLONE="$(clone_root_for "$REPO")"
BRANCH="issue-$N"
WT="$(worktree_path "$N" "$CLONE")"
LAST=""
WATCH_INTERVAL="${HERDR_ISSUE_WATCH_INTERVAL:-5}" # 輪詢秒數(不耗額度,只查 Gitea 與 Herdr)
BLOCKED_AFTER="${HERDR_ISSUE_BLOCKED_AFTER:-60}" # blocked 連續超過幾秒才通知
BLOCKED_SINCE=0
NOTIFIED=false
# 若 <label> 底下沒有 open 議題,留言並關閉議題 X;印出 closed|open|skip
close_if_done() { # $1=X $2=label
[ "$(issue_state "$REPO" "$1")" = open ] || { echo skip; return; }
if [ "$(open_count_with_label "$REPO" "$2")" = 0 ]; then
tea comment "$1" --repo "$REPO" "底下的議題全部關閉,由 gitea-issue 自動關閉。" >/dev/null
tea issues close "$1" --repo "$REPO" >/dev/null
echo closed
else
echo open
fi
}
while [ "$(issue_state "$REPO" "$N")" != closed ]; do # 查詢失敗會回空字串,要繼續等,不能當成已關閉
S="$(agent_status "$N")"
NOW="$(date +%s)"
if [ "$S" = blocked ]; then
[ "$LAST" = blocked ] || { BLOCKED_SINCE=$NOW; NOTIFIED=false; }
if ! $NOTIFIED && [ $((NOW - BLOCKED_SINCE)) -ge "$BLOCKED_AFTER" ]; then
notify "#$N 等你回答" "issue-$N 已 blocked 超過 $BLOCKED_AFTER 秒"
NOTIFIED=true
fi
elif [ "$S" = missing ] && [ "$LAST" != missing ] && [ -n "$LAST" ]; then
notify "#$N 的 agent 不見了" "issue-$N 的 agent 已結束,議題仍 open"
fi
LAST="$S"
sleep "$WATCH_INTERVAL"
done
echo "#$N 已關閉,開始清理"
LABELS="$(issue_labels "$REPO" "$N")"
TAB="$(tab_for_issue "$N")"
[ -n "$TAB" ] && herdr tab close "$TAB" >/dev/null && echo "關閉 tab $TAB"
tea issues edit "$N" --repo "$REPO" --remove-labels "$LABEL" >/dev/null 2>&1 || true
git -C "$CLONE" fetch --quiet origin || true
read -r PR STATE _ <<<"$(pr_for_issue "$REPO" "$N")" || true
DELETE_BRANCH=false
case "${STATE:-none}" in
merged) DELETE_BRANCH=true; echo "PR #$PR 已合併" ;;
closed|open) echo "PR #$PR 狀態 $STATE,保留分支" ;;
*) if [ "$(git -C "$CLONE" rev-list --count "origin/main..$BRANCH" 2>/dev/null || echo 0)" = 0 ]; then DELETE_BRANCH=true; echo "沒有 PR 也沒有 commit"; else echo "沒有 PR 但分支有 commit,保留"; fi ;;
esac
if git -C "$CLONE" worktree list --porcelain | grep -qx "worktree $WT"; then
git -C "$CLONE" worktree remove --force "$WT" && echo "移除 worktree $WT"
fi
$DELETE_BRANCH && git -C "$CLONE" branch -D "$BRANCH" >/dev/null 2>&1 && echo "刪除分支 $BRANCH"
SPEC="$(grep -o '規格/#[0-9]*' <<<"$LABELS" | head -1 | tr -d '規格/#')"
if [ -n "$SPEC" ]; then
R="$(close_if_done "$SPEC" "規格/#$SPEC")"; echo "規格 #$SPEC:$R"
if [ "$R" = closed ]; then
OV="$(issue_labels "$REPO" "$SPEC" | grep -o '總覽/#[0-9]*' | head -1 | tr -d '總覽/#')"
[ -n "$OV" ] && echo "總覽 #$OV:$(close_if_done "$OV" "總覽/#$OV")"
fi
fi
echo "#$N 清理完成"