Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8e358185f4 | ||
|
|
8d446ed62b | ||
|
|
d45a8243c8 | ||
|
|
d0334434c5 | ||
|
|
5506daa5f5 | ||
|
|
9a4887a677 | ||
|
|
02fad53aa4 | ||
|
|
5a48fdd590 | ||
|
|
36a50b95ed | ||
|
|
9e699f3b4e |
@@ -17,25 +17,45 @@ teai 是 Gitea 的命令列(CLI)工具,以更完整、更可靠的方式
|
||||
- **git**
|
||||
- Gitea 帳號的 **API token**(設定階段使用;在 Gitea「設定 → 應用程式」產生)
|
||||
|
||||
### 從原始碼建置
|
||||
### 以 go install 安裝(建議)
|
||||
|
||||
自 v0.1.0 起 module 已發佈 tag,可直接以版本安裝(二元檔裝進 `GOBIN`,預設 `~/go/bin`):
|
||||
|
||||
```sh
|
||||
go install gitea.alterminal.com/alterminal/teai/cmd/teai@latest # 最新 tag(亦可 @v<版本> 指定)
|
||||
teai version # 驗證:顯示安裝的 tag 版本(見下方說明)
|
||||
```
|
||||
|
||||
本 module 為公開倉庫,預設 `GOPROXY`/`GOSUMDB` 即可解析(已於乾淨環境驗證)。
|
||||
若所在網路環境無法經公用 proxy 抓取,可改設 `GOPRIVATE=gitea.alterminal.com`
|
||||
(略過 proxy 與 checksum database,直接從 Gitea 抓取)。
|
||||
|
||||
`teai version` 顯示建入的 module 版本,即安裝指定的 tag(如 `v0.1.1`)。
|
||||
注意:讀 build info 顯示安裝版本的支援,自包含該變更的 tag 起(v0.1.1)才生效——
|
||||
v0.1.0 早於此變更,以 `@v0.1.0` 安裝(或此變更前的 `@latest`)仍顯示舊行為 `0.1.0-dev`。
|
||||
|
||||
### 從原始碼建置(開發用)
|
||||
|
||||
開發、離線或想鎖定特定 commit 時,clone 後自行建置:
|
||||
|
||||
```sh
|
||||
git clone https://gitea.alterminal.com/alterminal/teai.git
|
||||
cd teai
|
||||
go build -o teai ./cmd/teai
|
||||
install -Dm755 teai ~/.local/bin/teai # 或複製到任何 PATH 內目錄
|
||||
teai version # 驗證:teai version 0.1.0-dev
|
||||
teai version # 驗證:teai version v0.1.1-0.<時間戳>-<commit>(見下方說明)
|
||||
```
|
||||
|
||||
也可以直接裝進 `GOBIN`(預設 `~/go/bin`;該目錄已在 PATH 時最省事):
|
||||
也可以在 clone 目錄內直接裝進 `GOBIN`:
|
||||
|
||||
```sh
|
||||
go install ./cmd/teai
|
||||
```
|
||||
|
||||
> **注意:** `go install gitea.alterminal.com/alterminal/teai/cmd/teai@latest` 目前**不可用**——
|
||||
> module 尚未發佈任何 tag,`@latest` 會解析到任意中繼 commit(且 #21 修復前 main 建置失敗)。
|
||||
> 請在 clone 目錄內以版本控制的方式建置;發佈 tag 後再提供 `teai@<version>` 安裝路徑。
|
||||
clone 建置時 Go 會把 VCS 狀態寫進 build info:位於 tag 上顯示該 tag、
|
||||
其後的 commit 顯示 pseudo-version(如 `v0.1.1-0.20260912160956-5a48fdd590b2`)、
|
||||
工作樹有修改再附加 `+dirty`;無 VCS 資訊(如從 tarball 解壓建置)時
|
||||
`Main.Version` 為 `(devel)`,此時退回開發版本號 `0.1.0-dev`。
|
||||
|
||||
### 登入設定(首次使用)
|
||||
|
||||
@@ -68,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
|
||||
@@ -97,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。
|
||||
|
||||
@@ -126,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 跟進清單 |
|
||||
@@ -305,7 +330,7 @@ teai whoami # 目前帳號(讀 tea 組態)
|
||||
|
||||
| 碼 | 意義 |
|
||||
| --- | --- |
|
||||
| 0 | 成功(含「沒有工作」→ 輸出 `null`/`[]`) |
|
||||
| 0 | 成功(含「沒有結果」→ 清單類命令輸出 `[]`、`next` 輸出 `null`;#37 判決) |
|
||||
| 2 | 用法錯誤(未知命令/參數) |
|
||||
| 3 | API 錯誤(連線失敗、401、403、5xx) |
|
||||
|
||||
@@ -324,8 +349,12 @@ teai whoami # 目前帳號(讀 tea 組態)
|
||||
已知輸出差異(刻意保留,機器可讀優先):
|
||||
|
||||
- **空清單**:`gitea.py` 的 `_dump` 對空清單**不輸出任何內容**;teai 的清單類命令
|
||||
(`mine`/`pulls`/`milestones` 總覽)空清單輸出 `[]`。對 JSON 消費者來說 `[]` 比「無輸出」更明確,
|
||||
交叉驗證時以此差異為準。`next` 兩者語義相同(無工作 → `null`/「沒有未完成的工作」)。
|
||||
(`mine`/`pulls`/`stalled`/`milestones` 總覽)空清單一律輸出 `[]`(exit 0)。對 JSON
|
||||
消費者來說 `[]` 比「無輸出」更明確(可區分「沒有結果」與「查詢失敗無輸出」),交叉驗證時
|
||||
以此差異為準。`next` 兩者語義相同:無工作時 teai 輸出 `null`(exit 0)、`gitea.py` 印
|
||||
「沒有未完成的工作」。**判決 #37:維持 teai 現行 JSON 一致性(方向 2)**——消費端
|
||||
(agents 倉庫巡邏提示與觸發腳本)應以「輸出 `[]`/`null` 且 exit 0」為「沒有」判據,
|
||||
不得以「無輸出」判斷;`members [--has-work]` 維持逐行帳號、空時無輸出(兩者一致,不受影響)。
|
||||
- **清單欄位集(`mine`/`pulls`)**:`gitea.py` 直接傾倒完整 Gitea API 物件
|
||||
(`_dump` 的副作用,欄位集隨 Gitea 版本浮動);teai 輸出策展摘要欄位
|
||||
(`mine`:repo/number/title/url;`pulls`:number/title/state/author/
|
||||
|
||||
+18
-1
@@ -15,6 +15,7 @@ import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"runtime/debug"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -23,8 +24,24 @@ import (
|
||||
)
|
||||
|
||||
// Version 是目前開發中的版本號。採用語意化版本;正式發佈前以 0 開頭。
|
||||
// 僅作為 fallback:以 `go install module@version` 或納入其他 module 建置時,
|
||||
// buildVersion 會改用 build info 內的 module 版本(見 runVersion)。
|
||||
var Version = "0.1.0-dev"
|
||||
|
||||
// buildVersion 回傳應顯示的版本字串。go install pkg@tag 建置的二元檔
|
||||
// 會在 Main.Version 帶入 tag(如 v0.1.0);clone 目錄內本機建置則帶
|
||||
// pseudo-version(如 v0.1.1-0.<時間戳>-<commit>,工作樹有修改時再附加
|
||||
// "+dirty"),同樣直接顯示。Main.Version 為 "(devel)" 或空(go test 環境、
|
||||
// 無 VCS 資訊的 tarball 建置)時,才退回開發版本號 Version。
|
||||
func buildVersion() string {
|
||||
if info, ok := debug.ReadBuildInfo(); ok {
|
||||
if v := info.Main.Version; v != "" && v != "(devel)" {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return Version
|
||||
}
|
||||
|
||||
// ExitCode 是 Run 回傳的行程結束碼。
|
||||
type ExitCode int
|
||||
|
||||
@@ -437,7 +454,7 @@ func runVersion(env *Env, args []string) error {
|
||||
if len(args) > 0 {
|
||||
return &ErrUsage{Msg: "version 不接受參數"}
|
||||
}
|
||||
fmt.Fprintf(env.Out, "teai version %s\n", Version)
|
||||
fmt.Fprintf(env.Out, "teai version %s\n", buildVersion())
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -117,6 +117,24 @@ func TestWiringStalled(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestWiringStalledEmpty 驗證 stalled 無任何條目時輸出 []、exit 0(#37)。
|
||||
func TestWiringStalledEmpty(t *testing.T) {
|
||||
newStubStalledEnv(t, &stubStalledSource{
|
||||
username: "ceo",
|
||||
orgs: []string{"alterminal"},
|
||||
repos: map[string][]workflow.Repo{"alterminal": {}},
|
||||
issues: map[string]map[int64]workflow.Issue{},
|
||||
comments: map[string][]workflow.Comment{},
|
||||
}, time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC))
|
||||
stdout, _, code := run("stalled")
|
||||
if code != 0 {
|
||||
t.Fatalf("stalled 空情境應回 0,得到 %d(stdout=%q)", code, stdout)
|
||||
}
|
||||
if strings.TrimSpace(stdout) != "[]" {
|
||||
t.Fatalf("stalled 無條目應輸出 [],得到 %q", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWiringStalledHoursFlag 驗證 --hours 覆寫門檻與非正數被攔。
|
||||
func TestWiringStalledHoursFlag(t *testing.T) {
|
||||
freeze := time.Date(2026, 9, 10, 8, 0, 0, 0, time.UTC)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
// version_test.go 驗證版本字串解析:go test 環境 Main.Version 為 "(devel)"
|
||||
// 時應退回開發版本號;tag 與 pseudo-version 行為由建置驗證涵蓋
|
||||
// (go install pkg@tag 顯示 tag;clone 建置顯示 pseudo-version)。
|
||||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestBuildVersionFallsBackToDev 確認本地測試環境(Main.Version == "(devel)")
|
||||
// 退回 Version;同時確保輸出是可讀版本字串(非空、無括號)。
|
||||
func TestBuildVersionFallsBackToDev(t *testing.T) {
|
||||
v := buildVersion()
|
||||
if v == "" {
|
||||
t.Fatalf("buildVersion 不應為空")
|
||||
}
|
||||
if strings.ContainsAny(v, "()") {
|
||||
t.Errorf("buildVersion 不應含內部佔位(括號),得到:%q", v)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunVersionUsesBuildVersion 確認 version 命令輸出 buildVersion 的結果。
|
||||
func TestRunVersionUsesBuildVersion(t *testing.T) {
|
||||
stdout, _, code := run("version")
|
||||
if code != 0 {
|
||||
t.Fatalf("version 應回 0,得到 %d", code)
|
||||
}
|
||||
want := "teai version " + buildVersion()
|
||||
if !strings.Contains(stdout, want) {
|
||||
t.Errorf("version 輸出應含 %q,得到:%q", want, stdout)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -136,7 +136,11 @@ func (s *Scanner) Members(hasWork bool) ([]string, error) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// iterOrgRepos 產出 (org, repoName);空倉庫跳過(沒有內容可掃)。
|
||||
// iterOrgRepos 產出 (org, repoName)。空倉庫(empty)**不**跳過:Gitea 允許
|
||||
// 空倉庫持有 open issue(如「初始化項目」型工作),gitea.py 的
|
||||
// for_all_repositories(list_my_followup_issues/for_all_organizations)
|
||||
// 掃 issue 時不檢查 empty;只有 PR 掃描(iterOpenPulls)、stalled 與
|
||||
// milestone overview 才跳過空倉庫。
|
||||
func (s *Scanner) iterOrgRepos() ([][2]string, error) {
|
||||
orgs, err := s.Orgs()
|
||||
if err != nil {
|
||||
@@ -149,7 +153,7 @@ func (s *Scanner) iterOrgRepos() ([][2]string, error) {
|
||||
return nil, err
|
||||
}
|
||||
for _, repo := range repos {
|
||||
if repo.Name == "" || repo.Empty {
|
||||
if repo.Name == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, [2]string{org, repo.Name})
|
||||
|
||||
@@ -164,6 +164,51 @@ func TestNextLayerFallbackToIssue(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestNextPicksIssueInEmptyRepo 回歸測試(#43):空倉庫(empty=true)中
|
||||
// 分派給目前帳號的 open issue 應被第三層拾取——gitea.py 的
|
||||
// for_all_repositories 掃 issue 不跳過空倉庫(「初始化項目」型工作)。
|
||||
func TestNextPicksIssueInEmptyRepo(t *testing.T) {
|
||||
s, fc := newTestScanner()
|
||||
fc.repos["alterminal"] = []Repo{
|
||||
{Name: "teai"},
|
||||
{Name: "colony-tick-demo", Empty: true},
|
||||
}
|
||||
fc.issues["alterminal/colony-tick-demo"] = []Issue{
|
||||
{
|
||||
Number: 1, Title: "初始化項目", UpdatedAt: "2026-09-08T00:00:00Z",
|
||||
Assignees: []User{*mkUser("alex")},
|
||||
Repository: &Repository{FullName: "alterminal/colony-tick-demo"},
|
||||
HTMLURL: "https://gitea.alterminal.com/alterminal/colony-tick-demo/issues/1",
|
||||
},
|
||||
}
|
||||
work, err := s.Next()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if work == nil || work.Type != "issue" || work.Number != 1 || work.Repo != "alterminal/colony-tick-demo" {
|
||||
t.Errorf("應拾取空倉庫中的 issue #1,得到 %+v", work)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMineIncludesEmptyRepoIssues 回歸測試(#43):mine 同樣不因倉庫為空而漏列。
|
||||
func TestMineIncludesEmptyRepoIssues(t *testing.T) {
|
||||
s, fc := newTestScanner()
|
||||
fc.repos["alterminal"] = []Repo{
|
||||
{Name: "teai"},
|
||||
{Name: "colony-tick-demo", Empty: true},
|
||||
}
|
||||
fc.issues["alterminal/colony-tick-demo"] = []Issue{
|
||||
{Number: 1, Title: "初始化項目", UpdatedAt: "2026-09-08T00:00:00Z", Assignees: []User{*mkUser("alex")}},
|
||||
}
|
||||
issues, err := s.Mine()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(issues) != 1 || issues[0].Num() != 1 {
|
||||
t.Errorf("mine 應列出空倉庫中的 issue #1,得到 %d 件", len(issues))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNextNone(t *testing.T) {
|
||||
s, _ := newTestScanner()
|
||||
work, err := s.Next()
|
||||
|
||||
Reference in New Issue
Block a user