Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e358185f4 | ||
|
|
8d446ed62b |
@@ -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 <owner>/<repo> [--state open|closed|all]
|
||||
teai milestones create --repo <owner>/<repo> --title … [--description …] [--due RFC3339] --yes
|
||||
teai releases list --repo <owner>/<repo>
|
||||
teai repos list [--org <org>]
|
||||
# 組織管理(tea organizations 對等;#45)
|
||||
teai organizations list # 列出自己可存取的組織(GET /user/orgs)
|
||||
teai organizations create --name <org> [--full-name …] [--description …] [--website …] [--location …] [--visibility public|limited|private] --yes
|
||||
teai organizations delete <org> --yes # 刪除組織(不可逆;DELETE /orgs/{org})
|
||||
# 任意 API 呼叫(逃生口)
|
||||
teai api <path> [--method GET|POST|PATCH|DELETE] [--data '<json>'] [--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 <ref> && git checkout -B <ref> FETCH_HEAD`;跨倉庫 head 需 `--dir`(新目錄)執行 `git clone --branch <ref> <clone_url>`。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 <owner>/<repo>]` | `pulls` 系列 | 跨倉庫 PR 跟進清單 |
|
||||
|
||||
@@ -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 <name> --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"})
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user