#!/usr/bin/env bash # herdr-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 "herdr-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 "herdr-issue: tea 沒有預設 login,請先 tea login default " >&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 "herdr-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 同層的 -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('herdr-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']))" }