From 8d446ed62b92d94942d56273a8401e70355b1a5c Mon Sep 17 00:00:00 2001 From: ceo Date: Sun, 13 Sep 2026 19:32:34 +0800 Subject: [PATCH] =?UTF-8?q?feat=EF=BC=9A=E6=96=B0=E5=A2=9E=20organizations?= =?UTF-8?q?=20=E6=8C=87=E4=BB=A4=EF=BC=88#45=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tea organizations 對等的日常操作: - organizations list:GET /user/orgs,輸出 name/full_name/website/ location/description/visibility(與 teai orgs 工作流命令同一 端點,但輸出完整欄位) - organizations create:POST /orgs,--name 必填,可選 --full-name/ --description/--website/--location/--visibility;寫入需 --yes - organizations delete:DELETE /orgs/{org},組織名為位置參數 (tea 相容);寫入需 --yes 驗收測試涵蓋端點、payload、--yes 閘門(未確認不發請求)與 值域檢查;並以本機建置對 Gitea 實測 list 輸出欄位與 tea 一致。 --- README.md | 9 +- internal/cli/dailyops_commands.go | 134 +++++++++++++++- internal/cli/organizations_commands_test.go | 169 ++++++++++++++++++++ internal/dailyops/dailyops.go | 61 +++++++ 4 files changed, 370 insertions(+), 3 deletions(-) create mode 100644 internal/cli/organizations_commands_test.go diff --git a/README.md b/README.md index e898189..0cb5f04 100644 --- a/README.md +++ b/README.md @@ -88,7 +88,7 @@ teai whoami # 驗證:應輸出 {"username":"<你的帳號>"} ### 日常操作(tea 對等指令;#3 不避開 tea 已有功能;#8 已實作) -issues/pulls/labels/milestones/releases/repos/api 的日常操作,自行實作、輸出一致 JSON、共用同一套認證與全域選項。介面語義以 `tea`/Gitea API 為相容參考。已實作: +issues/pulls/labels/milestones/releases/repos/organizations/api 的日常操作,自行實作、輸出一致 JSON、共用同一套認證與全域選項。介面語義以 `tea`/Gitea API 為相容參考。已實作: ```sh # issues @@ -117,11 +117,15 @@ teai milestones list --repo / [--state open|closed|all] teai milestones create --repo / --title … [--description …] [--due RFC3339] --yes teai releases list --repo / teai repos list [--org ] +# 組織管理(tea organizations 對等;#45) +teai organizations list # 列出自己可存取的組織(GET /user/orgs) +teai organizations create --name [--full-name …] [--description …] [--website …] [--location …] [--visibility public|limited|private] --yes +teai organizations delete --yes # 刪除組織(不可逆;DELETE /orgs/{org}) # 任意 API 呼叫(逃生口) teai api [--method GET|POST|PATCH|DELETE] [--data ''] [--yes] ``` -> `--yes` 閘門:所有寫入類命令(create/close/comment/merge/approve/labels create/milestones create/api 非 GET 動詞)未帶 `--yes` 時回 exit 2(用法錯誤),且不發出任何 HTTP 請求。 +> `--yes` 閘門:所有寫入類命令(create/close/comment/merge/approve/labels create/milestones create/organizations create/delete/api 非 GET 動詞)未帶 `--yes` 時回 exit 2(用法錯誤),且不發出任何 HTTP 請求。 > `teai api` 的 path 內含查詢字串(如 `/repos/a/b/issues?state=closed`)時會正確傳遞。 > `teai pulls checkout` 是本機唯讀輔助:同倉庫 head 在 `--dir`(預設當前目錄)的既有工作樹 `git fetch origin && git checkout -B FETCH_HEAD`;跨倉庫 head 需 `--dir`(新目錄)執行 `git clone --branch `。teai 不代管 git 認證,私有倉庫請自備 credential helper。 @@ -146,6 +150,7 @@ teai [全域選項] <命令> [參數] | `teai login list\|add\|default\|remove` | — | 管理 tea 相容登入組態(見下) | | `teai whoami` | `get_current_username` | 目前帳號 | | `teai orgs` | `for_all_organizations` | 我所屬的組織 | +| `teai organizations list\|create\|delete` | — | 組織管理(tea 對等日常操作;`orgs` 是工作流精簡版) | | `teai members [--has-work]` | `members [--has-work]` | Agents 團隊成員;`--has-work` 只列有未完成工作者 | | `teai mine` | `mine` | 分派給我、待跟進的 issues | | `teai pulls [--mine\|--reviewer] [--repo /]` | `pulls` 系列 | 跨倉庫 PR 跟進清單 | diff --git a/internal/cli/dailyops_commands.go b/internal/cli/dailyops_commands.go index 18e47a8..ddde810 100644 --- a/internal/cli/dailyops_commands.go +++ b/internal/cli/dailyops_commands.go @@ -1,5 +1,5 @@ // dailyops_commands.go — 日常操作命令的 CLI 接線(#8):issues、pulls、 -// labels、milestones、releases、repos、api(tea 對等指令)。 +// labels、milestones、releases、repos、organizations、api(tea 對等指令)。 // // 規則: // - 寫入類命令(create/close/comment/merge…)一律要求 --yes, @@ -33,6 +33,7 @@ func init() { register(&command{name: "milestones", usage: "里程碑(跨倉庫總覽;list/create 管理單倉庫)", run: runMilestones}) register(&command{name: "releases", usage: "發佈清單(list)", run: runReleases}) register(&command{name: "repos", usage: "倉庫查詢(list)", run: runRepos}) + register(&command{name: "organizations", usage: "組織管理(list/create/delete;tea organizations 對等)", run: runOrganizations}) register(&command{name: "api", usage: "任意 API 呼叫(--method/--data;寫入動詞需 --yes)", run: runAPIDirect}) } @@ -1097,3 +1098,134 @@ func runAPIDirect(env *Env, args []string) error { } return err } + +// ---- organizations(#45,tea organizations 對等)---- + +// orgListFields 是 organizations 清單的欄位定義。 +func orgListFields() []gitea.Field { + return []gitea.Field{ + {Name: "name"}, {Name: "full_name"}, {Name: "website"}, + {Name: "location"}, {Name: "description"}, {Name: "visibility"}, + } +} + +// runOrganizations:teai organizations [list|create|delete] …。 +// tea 對等:organizations list 列出自己可存取的組織;create/delete 是 +// 寫入操作,比照慣例要求 --yes。 +func runOrganizations(env *Env, args []string) error { + if len(args) == 0 { + return &ErrUsage{Msg: "organizations 需要子命令:list | create | delete"} + } + sub, rest := args[0], args[1:] + switch sub { + case "list": + return runOrganizationsList(env, rest) + case "create": + return runOrganizationsCreate(env, rest) + case "delete": + return runOrganizationsDelete(env, rest) + default: + return &ErrUsage{Msg: fmt.Sprintf("organizations 未知子命令 %q(可用:list | create | delete)", sub)} + } +} + +// runOrganizationsList:teai organizations list。 +// GET /user/orgs(與 teai orgs 工作流命令同一端點,但輸出完整欄位)。 +func runOrganizationsList(env *Env, args []string) error { + fs := flag.NewFlagSet("teai organizations list", flag.ContinueOnError) + fs.SetOutput(env.Err) + if err := parseFlags(fs, args); err != nil { + return err + } + if fs.NArg() != 0 { + return &ErrUsage{Msg: "organizations list 不接受位置參數"} + } + ops, err := newOpsClient(env) + if err != nil { + return err + } + orgs, err := ops.ListMyOrganizations(context.Background()) + if err != nil { + return err + } + p := printer(env) + rows := make([]gitea.Values, 0, len(orgs)) + for i := range orgs { + org := &orgs[i] + rows = append(rows, gitea.Values{ + org.Account(), org.FullName, org.Website, + org.Location, org.Description, org.Visibility, + }) + } + return p.PrintList(orgListFields(), rows) +} + +// runOrganizationsCreate:teai organizations create --name … [--full-name …] +// [--description …] [--website …] [--location …] [--visibility public|limited|private] --yes。 +// tea 以位置參數給組織名;teai 慣例用 --name(語義等價,命名與其他 create 一致)。 +func runOrganizationsCreate(env *Env, args []string) error { + fs := flag.NewFlagSet("teai organizations create", flag.ContinueOnError) + fs.SetOutput(env.Err) + name := fs.String("name", "", "組織帳號名(必填,同 tea 的位置參數)") + fullName := fs.String("full-name", "", "顯示名稱") + desc := fs.String("description", "", "說明") + website := fs.String("website", "", "網站") + location := fs.String("location", "", "所在地") + visibility := fs.String("visibility", "", "可見度:public|limited|private(預設服務端決定)") + yes := fs.Bool("yes", false, "確認寫入") + if err := parseFlags(fs, args); err != nil { + return err + } + if strings.TrimSpace(*name) == "" { + return &ErrUsage{Msg: "organizations create 需要 --name"} + } + switch *visibility { + case "", "public", "limited", "private": + default: + return &ErrUsage{Msg: "organizations create 的 --visibility 需為 public|limited|private"} + } + if err := requireYes(*yes, "organizations create"); err != nil { + return err + } + ops, err := newOpsClient(env) + if err != nil { + return err + } + org, err := ops.CreateOrganization(context.Background(), *name, *fullName, *desc, *website, *location, *visibility) + if err != nil { + return err + } + return printer(env).PrintOne(orgListFields(), gitea.Values{ + org.Account(), org.FullName, org.Website, + org.Location, org.Description, org.Visibility, + }) +} + +// runOrganizationsDelete:teai organizations delete --yes。 +// tea 相容:組織名作為位置參數。 +func runOrganizationsDelete(env *Env, args []string) error { + fs := flag.NewFlagSet("teai organizations delete", flag.ContinueOnError) + fs.SetOutput(env.Err) + yes := fs.Bool("yes", false, "確認寫入") + if err := parseFlags(fs, args); err != nil { + return err + } + if fs.NArg() != 1 { + return &ErrUsage{Msg: "organizations delete 需要恰好一個組織名"} + } + orgName := strings.TrimSpace(fs.Arg(0)) + if orgName == "" { + return &ErrUsage{Msg: "organizations delete 需要組織名"} + } + if err := requireYes(*yes, "organizations delete"); err != nil { + return err + } + ops, err := newOpsClient(env) + if err != nil { + return err + } + if err := ops.DeleteOrganization(context.Background(), orgName); err != nil { + return err + } + return printer(env).PrintOne([]gitea.Field{{Name: "name"}, {Name: "state"}}, gitea.Values{orgName, "deleted"}) +} diff --git a/internal/cli/organizations_commands_test.go b/internal/cli/organizations_commands_test.go new file mode 100644 index 0000000..d9b4a98 --- /dev/null +++ b/internal/cli/organizations_commands_test.go @@ -0,0 +1,169 @@ +// organizations_commands_test.go — #45 的驗收測試:tea organizations 對等指令 +// list/create/delete。讀取 list 打對端點;寫入 create/delete 未帶 --yes +// 不得發出任何請求,帶 --yes 時打對端點與 payload。 +package cli + +import ( + "fmt" + "net/http" + "strings" + "testing" +) + +// TestOrganizationsListGetsUserOrgs 驗證 organizations list: +// GET /user/orgs,輸出 name(username 優先)與 full_name 等欄位。 +func TestOrganizationsListGetsUserOrgs(t *testing.T) { + var gotMethod, gotPath string + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + fmt.Fprint(w, `[{"username":"alterminal","name":"alterminal","full_name":"Alterminal Co., Ltd.","website":"","location":"","description":"","visibility":"public"},{"username":"","name":"fallback-org","full_name":"Fallback Org","visibility":"public"}]`) + }) + stdout, _, code := run("organizations", "list") + if code != 0 { + t.Fatalf("organizations list 應回 0,得到 %d", code) + } + if gotMethod != "GET" || !strings.HasSuffix(gotPath, "/user/orgs") { + t.Errorf("應 GET /user/orgs,得到 %s %s", gotMethod, gotPath) + } + if !strings.Contains(stdout, `"name":"alterminal"`) || !strings.Contains(stdout, `"full_name":"Alterminal Co., Ltd."`) { + t.Errorf("輸出應含 name 與 full_name,得到:%s", stdout) + } + // username 缺漏時回退 name 欄位(同 workflow.Org.Account 取值順序)。 + if !strings.Contains(stdout, `"name":"fallback-org"`) { + t.Errorf("username 缺漏時 name 应回退 name 欄位,得到:%s", stdout) + } +} + +// TestOrganizationsListRejectsPositionalArgs 驗證 list 不接受位置參數(回 2)。 +func TestOrganizationsListRejectsPositionalArgs(t *testing.T) { + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("不應發出任何請求") + }) + _, stderr, code := run("organizations", "list", "alterminal") + if code != 2 { + t.Errorf("list 帶位置參數應回 2,得到 %d", code) + } + if !strings.Contains(stderr, "位置參數") { + t.Errorf("錯誤訊息應提及位置參數,得到:%s", stderr) + } +} + +// TestOrganizationsCreateRequiresYes 驗證 create 未帶 --yes:回 2 且不發請求。 +func TestOrganizationsCreateRequiresYes(t *testing.T) { + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("未帶 --yes 不得發出請求(%s %s)", r.Method, r.URL.Path) + }) + _, stderr, code := run("organizations", "create", "--name", "neworg") + if code != 2 { + t.Errorf("create 未帶 --yes 應回 2,得到 %d", code) + } + if !strings.Contains(stderr, "--yes") { + t.Errorf("錯誤訊息應提及 --yes,得到:%s", stderr) + } +} + +// TestOrganizationsCreateRequiresName 驗證 create 缺 --name:回 2(且在 --yes 閘門之前檢查)。 +func TestOrganizationsCreateRequiresName(t *testing.T) { + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("缺 --name 不應發出請求") + }) + _, stderr, code := run("organizations", "create", "--yes") + if code != 2 { + t.Errorf("create 缺 --name 應回 2,得到 %d", code) + } + if !strings.Contains(stderr, "--name") { + t.Errorf("錯誤訊息應提及 --name,得到:%s", stderr) + } +} + +// TestOrganizationsCreatePostsOrg 驗證 create --yes: +// POST /orgs,payload 帶 username 與 full_name;visibility 空時不傳。 +func TestOrganizationsCreatePostsOrg(t *testing.T) { + var gotMethod, gotPath, gotBody string + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + buf := make([]byte, 1024) + n, _ := r.Body.Read(buf) + gotBody = string(buf[:n]) + fmt.Fprint(w, `{"username":"neworg","name":"neworg","full_name":"New Org","website":"https://x","location":"TW","description":"d","visibility":"public"}`) + }) + stdout, _, code := run("organizations", "create", "--yes", "--name", "neworg", "--full-name", "New Org", "--website", "https://x", "--location", "TW", "--description", "d") + if code != 0 { + t.Fatalf("create --yes 應回 0,得到 %d", code) + } + if gotMethod != "POST" || !strings.HasSuffix(gotPath, "/orgs") { + t.Errorf("應 POST /orgs,得到 %s %s", gotMethod, gotPath) + } + for _, want := range []string{`"username":"neworg"`, `"full_name":"New Org"`, `"website":"https://x"`, `"location":"TW"`, `"description":"d"`} { + if !strings.Contains(gotBody, want) { + t.Errorf("body 應含 %s,得到:%s", want, gotBody) + } + } + if strings.Contains(gotBody, "visibility") { + t.Errorf("未指定 --visibility 時 body 不應含 visibility,得到:%s", gotBody) + } + if !strings.Contains(stdout, `"name":"neworg"`) || !strings.Contains(stdout, `"full_name":"New Org"`) { + t.Errorf("輸出應含 name 與 full_name,得到:%s", stdout) + } +} + +// TestOrganizationsCreateRejectsBadVisibility 驗證 --visibility 值域檢查(回 2,不發請求)。 +func TestOrganizationsCreateRejectsBadVisibility(t *testing.T) { + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("值域檢查失敗不應發出請求") + }) + _, stderr, code := run("organizations", "create", "--yes", "--name", "neworg", "--visibility", "secret") + if code != 2 { + t.Errorf("--visibility secret 應回 2,得到 %d", code) + } + if !strings.Contains(stderr, "visibility") { + t.Errorf("錯誤訊息應提及 visibility,得到:%s", stderr) + } +} + +// TestOrganizationsDeleteRequiresYes 驗證 delete 未帶 --yes:回 2 且不發請求。 +func TestOrganizationsDeleteRequiresYes(t *testing.T) { + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("未帶 --yes 不得發出請求(%s %s)", r.Method, r.URL.Path) + }) + _, stderr, code := run("organizations", "delete", "oldorg") + if code != 2 { + t.Errorf("delete 未帶 --yes 應回 2,得到 %d", code) + } + if !strings.Contains(stderr, "--yes") { + t.Errorf("錯誤訊息應提及 --yes,得到:%s", stderr) + } +} + +// TestOrganizationsDeleteOrg 驗證 delete --yes:DELETE /orgs/{org}。 +func TestOrganizationsDeleteOrg(t *testing.T) { + var gotMethod, gotPath string + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + gotMethod, gotPath = r.Method, r.URL.Path + w.WriteHeader(http.StatusNoContent) + }) + stdout, _, code := run("organizations", "delete", "--yes", "oldorg") + if code != 0 { + t.Fatalf("delete --yes 應回 0,得到 %d", code) + } + if gotMethod != "DELETE" || !strings.HasSuffix(gotPath, "/orgs/oldorg") { + t.Errorf("應 DELETE /orgs/oldorg,得到 %s %s", gotMethod, gotPath) + } + if !strings.Contains(stdout, `"state":"deleted"`) { + t.Errorf("輸出應含 state=deleted,得到:%s", stdout) + } +} + +// TestOrganizationsUnknownSubcommand 驗證未知子命令回 2。 +func TestOrganizationsUnknownSubcommand(t *testing.T) { + run := opsTestServer(t, func(w http.ResponseWriter, r *http.Request) { + t.Errorf("未知子命令不應發出請求") + }) + _, stderr, code := run("organizations", "rename") + if code != 2 { + t.Errorf("未知子命令應回 2,得到 %d", code) + } + if !strings.Contains(stderr, "organizations") { + t.Errorf("錯誤訊息應提及 organizations,得到:%s", stderr) + } +} diff --git a/internal/dailyops/dailyops.go b/internal/dailyops/dailyops.go index f2d83e6..2f7a89a 100644 --- a/internal/dailyops/dailyops.go +++ b/internal/dailyops/dailyops.go @@ -470,3 +470,64 @@ func (o *Ops) CallAPIDirect(ctx context.Context, method, path, rawQuery string, } return o.Client.DoRaw(ctx, method, path, rawQuery, data) } + +// ---- organizations(#45,tea organizations 對等)---- + +// Organization 是帳號所屬/可存取組織的精簡欄位 +// (GET /user/orgs、POST /orgs 回應共用)。name 是帳號名、 +// full_name 是顯示名稱(如 "Alterminal Co., Ltd."),兩者不同。 +type Organization struct { + UserName string `json:"username"` + Name string `json:"name"` + FullName string `json:"full_name"` + Website string `json:"website"` + Location string `json:"location"` + Description string `json:"description"` + Visibility string `json:"visibility"` +} + +// Account 回傳組織帳號名:先 username,缺漏時回退 name +// (同 workflow.Org.Account 的取值順序)。 +func (o *Organization) Account() string { + if o.UserName != "" { + return o.UserName + } + return o.Name +} + +// ListMyOrganizations 列出自己可存取的組織(GET /user/orgs,分頁)。 +// tea organizations list 的對等端點;teai orgs(工作流)輸出帳號清單, +// 本指令輸出完整欄位供日常操作使用。 +func (o *Ops) ListMyOrganizations(ctx context.Context) ([]Organization, error) { + var orgs []Organization + if err := o.Client.ListAll(ctx, "/user/orgs", gitea.ListOptions{}, &orgs); err != nil { + return nil, err + } + return orgs, nil +} + +// CreateOrganization 建立組織(POST /orgs)。name 必填;其餘欄位可空。 +// visibility:public|limited|private(空值交由服務端預設)。 +func (o *Ops) CreateOrganization(ctx context.Context, name, fullName, description, website, location, visibility string) (*Organization, error) { + payload := map[string]any{ + "username": name, + "full_name": fullName, + "description": description, + "website": website, + "location": location, + } + if visibility != "" { + payload["visibility"] = visibility + } + var org Organization + if err := o.Client.DoJSON(ctx, "POST", "/orgs", "", payload, &org); err != nil { + return nil, err + } + return &org, nil +} + +// DeleteOrganization 刪除組織(DELETE /orgs/{org})。 +func (o *Ops) DeleteOrganization(ctx context.Context, org string) error { + path := "/orgs/" + url.PathEscape(org) + return o.Client.DoJSON(ctx, "DELETE", path, "", nil, nil) +} -- 2.54.0