feat:新增 organizations 指令(#45)
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 一致。
This commit is contained in:
@@ -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