diff --git a/README.md b/README.md index 0cb5f04..0ba4af9 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ teai 是 Gitea 的命令列(CLI)工具,以更完整、更可靠的方式 - **語言:** Go(與 Gitea 本體一致),僅用標準庫,離線可建置 - **目標站點:** `https://gitea.alterminal.com` -- **狀態:** 基礎建設(#5:internal/gitea)、工作流命令(#6:whoami/orgs/members/mine/pulls/next)、日常操作命令(#8:issues/pulls/labels/milestones/releases/repos/api)與管理者巡邏命令(#7:stalled/xrefs)已實作 +- **狀態:** 基礎建設(#5:internal/gitea)、工作流命令(#6:whoami/role/orgs/members/mine/pulls/next)、日常操作命令(#8:issues/pulls/labels/milestones/releases/repos/api)與管理者巡邏命令(#7:stalled/xrefs)已實作 - **範圍(#3):** 不避開 tea 已有的功能;單一工具涵蓋完整操作 ## 安裝與建置 @@ -59,8 +59,6 @@ clone 建置時 Go 會把 VCS 狀態寫進 build info:位於 tag 上顯示該 ### 登入設定(首次使用) -teai 與 `tea` 共用同一份組態檔;若 `tea whoami` 已可正常輸出,可跳過本節。 - ```sh teai login add --url https://gitea.alterminal.com # 未給 --token 時自 stdin 讀一行:貼上 token 後按 Enter(不會進 shell 歷史) @@ -68,8 +66,7 @@ teai whoami # 驗證:應輸出 {"username":"<你的帳號>"} ``` - token 驗證失敗(HTTP 401)不會寫入組態,結束碼 3。 -- 組態檔:`--config`/`TEA_CONFIG` 指定,預設 `~/.config/tea/config.yml`;teai 採行級編輯, - 不會改壞 tea 也在使用的欄位,檔案權限 `0600`。 +- 組態檔:`--config`/`TEAI_CONFIG` 指定,預設 `~/.config/teai/config.yml`;檔案權限 `0600`。 - 多站台以 `--name` 區分、`teai login default <名稱>` 切換預設;詳見下方 [`teai login`](#teai-login--管理登入) 一節。 ## 指令設計 @@ -83,7 +80,7 @@ teai whoami # 驗證:應輸出 {"username":"<你的帳號>"} 2. **規則單一來源**:「最後一則留言不是自己」等工作流判定規則以 `AGENTS.md` 為準,teai 只實作、不改義。規則要變更,先改 AGENTS.md,teai 跟著改。 3. **機器可讀優先**:預設輸出 JSON;`--output table` 僅供人工抽查。 4. **可直接替換原型**:`gitea.py` 的每個子命令在 teai 都有語義相同的對應命令,可交叉驗證、平滑遷移。 -5. **認證沿用 tea**:讀 `tea` 的登入組態(`TEA_CONFIG` 或 `~/.config/tea/config.yml`),不另建認證體系。 +5. **自主認證組態**:登入資料寫在 teai 自己的組態(`TEAI_CONFIG` 或 `~/.config/teai/config.yml`),不依賴 tea。 6. **安全預設**:查詢類唯讀;寫入類命令(#8 起)一律要求明確 `--yes`,未確認時不發出任何請求、不產生任何遠端變更。 ### 日常操作(tea 對等指令;#3 不避開 tea 已有功能;#8 已實作) @@ -138,8 +135,8 @@ teai [全域選項] <命令> [參數] | 選項 | 說明 | | --- | --- | | `--url ` | Gitea 站點,預設 `https://gitea.alterminal.com` | -| `--token ` | API token;未給則依序嘗試 `TEAI_TOKEN` 環境變數、tea 登入組態 | -| `--config ` | tea 組態檔路徑,等同 `TEA_CONFIG` | +| `--token ` | API token;未給則依序嘗試 `TEAI_TOKEN` 環境變數、teai 登入組態 | +| `--config ` | teai 組態檔路徑,等同 `TEAI_CONFIG` | | `--output json\|table` | 輸出格式,預設 `json` | | `--timeout ` | HTTP 請求逾時,預設 `30s` | @@ -147,8 +144,9 @@ teai [全域選項] <命令> [參數] | 命令 | 對應 gitea.py | 說明 | | --- | --- | --- | -| `teai login list\|add\|default\|remove` | — | 管理 tea 相容登入組態(見下) | +| `teai login list\|add\|default\|remove` | — | 管理登入組態(見下) | | `teai whoami` | `get_current_username` | 目前帳號 | +| `teai role` | — | 目前角色(Supervisor 團隊→supervisor,Agents 團隊→worker) | | `teai orgs` | `for_all_organizations` | 我所屬的組織 | | `teai organizations list\|create\|delete` | — | 組織管理(tea 對等日常操作;`orgs` 是工作流精簡版) | | `teai members [--has-work]` | `members [--has-work]` | Agents 團隊成員;`--has-work` 只列有未完成工作者 | @@ -189,9 +187,8 @@ teai next --output table # 人類可讀 ### `teai login` — 管理登入 -teai 與 tea 共用同一份組態檔(`--config`/`TEA_CONFIG`,預設 `~/.config/tea/config.yml`)。 -寫入採行級編輯:只動目標項目的行,未知欄位(`ssh_*` 等)與 `preferences` 區段逐字保留, -不改壞 tea 也在使用的檔案;檔案權限 `0600`、暫存檔原子替換。 +登入組態由 teai 自行管理(`--config`/`TEAI_CONFIG`,預設 `~/.config/teai/config.yml`)。 +寫入採行級編輯:只動目標項目的行,未知欄位與註解逐字保留;檔案權限 `0600`、暫存檔原子替換。 ```sh teai login list # 列出登入(一律不含 token) @@ -305,13 +302,21 @@ teai pulls --repo alterminal/teai # 限定單一倉庫 teai mine # 分派給我、最後一則留言不是自己的 open issues(跨所屬組織) ``` -### `teai orgs` / `teai whoami` +### `teai orgs` / `teai whoami` / `teai role` ```sh teai orgs # 我所屬的組織 -teai whoami # 目前帳號(讀 tea 組態) +teai whoami # 目前帳號 +teai role # 目前角色 ``` +角色由所屬組織的團隊決定: + +- 在 **Supervisor** 團隊 → `{"role":"supervisor"}` +- 在 **Agents** 團隊 → `{"role":"worker"}` +- 同時屬於兩者時以 supervisor 為準 +- 兩者都不屬於 → 輸出 `null`,exit 0 + ### `teai xrefs / ` — 引用解析 解析該 issue/PR 內文與全部留言中的 `#N`、`repo#N`、`owner/repo#N` 引用,輸出解析後的目標清單(去重): @@ -330,7 +335,7 @@ teai whoami # 目前帳號(讀 tea 組態) | 碼 | 意義 | | --- | --- | -| 0 | 成功(含「沒有結果」→ 清單類命令輸出 `[]`、`next` 輸出 `null`;#37 判決) | +| 0 | 成功(含「沒有結果」→ 清單類命令輸出 `[]`、`next`/`role` 輸出 `null`;#37 判決) | | 2 | 用法錯誤(未知命令/參數) | | 3 | API 錯誤(連線失敗、401、403、5xx) | @@ -355,6 +360,7 @@ teai whoami # 目前帳號(讀 tea 組態) 「沒有未完成的工作」。**判決 #37:維持 teai 現行 JSON 一致性(方向 2)**——消費端 (agents 倉庫巡邏提示與觸發腳本)應以「輸出 `[]`/`null` 且 exit 0」為「沒有」判據, 不得以「無輸出」判斷;`members [--has-work]` 維持逐行帳號、空時無輸出(兩者一致,不受影響)。 + `teai role` 不在 Supervisor/Agents 時同樣輸出 `null`(exit 0)。 - **清單欄位集(`mine`/`pulls`)**:`gitea.py` 直接傾倒完整 Gitea API 物件 (`_dump` 的副作用,欄位集隨 Gitea 版本浮動);teai 輸出策展摘要欄位 (`mine`:repo/number/title/url;`pulls`:number/title/state/author/ @@ -374,7 +380,7 @@ teai whoami # 目前帳號(讀 tea 組態) ### 安全設計 - 本期命令全部唯讀;寫入類命令未來加入時須 `--yes` 明確確認。 -- token 不入輸出、不入日誌;建議用 `TEAI_TOKEN` 或 tea 組態,避免出現在命令列。 +- token 不入輸出、不入日誌;建議用 `TEAI_TOKEN` 或 teai 組態,避免出現在命令列。 - 清單類 API 一律帶 `limit`(Gitea 預設上限 50),避免漏抓。 - 「有無實際產出」的判斷一律對照遠端實況(API 回傳),不信任回報文字。 diff --git a/internal/cli/cli.go b/internal/cli/cli.go index aa22c83..e4e848d 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -75,9 +75,9 @@ type command struct { type Globals struct { // URL 是 Gitea 站點網址。 URL string - // Token 來自 --token 旗標;空字串表示未提供(依序退回環境變數與 tea 組態)。 + // Token 來自 --token 旗標;空字串表示未提供(依序退回環境變數與 teai 組態)。 Token string - // ConfigPath 是 tea 組態路徑(--config,等同 TEA_CONFIG)。 + // ConfigPath 是 teai 組態路徑(--config,等同 TEAI_CONFIG)。 ConfigPath string // Output 是輸出格式。 Output gitea.Format @@ -464,8 +464,8 @@ func printUsage(w io.Writer) { fmt.Fprintf(w, "用法:\n teai [全域選項] <命令> [參數]\n\n") fmt.Fprintf(w, "全域選項:\n") fmt.Fprintf(w, " --url Gitea 站點(預設 https://gitea.alterminal.com)\n") - fmt.Fprintf(w, " --token API token;未給則依序嘗試 TEAI_TOKEN、tea 登入組態\n") - fmt.Fprintf(w, " --config tea 組態檔路徑,等同 TEA_CONFIG\n") + fmt.Fprintf(w, " --token API token;未給則依序嘗試 TEAI_TOKEN、teai 登入組態\n") + fmt.Fprintf(w, " --config teai 組態檔路徑,等同 TEAI_CONFIG\n") fmt.Fprintf(w, " --output 輸出格式 json|table(預設 json)\n") fmt.Fprintf(w, " --timeout HTTP 逾時(預設 30s)\n\n") fmt.Fprintf(w, "命令:\n") diff --git a/internal/cli/login.go b/internal/cli/login.go index e5b56a4..7ca4c01 100644 --- a/internal/cli/login.go +++ b/internal/cli/login.go @@ -1,6 +1,6 @@ -// login.go — teai login 指令:管理 tea 相容的登入組態。 +// login.go — teai login 指令:管理 teai 自己的登入組態。 // -// teai 與 tea 共用同一份組態檔(README 設計原則 5),login 的職責是: +// 組態由 teai 自行讀寫(README 設計原則 5),不依賴 tea。login 的職責是: // 以 token 向站點驗證(GET /user),成功後才寫入組態並記下帳號。 // token 只走記憶體與組態檔,不進輸出、日誌或錯誤訊息。 // @@ -25,20 +25,20 @@ import ( // loginStdin 是讀 token 與確認的輸入(測試可替換)。 var loginStdin io.Reader = os.Stdin -// loginConfigPath 回傳組態檔路徑:--config/TEA_CONFIG 優先,其次 -// ~/.config/tea/config.yml。找不到可用路徑回空字串。 +// loginConfigPath 回傳組態檔路徑:--config/TEAI_CONFIG 優先,其次 +// ~/.config/teai/config.yml。找不到可用路徑回空字串。 func loginConfigPath(g Globals) string { if p := strings.TrimSpace(g.ConfigPath); p != "" { return expandHome(p) } - if p := os.Getenv("TEA_CONFIG"); strings.TrimSpace(p) != "" { + if p := os.Getenv("TEAI_CONFIG"); strings.TrimSpace(p) != "" { return expandHome(strings.TrimSpace(p)) } home, err := os.UserHomeDir() if err != nil || home == "" { return "" } - return filepath.Join(home, ".config", "tea", "config.yml") + return filepath.Join(home, ".config", "teai", "config.yml") } // expandHome 展開路徑開頭的 ~。 @@ -178,7 +178,7 @@ func runLoginAdd(env *Env, args []string) error { if err != nil { return err } - if err := gitea.SaveTeaConfigFile(path, out); err != nil { + if err := gitea.SaveConfigFile(path, out); err != nil { return err } p := gitea.Printer{Format: env.Globals.Output, Out: env.Out} @@ -329,7 +329,7 @@ func saveLoginConfig(env *Env, data []byte) error { if path == "" { return fmt.Errorf("cannot determine config path") } - return gitea.SaveTeaConfigFile(path, data) + return gitea.SaveConfigFile(path, data) } // findLoginByHost 在組態內容中找 URL 主機與 siteURL 相同的登入。 @@ -352,7 +352,7 @@ func findLogin(data []byte, name string) (gitea.LoginEntry, bool) { return gitea.LoginEntry{}, false } -// defaultLoginName 以 URL 主機作為登入名稱(同 tea 的習慣)。 +// defaultLoginName 以 URL 主機作為登入名稱。 func defaultLoginName(rawURL string) string { s := rawURL if !strings.Contains(s, "://") { diff --git a/internal/cli/login_test.go b/internal/cli/login_test.go index f8e632f..33e43b7 100644 --- a/internal/cli/login_test.go +++ b/internal/cli/login_test.go @@ -25,7 +25,7 @@ func loginTestEnv(t *testing.T, configContent string) (args []string, configPath t.Fatal(err) } } - t.Setenv("TEA_CONFIG", configPath) + t.Setenv("TEAI_CONFIG", configPath) return []string{"--config", configPath}, configPath } @@ -117,6 +117,13 @@ func TestLoginAddVerifiesThenWrites(t *testing.T) { if info.Mode().Perm() != 0o600 { t.Fatalf("perm = %v", info.Mode().Perm()) } + // 新項目只寫 teai 欄位,不含 tea 的 ssh_*/preferences。 + s := string(data) + for _, ban := range []string{"ssh_", "insecure:", "version_check:", "preferences:"} { + if strings.Contains(s, ban) { + t.Fatalf("tea field leaked %q:\n%s", ban, s) + } + } } func TestLoginAddTokenFromStdin(t *testing.T) { @@ -187,6 +194,7 @@ preferences: if strings.Contains(s, "old-tok") || !strings.Contains(s, "token: new-tok") { t.Fatalf("token not replaced:\n%s", s) } + // 未知欄位與其他區段逐字保留(行級編輯,不整檔覆寫)。 if !strings.Contains(s, `ssh_key: "keep-me"`) || !strings.Contains(s, "editor: false") { t.Fatalf("unrelated content lost:\n%s", s) } @@ -290,3 +298,26 @@ func TestLoginRegisteredInUsage(t *testing.T) { t.Fatalf("usage should list login: %s", stdout) } } + +func TestLoginConfigPathIndependentOfTea(t *testing.T) { + t.Setenv("TEA_CONFIG", "/from-tea.yml") + t.Setenv("TEAI_CONFIG", "") + g := defaultGlobals() + got := loginConfigPath(g) + if got == "/from-tea.yml" { + t.Fatal("TEA_CONFIG must not be used") + } + if !strings.HasSuffix(filepath.ToSlash(got), ".config/teai/config.yml") { + t.Fatalf("default path want ~/.config/teai/config.yml, got %q", got) + } + + t.Setenv("TEAI_CONFIG", "/from-teai.yml") + if got := loginConfigPath(g); got != "/from-teai.yml" { + t.Fatalf("TEAI_CONFIG: got %q", got) + } + + g.ConfigPath = "/explicit.yml" + if got := loginConfigPath(g); got != "/explicit.yml" { + t.Fatalf("--config: got %q", got) + } +} diff --git a/internal/cli/workflow_commands.go b/internal/cli/workflow_commands.go index 198818b..8f1277e 100644 --- a/internal/cli/workflow_commands.go +++ b/internal/cli/workflow_commands.go @@ -1,5 +1,5 @@ // workflow_commands.go — gitea.py 工作流命令的 CLI 接線(#6): -// whoami、orgs、members [--has-work]、mine、pulls [--mine|--reviewer] [--repo]、next。 +// whoami、role、orgs、members [--has-work]、mine、pulls [--mine|--reviewer] [--repo]、next。 // // 輸出走 internal/gitea 的 Printer(--output json|table);錯誤經 cli.exitCodeFor // 映射結束碼(用法 2/API 3)。token 解析選序見 internal/gitea/auth.go。 @@ -20,6 +20,7 @@ func init() { commands[c.name] = c } register(&command{name: "whoami", usage: "目前帳號", run: runWhoami}) + register(&command{name: "role", usage: "目前角色(Supervisor→supervisor,Agents→worker)", run: runRole}) register(&command{name: "orgs", usage: "我所屬的組織", run: runOrgs}) register(&command{name: "members", usage: "Agents 團隊成員;--has-work 只列有未完成工作者", run: runMembers}) register(&command{name: "mine", usage: "分派給我、待跟進的 issues", run: runMine}) @@ -77,6 +78,29 @@ func runWhoami(env *Env, args []string) error { ) } +// runRole:teai role。不在 Supervisor/Agents 團隊時輸出 null(exit 0)。 +func runRole(env *Env, args []string) error { + if len(args) != 0 { + return &ErrUsage{Msg: "role 不接受參數"} + } + s, err := newScannerFunc(env) + if err != nil { + return err + } + role, err := s.Role() + if err != nil { + return err + } + p := printer(env) + if role == "" { + return p.PrintNull() + } + return p.PrintOne( + []gitea.Field{{Name: "role"}}, + gitea.Values{role}, + ) +} + // runOrgs:teai orgs。 func runOrgs(env *Env, args []string) error { if len(args) != 0 { diff --git a/internal/cli/workflow_commands_test.go b/internal/cli/workflow_commands_test.go index 5dd7bda..20775a1 100644 --- a/internal/cli/workflow_commands_test.go +++ b/internal/cli/workflow_commands_test.go @@ -224,6 +224,71 @@ func TestWiringWhoamiOrgs(t *testing.T) { } } +// TestWiringRoleWorker 驗證 Agents 成員的 role 輸出 worker。 +func TestWiringRoleWorker(t *testing.T) { + newStubEnv(t, newStubData()) + stdout, _, code := run("role") + if code != 0 { + t.Fatalf("role 應回 0,得到 %d(stdout=%q)", code, stdout) + } + if strings.TrimSpace(stdout) != `{"role":"worker"}` { + t.Fatalf("Agents 成員 role 應輸出 worker,得到 %q", stdout) + } +} + +// TestWiringRoleSupervisor 驗證 Supervisor 成員的 role 輸出 supervisor。 +func TestWiringRoleSupervisor(t *testing.T) { + c := newStubData() + c.teams["alterminal"] = []workflow.Team{ + {ID: 1, Name: "Agents"}, + {ID: 2, Name: "Supervisor"}, + } + c.members[2] = []string{"alex"} + newStubEnv(t, c) + stdout, _, code := run("role") + if code != 0 { + t.Fatalf("role 應回 0,得到 %d(stdout=%q)", code, stdout) + } + if strings.TrimSpace(stdout) != `{"role":"supervisor"}` { + t.Fatalf("Supervisor 成員 role 應輸出 supervisor,得到 %q", stdout) + } +} + +// TestWiringRoleNone 驗證不在任一角色團隊時輸出 null、exit 0。 +func TestWiringRoleNone(t *testing.T) { + c := newStubData() + c.currentUser = "outsider" + newStubEnv(t, c) + stdout, _, code := run("role") + if code != 0 { + t.Fatalf("role 無角色應回 0,得到 %d", code) + } + if strings.TrimSpace(stdout) != "null" { + t.Fatalf("role 無角色應輸出 null,得到 %q", stdout) + } +} + +// TestWiringRoleRejectsArgs 驗證 role 不接受參數。 +func TestWiringRoleRejectsArgs(t *testing.T) { + newStubEnv(t, newStubData()) + _, _, code := run("role", "extra") + if code != int(ExitUsage) { + t.Fatalf("role extra 應回 ExitUsage(%d),得到 %d", int(ExitUsage), code) + } +} + +// TestWiringRoleTable 驗證 teai role --output table。 +func TestWiringRoleTable(t *testing.T) { + newStubEnv(t, newStubData()) + stdout, _, code := run("role", "--output", "table") + if code != 0 { + t.Fatalf("role --output table 應回 0,得到 %d", code) + } + if !strings.Contains(stdout, "worker") { + t.Fatalf("role --output table 應含 worker,得到 %q", stdout) + } +} + // TestWiringVersionRejectsArgs 驗證 version 不接受參數(新剖析語義下仍 exit 2)。 func TestWiringVersionRejectsArgs(t *testing.T) { _, _, code := run("version", "--wat") diff --git a/internal/gitea/auth.go b/internal/gitea/auth.go index 4d5df4f..2e90587 100644 --- a/internal/gitea/auth.go +++ b/internal/gitea/auth.go @@ -1,7 +1,7 @@ // auth.go — 認證來源的解析與選序。 // // 選序(README「全域介面」):--token 旗標 → TEAI_TOKEN 環境變數 → -// tea 登入組態(TEA_CONFIG 指定的路徑,或 ~/.config/tea/config.yml)。 +// teai 登入組態(TEAI_CONFIG 指定的路徑,或 ~/.config/teai/config.yml)。 // token 一律只在記憶體傳遞,不寫入輸出、日誌或錯誤訊息。 package gitea @@ -23,8 +23,8 @@ const ( SourceFlag // SourceEnv 是 TEAI_TOKEN 環境變數。 SourceEnv - // SourceTeaConfig 是 tea 登入組態。 - SourceTeaConfig + // SourceConfig 是 teai 登入組態。 + SourceConfig ) // String 回傳來源名稱(診斷用)。 @@ -34,8 +34,8 @@ func (s TokenSource) String() string { return "flag" case SourceEnv: return "env" - case SourceTeaConfig: - return "tea-config" + case SourceConfig: + return "config" default: return "none" } @@ -62,12 +62,12 @@ func osReadFile(path string) ([]byte, error) { type AuthOptions struct { // FlagToken 是 --token 旗標值;空字串表示未提供。 FlagToken string - // SiteURL 是目標站點(用來比對 tea 組態中的 login url)。 + // SiteURL 是目標站點(用來比對組態中的 login url)。 SiteURL string // Env 是環境變數查詢;nil 時用 os.LookupEnv。 Env EnvLookup - // ConfigPath 是 tea 組態的明確路徑(TEA_CONFIG 或 --config)。 - // 空字串表示用預設 ~/.config/tea/config.yml。 + // ConfigPath 是 teai 組態的明確路徑(TEAI_CONFIG 或 --config)。 + // 空字串表示用預設 ~/.config/teai/config.yml。 ConfigPath string // Home 是使用者家目錄(展開 ~ 用);空字串表示不展開。 Home string @@ -75,7 +75,7 @@ type AuthOptions struct { ReadFile ReadFileFunc } -// ResolveToken 依選序解析 token:flag → env(TEAI_TOKEN) → tea 組態。 +// ResolveToken 依選序解析 token:flag → env(TEAI_TOKEN) → teai 組態。 // // 找不到任何 token 時回傳 ("", SourceNone, nil)——查詢公開端點不需要 // token,是否為錯由呼叫端依情境決定。組態檔存在但解析失敗(格式錯誤) @@ -93,8 +93,8 @@ func ResolveToken(opts AuthOptions) (string, TokenSource, error) { } path := opts.ConfigPath if path == "" { - // --config 未給時,先看 TEA_CONFIG 環境變數。 - if p, ok := env("TEA_CONFIG"); ok && strings.TrimSpace(p) != "" { + // --config 未給時,先看 TEAI_CONFIG 環境變數。 + if p, ok := env("TEAI_CONFIG"); ok && strings.TrimSpace(p) != "" { path = strings.TrimSpace(p) } } @@ -110,7 +110,7 @@ func ResolveToken(opts AuthOptions) (string, TokenSource, error) { if home == "" { return "", SourceNone, nil } - path = filepath.Join(home, ".config", "tea", "config.yml") + path = filepath.Join(home, ".config", "teai", "config.yml") } if path == "" { return "", SourceNone, nil @@ -124,33 +124,32 @@ func ResolveToken(opts AuthOptions) (string, TokenSource, error) { // 組態不存在或讀不到:視為沒有這個來源,不是錯誤。 return "", SourceNone, nil } - login, err := parseTeaConfig(data, opts.SiteURL) + login, err := parseConfig(data, opts.SiteURL) if err != nil { - return "", SourceNone, fmt.Errorf("gitea: parse tea config %s: %w", path, err) + return "", SourceNone, fmt.Errorf("gitea: parse config %s: %w", path, err) } if login.Token == "" { return "", SourceNone, nil } - return login.Token, SourceTeaConfig, nil + return login.Token, SourceConfig, nil } -// teaLogin 是 tea 組態中一筆登入資料(只取 teai 需要的欄位)。 -type teaLogin struct { +// configLogin 是組態中一筆登入資料(只取解析 token 需要的欄位)。 +type configLogin struct { Name string URL string Token string Default bool } -// parseTeaConfig 從 tea 的 config.yml 內容挑出最匹配 siteURL 的登入: +// parseConfig 從 teai 的 config.yml 內容挑出最匹配 siteURL 的登入: // url 主機相同者優先,其中 default: true 最優,否則取第一筆; // 都不相同時退而取 default: true,再退第一筆。 // -// 僅解析 tea 實際寫出的 YAML 子集:頂層 `logins:` 清單,項目為 -// `key: value` 的平面映射。不處理錨點、多文件、巢狀清單——tea 不會寫出 -// 這些,遇到也不該默默誤讀。 -func parseTeaConfig(data []byte, siteURL string) (teaLogin, error) { - var logins []teaLogin +// 僅解析 teai 寫出的 YAML 子集:頂層 `logins:` 清單,項目為 +// `key: value` 的平面映射。不處理錨點、多文件、巢狀清單。 +func parseConfig(data []byte, siteURL string) (configLogin, error) { + var logins []configLogin inLogins := false cur := -1 // 目前項目在 logins 的索引;-1 表示尚未開始任何項目 for lineNo, raw := range strings.Split(string(data), "\n") { @@ -171,26 +170,26 @@ func parseTeaConfig(data []byte, siteURL string) (teaLogin, error) { } if strings.HasPrefix(trimmed, "- ") || trimmed == "-" { // 新的清單項目;`- name: x` 形式同行帶鍵值。 - logins = append(logins, teaLogin{}) + logins = append(logins, configLogin{}) cur = len(logins) - 1 rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "-")) if rest != "" { if err := applyLoginField(&logins[cur], rest, lineNo+1); err != nil { - return teaLogin{}, err + return configLogin{}, err } } continue } if cur < 0 { // logins: 底下卻不是清單項目——不是預期的結構。 - return teaLogin{}, fmt.Errorf("line %d: expected list item under logins:", lineNo+1) + return configLogin{}, fmt.Errorf("line %d: expected list item under logins:", lineNo+1) } if err := applyLoginField(&logins[cur], trimmed, lineNo+1); err != nil { - return teaLogin{}, err + return configLogin{}, err } } if len(logins) == 0 { - return teaLogin{}, nil + return configLogin{}, nil } wantHost := hostOf(siteURL) // 第一輪:主機相符者,default 優先,否則第一筆。 @@ -219,7 +218,7 @@ func parseTeaConfig(data []byte, siteURL string) (teaLogin, error) { } // applyLoginField 把 `key: value` 套用到 login;value 的引號會剝除。 -func applyLoginField(l *teaLogin, kv string, lineNo int) error { +func applyLoginField(l *configLogin, kv string, lineNo int) error { key, value, ok := strings.Cut(kv, ":") if !ok { return fmt.Errorf("line %d: expected key: value, got %q", lineNo, kv) diff --git a/internal/gitea/auth_test.go b/internal/gitea/auth_test.go index 553584b..08552e7 100644 --- a/internal/gitea/auth_test.go +++ b/internal/gitea/auth_test.go @@ -1,5 +1,5 @@ -// auth_test.go 驗證認證來源選序(--token → TEAI_TOKEN → tea 組態) -// 與 tea 組態解析。 +// auth_test.go 驗證認證來源選序(--token → TEAI_TOKEN → teai 組態) +// 與組態解析。 package gitea import ( @@ -25,7 +25,7 @@ func fakeReadFile(m map[string]string) ReadFileFunc { } } -const sampleTeaConfig = `logins: +const sampleConfig = `logins: - name: other.example.com url: https://other.example.com token: other-token @@ -43,7 +43,7 @@ func TestResolveTokenOrdering(t *testing.T) { SiteURL: "https://gitea.alterminal.com", Env: fakeEnv(nil), ReadFile: fakeReadFile(map[string]string{ - "/cfg/config.yml": sampleTeaConfig, + "/cfg/config.yml": sampleConfig, }), ConfigPath: "/cfg/config.yml", } @@ -67,11 +67,11 @@ func TestResolveTokenOrdering(t *testing.T) { } }) - t.Run("tea config used when flag and env absent", func(t *testing.T) { + t.Run("config used when flag and env absent", func(t *testing.T) { opts := base tok, src, err := ResolveToken(opts) - if err != nil || tok != "site-token" || src != SourceTeaConfig { - t.Fatalf("want site-token/tea-config, got %q/%v (err %v)", tok, src, err) + if err != nil || tok != "site-token" || src != SourceConfig { + t.Fatalf("want site-token/config, got %q/%v (err %v)", tok, src, err) } }) @@ -88,8 +88,8 @@ func TestResolveTokenOrdering(t *testing.T) { opts := base opts.Env = fakeEnv(map[string]string{"TEAI_TOKEN": " "}) tok, src, err := ResolveToken(opts) - if err != nil || tok != "site-token" || src != SourceTeaConfig { - t.Fatalf("want site-token/tea-config, got %q/%v (err %v)", tok, src, err) + if err != nil || tok != "site-token" || src != SourceConfig { + t.Fatalf("want site-token/config, got %q/%v (err %v)", tok, src, err) } }) @@ -103,36 +103,54 @@ func TestResolveTokenOrdering(t *testing.T) { } }) - t.Run("TEA_CONFIG points config path", func(t *testing.T) { + t.Run("TEAI_CONFIG points config path", func(t *testing.T) { opts := base - opts.ConfigPath = "" // 不用 --config,改用 TEA_CONFIG + opts.ConfigPath = "" // 不用 --config,改用 TEAI_CONFIG opts.Env = fakeEnv(map[string]string{ - "TEA_CONFIG": "/cfg/config.yml", + "TEAI_CONFIG": "/cfg/config.yml", }) tok, src, err := ResolveToken(opts) - if err != nil || tok != "site-token" || src != SourceTeaConfig { - t.Fatalf("want site-token/tea-config via TEA_CONFIG, got %q/%v (err %v)", tok, src, err) + if err != nil || tok != "site-token" || src != SourceConfig { + t.Fatalf("want site-token/config via TEAI_CONFIG, got %q/%v (err %v)", tok, src, err) } }) - t.Run("explicit --config beats TEA_CONFIG", func(t *testing.T) { + t.Run("explicit --config beats TEAI_CONFIG", func(t *testing.T) { opts := base // ConfigPath = /cfg/config.yml(有 site-token) opts.Env = fakeEnv(map[string]string{ - "TEA_CONFIG": "/cfg/other.yml", + "TEAI_CONFIG": "/cfg/other.yml", }) read := fakeReadFile(map[string]string{ - "/cfg/config.yml": sampleTeaConfig, + "/cfg/config.yml": sampleConfig, "/cfg/other.yml": "logins:\n - name: x\n url: https://gitea.alterminal.com\n token: other\n default: true\n", }) opts.ReadFile = read tok, src, err := ResolveToken(opts) - if err != nil || tok != "site-token" || src != SourceTeaConfig { + if err != nil || tok != "site-token" || src != SourceConfig { t.Fatalf("want site-token from explicit config, got %q/%v (err %v)", tok, src, err) } }) + + t.Run("TEA_CONFIG is ignored", func(t *testing.T) { + opts := AuthOptions{ + SiteURL: "https://gitea.alterminal.com", + Env: fakeEnv(map[string]string{ + "TEA_CONFIG": "/cfg/config.yml", + }), + ReadFile: fakeReadFile(map[string]string{ + "/cfg/config.yml": sampleConfig, + "/home/.config/teai/config.yml": "logins:\n - name: x\n url: https://gitea.alterminal.com\n token: teai-token\n default: true\n", + }), + Home: "/home", + } + tok, src, err := ResolveToken(opts) + if err != nil || tok != "teai-token" || src != SourceConfig { + t.Fatalf("want teai-token from teai config, got %q/%v (err %v)", tok, src, err) + } + }) } -func TestParseTeaConfigPicksMatchingLogin(t *testing.T) { +func TestParseConfigPicksMatchingLogin(t *testing.T) { t.Run("host match preferred over default", func(t *testing.T) { cfg := `logins: - name: other @@ -144,7 +162,7 @@ func TestParseTeaConfigPicksMatchingLogin(t *testing.T) { token: right-token default: false ` - login, err := parseTeaConfig([]byte(cfg), "https://gitea.alterminal.com") + login, err := parseConfig([]byte(cfg), "https://gitea.alterminal.com") if err != nil { t.Fatalf("parse error: %v", err) } @@ -155,7 +173,7 @@ func TestParseTeaConfigPicksMatchingLogin(t *testing.T) { t.Run("quoted token unquoted", func(t *testing.T) { cfg := "logins:\n - name: t\n url: https://gitea.alterminal.com\n token: \"quoted-token\"\n default: true\n" - login, err := parseTeaConfig([]byte(cfg), "https://gitea.alterminal.com") + login, err := parseConfig([]byte(cfg), "https://gitea.alterminal.com") if err != nil { t.Fatalf("parse error: %v", err) } @@ -166,14 +184,14 @@ func TestParseTeaConfigPicksMatchingLogin(t *testing.T) { t.Run("malformed login entry errors", func(t *testing.T) { cfg := "logins:\n - name: t\n url: https://gitea.alterminal.com\n oops\n" - if _, err := parseTeaConfig([]byte(cfg), ""); err == nil { + if _, err := parseConfig([]byte(cfg), ""); err == nil { t.Fatal("malformed line should error") } }) t.Run("no logins yields empty", func(t *testing.T) { cfg := "preferences:\n editor: false\n" - login, err := parseTeaConfig([]byte(cfg), "") + login, err := parseConfig([]byte(cfg), "") if err != nil || login.Token != "" { t.Fatalf("want empty login, got %+v (err %v)", login, err) } diff --git a/internal/gitea/config.go b/internal/gitea/config.go index 1788984..04b9a2d 100644 --- a/internal/gitea/config.go +++ b/internal/gitea/config.go @@ -1,9 +1,8 @@ -// config.go — tea 相容登入組態的讀取與行級編輯。 +// config.go — teai 登入組態的讀取與行級編輯。 // -// teai 與 tea 共用同一份組態檔(README 設計原則 5),因此寫入採「行級編輯」: -// 只修改目標登入項目的所屬行,其餘內容(未知欄位、註解、preferences 區段、 -// 空行與縮排)逐字保留,避免改壞 tea 也在使用的檔案。新增項目依 tea 實際 -// 寫出的欄位順序輸出,讓檔案外觀與 tea 一致。 +// 組態由 teai 自行管理(README 設計原則 5),不依賴 tea。寫入採「行級編輯」: +// 只修改目標登入項目的所屬行,其餘內容(未知欄位、註解、空行與縮排)逐字 +// 保留。新增項目只寫 teai 使用的欄位:name/url/token/default/user。 // // token 值只會進出這個套件的資料結構與組態檔本身,永遠不出現在錯誤訊息。 package gitea @@ -18,7 +17,7 @@ import ( "strings" ) -// LoginEntry 是組態檔中的一筆登入資料(編輯用,含 tea 的 user 欄位)。 +// LoginEntry 是組態檔中的一筆登入資料(編輯用,含 user 欄位)。 type LoginEntry struct { Name string URL string @@ -43,7 +42,7 @@ type configDoc struct { } // parseConfigDoc 掃描組態內容,定位 logins 清單與各項目的行範圍。 -// 只認識 tea 實際寫出的格式:頂層 `logins:` 加縮排的 `- key: value` 項目; +// 只認識 teai 寫出的格式:頂層 `logins:` 加縮排的 `- key: value` 項目; // 其餘行原樣保留在 lines 中。 func parseConfigDoc(data []byte) configDoc { doc := configDoc{lines: splitLines(data), loginsHeader: -1, itemIndent: " "} @@ -103,7 +102,7 @@ func (d configDoc) findItem(name string) int { return -1 } -// fieldIndent 回傳項目內屬性行的縮排(項目縮排 + 兩格,tea 的格式)。 +// fieldIndent 回傳項目內屬性行的縮排(項目縮排 + 兩格)。 func (d configDoc) fieldIndent() string { return d.itemIndent + " " } @@ -168,7 +167,7 @@ func ListLoginsFile(path string) ([]LoginEntry, error) { // UpsertLogin 新增或更新名為 e.Name 的登入。 // // 已存在則就地更新 url/token/user/default(保留其他欄位與行);不存在則 -// 依 tea 的欄位順序插入新項目。makeDefault 為 true 時將其設為預設並取消 +// 依 teai 的欄位順序插入新項目。makeDefault 為 true 時將其設為預設並取消 // 其他登入的預設旗標;新增後若成為唯一登入也自動成為預設。 // 回傳新內容與最終生效的項目值。 func UpsertLogin(data []byte, e LoginEntry, makeDefault bool) ([]byte, LoginEntry, error) { @@ -326,7 +325,7 @@ func setFieldInItem(lines []string, it loginItemSpan, key, value, fieldIndent st return append(out, lines[it.end:]...) } -// newLoginBlock 產生 tea 格式的新登入項目(欄位順序與 tea 實際寫出的一致)。 +// newLoginBlock 產生 teai 格式的新登入項目(name/url/token/default/user)。 func newLoginBlock(itemIndent, name, url, token string, isDefault bool, user string) []string { f := itemIndent + " " return []string{ @@ -334,15 +333,7 @@ func newLoginBlock(itemIndent, name, url, token string, isDefault bool, user str f + "url: " + yamlScalar(url), f + "token: " + yamlScalar(token), f + "default: " + strconv.FormatBool(isDefault), - f + `ssh_host: ""`, - f + `ssh_key: ""`, - f + "insecure: false", - f + `ssh_certificate_principal: ""`, - f + "ssh_agent: false", - f + `ssh_key_agent_pub: ""`, - f + "version_check: false", f + "user: " + yamlScalar(user), - f + "created: 0", } } @@ -385,9 +376,9 @@ func joinLines(lines []string) []byte { return []byte(s) } -// SaveTeaConfigFile 把組態內容原子寫入 path:先寫同目錄暫存檔(0600), +// SaveConfigFile 把組態內容原子寫入 path:先寫同目錄暫存檔(0600), // 再 rename 取代,避免半寫入狀態。必要時建立上層目錄(0700)。 -func SaveTeaConfigFile(path string, data []byte) error { +func SaveConfigFile(path string, data []byte) error { dir := filepath.Dir(path) if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("gitea: create config dir: %w", err) diff --git a/internal/gitea/config_test.go b/internal/gitea/config_test.go index 0815761..6f313af 100644 --- a/internal/gitea/config_test.go +++ b/internal/gitea/config_test.go @@ -1,4 +1,4 @@ -// config_test.go 驗證 tea 相容組態的行級編輯:新增、更新、設預設、 +// config_test.go 驗證登入組態的行級編輯:新增、更新、設預設、 // 移除,以及「未知欄位與其他區段逐字保留」的關鍵性質。 package gitea @@ -10,8 +10,8 @@ import ( "testing" ) -// realWorldTeaConfig 是 tea 實際寫出的組態(含 ssh_* 等未知欄位與 preferences)。 -const realWorldTeaConfig = `logins: +// extraFieldsConfig 含未知欄位與額外區段,用來驗證行級編輯不會改壞不相干內容。 +const extraFieldsConfig = `logins: - name: alterminal url: https://gitea.alterminal.com token: tok-1 @@ -32,7 +32,7 @@ preferences: ` func TestListLoginsParsesAllEntries(t *testing.T) { - logins := ListLogins([]byte(realWorldTeaConfig)) + logins := ListLogins([]byte(extraFieldsConfig)) if len(logins) != 1 { t.Fatalf("want 1 login, got %d", len(logins)) } @@ -43,7 +43,7 @@ func TestListLoginsParsesAllEntries(t *testing.T) { } func TestUpsertLoginUpdatesExistingPreservesRest(t *testing.T) { - out, final, err := UpsertLogin([]byte(realWorldTeaConfig), LoginEntry{ + out, final, err := UpsertLogin([]byte(extraFieldsConfig), LoginEntry{ Name: "alterminal", URL: "https://gitea.alterminal.com", Token: "tok-2", User: "ceo", }, false) if err != nil { @@ -74,7 +74,7 @@ func TestUpsertLoginUpdatesExistingPreservesRest(t *testing.T) { } func TestUpsertLoginAppendsNewAndMakesDefault(t *testing.T) { - out, final, err := UpsertLogin([]byte(realWorldTeaConfig), LoginEntry{ + out, final, err := UpsertLogin([]byte(extraFieldsConfig), LoginEntry{ Name: "second", URL: "https://git.example.com", Token: "tok-b", User: "bob", }, true) if err != nil { @@ -93,10 +93,15 @@ func TestUpsertLoginAppendsNewAndMakesDefault(t *testing.T) { if !final.Default { t.Fatalf("final should be default: %+v", final) } - // tea 的欄位順序仍在。 + // 新項目依 teai 欄位順序附加。 s := string(out) if !strings.Contains(s, " - name: second\n") { - t.Fatalf("new item not appended in tea style:\n%s", s) + t.Fatalf("new item not appended:\n%s", s) + } + for _, ban := range []string{"ssh_", "insecure:", "version_check:", "created:"} { + if strings.Contains(s[strings.Index(s, "- name: second"):], ban) { + t.Fatalf("new item leaked tea field %q:\n%s", ban, s) + } } } @@ -117,6 +122,12 @@ func TestUpsertLoginFirstEverLoginCreatesSection(t *testing.T) { if !strings.HasPrefix(string(out), "logins:\n") { t.Fatalf("section not created:\n%s", out) } + s := string(out) + for _, ban := range []string{"ssh_", "insecure:", "version_check:", "created:", "preferences:"} { + if strings.Contains(s, ban) { + t.Fatalf("new config leaked tea field %q:\n%s", ban, s) + } + } } func TestUpsertLoginRejectsBadInput(t *testing.T) { @@ -158,7 +169,7 @@ func TestSetDefaultLoginSwitchesFlag(t *testing.T) { } func TestSetDefaultLoginUnknownNameErrors(t *testing.T) { - if _, err := SetDefaultLogin([]byte(realWorldTeaConfig), "nope"); err == nil { + if _, err := SetDefaultLogin([]byte(extraFieldsConfig), "nope"); err == nil { t.Fatal("unknown name should error") } } @@ -195,7 +206,7 @@ preferences: } func TestRemoveLoginLastOne(t *testing.T) { - out, promoted, err := RemoveLogin([]byte(realWorldTeaConfig), "alterminal") + out, promoted, err := RemoveLogin([]byte(extraFieldsConfig), "alterminal") if err != nil || promoted != "" { t.Fatalf("want clean removal, promoted=%q err=%v", promoted, err) } @@ -207,10 +218,10 @@ func TestRemoveLoginLastOne(t *testing.T) { } } -func TestSaveTeaConfigFileAtomicAndMode(t *testing.T) { +func TestSaveConfigFileAtomicAndMode(t *testing.T) { dir := t.TempDir() - path := filepath.Join(dir, "tea", "config.yml") - if err := SaveTeaConfigFile(path, []byte("logins: []\n")); err != nil { + path := filepath.Join(dir, "teai", "config.yml") + if err := SaveConfigFile(path, []byte("logins: []\n")); err != nil { t.Fatal(err) } info, err := os.Stat(path) @@ -221,7 +232,7 @@ func TestSaveTeaConfigFileAtomicAndMode(t *testing.T) { t.Fatalf("perm = %v, want 0600", info.Mode().Perm()) } // 再寫一次(覆蓋既有檔),不留暫存殘檔。 - if err := SaveTeaConfigFile(path, []byte("logins: []\n# v2\n")); err != nil { + if err := SaveConfigFile(path, []byte("logins: []\n# v2\n")); err != nil { t.Fatal(err) } entries, _ := os.ReadDir(filepath.Dir(path)) diff --git a/internal/workflow/scan.go b/internal/workflow/scan.go index 0a7e552..a3393a6 100644 --- a/internal/workflow/scan.go +++ b/internal/workflow/scan.go @@ -9,8 +9,17 @@ import ( "sort" ) -// AgentsTeamName 是工作流團隊的名稱(AGENTS.md:調度成員由 Agents 團隊決定)。 -const AgentsTeamName = "Agents" +const ( + // AgentsTeamName 是工作流團隊的名稱(AGENTS.md:調度成員由 Agents 團隊決定)。 + AgentsTeamName = "Agents" + // SupervisorTeamName 是管理者團隊的名稱;成員角色為 supervisor。 + SupervisorTeamName = "Supervisor" + + // RoleSupervisor 是 Supervisor 團隊成員的角色值。 + RoleSupervisor = "supervisor" + // RoleWorker 是 Agents 團隊成員的角色值。 + RoleWorker = "worker" +) // Client 抽象 teai 需要的 Gitea API 讀取操作(僅唯讀)。 // #5 的 internal/gitea HTTP 用戶端實作此介面;workflow 不依賴 HTTP 細節。 @@ -49,7 +58,7 @@ type PullItem struct { Pull PullRequest } -// Scanner 在 Client 之上組合出各命令的資料(whoami/orgs/members/mine/pulls/next)。 +// Scanner 在 Client 之上組合出各命令的資料(whoami/role/orgs/members/mine/pulls/next)。 type Scanner struct { Client Client } @@ -59,6 +68,63 @@ func (s *Scanner) Whoami() (string, error) { return s.Client.CurrentUser() } +// Role 依目前帳號所屬團隊回傳角色:Supervisor 團隊為 supervisor, +// Agents 團隊為 worker。同時屬於兩者時以 supervisor 為準。 +// 兩者都不屬於時回空字串(CLI 輸出 null)。 +func (s *Scanner) Role() (string, error) { + username, err := s.Whoami() + if err != nil { + return "", err + } + orgs, err := s.Orgs() + if err != nil { + return "", err + } + inAgents := false + for _, org := range orgs { + teams, err := s.Client.ListTeams(org) + if err != nil { + return "", err + } + for _, team := range teams { + switch team.Name { + case SupervisorTeamName, AgentsTeamName: + default: + continue + } + ok, err := s.usernameInTeam(team.ID, username) + if err != nil { + return "", err + } + if !ok { + continue + } + if team.Name == SupervisorTeamName { + return RoleSupervisor, nil + } + inAgents = true + } + } + if inAgents { + return RoleWorker, nil + } + return "", nil +} + +// usernameInTeam 檢查帳號是否為該團隊成員。 +func (s *Scanner) usernameInTeam(teamID int64, username string) (bool, error) { + members, err := s.Client.ListTeamMembers(teamID) + if err != nil { + return false, err + } + for _, m := range members { + if m == username { + return true, nil + } + } + return false, nil +} + // Orgs 回傳目前帳號所屬的組織(去重、排序)。 func (s *Scanner) Orgs() ([]string, error) { names, err := s.Client.ListOrgs() diff --git a/internal/workflow/scan_test.go b/internal/workflow/scan_test.go index 278a44f..8eb288f 100644 --- a/internal/workflow/scan_test.go +++ b/internal/workflow/scan_test.go @@ -288,6 +288,91 @@ func TestErrorPropagation(t *testing.T) { } } +func TestRoleWorker(t *testing.T) { + s, _ := newTestScanner() + got, err := s.Role() + if err != nil { + t.Fatal(err) + } + if got != RoleWorker { + t.Errorf("Agents 團隊成員應為 worker,得到 %q", got) + } +} + +func TestRoleSupervisor(t *testing.T) { + s, fc := newTestScanner() + fc.teams["alterminal"] = []Team{ + {ID: 1, Name: "Agents"}, + {ID: 2, Name: "Supervisor"}, + } + fc.teamMembers[2] = []string{"alex"} + got, err := s.Role() + if err != nil { + t.Fatal(err) + } + if got != RoleSupervisor { + t.Errorf("Supervisor 團隊成員應為 supervisor,得到 %q", got) + } +} + +func TestRoleSupervisorTakesPrecedence(t *testing.T) { + s, fc := newTestScanner() + // alex 同時在 Agents(newTestScanner 預設)與 Supervisor。 + fc.teams["alterminal"] = []Team{ + {ID: 1, Name: "Agents"}, + {ID: 2, Name: "Supervisor"}, + } + fc.teamMembers[2] = []string{"alex", "ceo"} + got, err := s.Role() + if err != nil { + t.Fatal(err) + } + if got != RoleSupervisor { + t.Errorf("同時屬於兩者時應為 supervisor,得到 %q", got) + } +} + +func TestRoleNone(t *testing.T) { + s, fc := newTestScanner() + fc.currentUser = "outsider" + got, err := s.Role() + if err != nil { + t.Fatal(err) + } + if got != "" { + t.Errorf("不在 Supervisor/Agents 應回空字串,得到 %q", got) + } +} + +func TestRoleWorkerWhenSupervisorExists(t *testing.T) { + s, fc := newTestScanner() + fc.teams["alterminal"] = []Team{ + {ID: 1, Name: "Agents"}, + {ID: 2, Name: "Supervisor"}, + } + fc.teamMembers[2] = []string{"ceo"} + got, err := s.Role() + if err != nil { + t.Fatal(err) + } + if got != RoleWorker { + t.Errorf("只在 Agents、不在 Supervisor 應為 worker,得到 %q", got) + } +} + +func TestRoleErrorPropagation(t *testing.T) { + s, fc := newTestScanner() + fc.failOn["teams"] = errors.New("boom") + if _, err := s.Role(); err == nil { + t.Errorf("ListTeams 錯誤應向上傳播") + } + delete(fc.failOn, "teams") + fc.failOn["members"] = errors.New("boom") + if _, err := s.Role(); err == nil { + t.Errorf("ListTeamMembers 錯誤應向上傳播") + } +} + func TestPullsDefault(t *testing.T) { s, fc := newTestScanner() fc.pulls["alterminal/teai"] = []PullRequest{