Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d89caf4af2 | ||
|
|
d45a8243c8 | ||
|
|
d0334434c5 | ||
|
|
5506daa5f5 | ||
|
|
9a4887a677 | ||
|
|
5a48fdd590 |
@@ -17,25 +17,45 @@ teai 是 Gitea 的命令列(CLI)工具,以更完整、更可靠的方式
|
|||||||
- **git**
|
- **git**
|
||||||
- Gitea 帳號的 **API token**(設定階段使用;在 Gitea「設定 → 應用程式」產生)
|
- 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
|
```sh
|
||||||
git clone https://gitea.alterminal.com/alterminal/teai.git
|
git clone https://gitea.alterminal.com/alterminal/teai.git
|
||||||
cd teai
|
cd teai
|
||||||
go build -o teai ./cmd/teai
|
go build -o teai ./cmd/teai
|
||||||
install -Dm755 teai ~/.local/bin/teai # 或複製到任何 PATH 內目錄
|
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
|
```sh
|
||||||
go install ./cmd/teai
|
go install ./cmd/teai
|
||||||
```
|
```
|
||||||
|
|
||||||
> **注意:** `go install gitea.alterminal.com/alterminal/teai/cmd/teai@latest` 目前**不可用**——
|
clone 建置時 Go 會把 VCS 狀態寫進 build info:位於 tag 上顯示該 tag、
|
||||||
> module 尚未發佈任何 tag,`@latest` 會解析到任意中繼 commit(且 #21 修復前 main 建置失敗)。
|
其後的 commit 顯示 pseudo-version(如 `v0.1.1-0.20260912160956-5a48fdd590b2`)、
|
||||||
> 請在 clone 目錄內以版本控制的方式建置;發佈 tag 後再提供 `teai@<version>` 安裝路徑。
|
工作樹有修改再附加 `+dirty`;無 VCS 資訊(如從 tarball 解壓建置)時
|
||||||
|
`Main.Version` 為 `(devel)`,此時退回開發版本號 `0.1.0-dev`。
|
||||||
|
|
||||||
### 登入設定(首次使用)
|
### 登入設定(首次使用)
|
||||||
|
|
||||||
|
|||||||
+18
-1
@@ -15,6 +15,7 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"runtime/debug"
|
||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -23,8 +24,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Version 是目前開發中的版本號。採用語意化版本;正式發佈前以 0 開頭。
|
// Version 是目前開發中的版本號。採用語意化版本;正式發佈前以 0 開頭。
|
||||||
|
// 僅作為 fallback:以 `go install module@version` 或納入其他 module 建置時,
|
||||||
|
// buildVersion 會改用 build info 內的 module 版本(見 runVersion)。
|
||||||
var Version = "0.1.0-dev"
|
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 回傳的行程結束碼。
|
// ExitCode 是 Run 回傳的行程結束碼。
|
||||||
type ExitCode int
|
type ExitCode int
|
||||||
|
|
||||||
@@ -437,7 +454,7 @@ func runVersion(env *Env, args []string) error {
|
|||||||
if len(args) > 0 {
|
if len(args) > 0 {
|
||||||
return &ErrUsage{Msg: "version 不接受參數"}
|
return &ErrUsage{Msg: "version 不接受參數"}
|
||||||
}
|
}
|
||||||
fmt.Fprintf(env.Out, "teai version %s\n", Version)
|
fmt.Fprintf(env.Out, "teai version %s\n", buildVersion())
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,7 +136,11 @@ func (s *Scanner) Members(hasWork bool) ([]string, error) {
|
|||||||
return out, nil
|
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) {
|
func (s *Scanner) iterOrgRepos() ([][2]string, error) {
|
||||||
orgs, err := s.Orgs()
|
orgs, err := s.Orgs()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -149,7 +153,7 @@ func (s *Scanner) iterOrgRepos() ([][2]string, error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
for _, repo := range repos {
|
for _, repo := range repos {
|
||||||
if repo.Name == "" || repo.Empty {
|
if repo.Name == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
out = append(out, [2]string{org, repo.Name})
|
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) {
|
func TestNextNone(t *testing.T) {
|
||||||
s, _ := newTestScanner()
|
s, _ := newTestScanner()
|
||||||
work, err := s.Next()
|
work, err := s.Next()
|
||||||
|
|||||||
@@ -530,7 +530,11 @@ func considerIssue(src Source, org, repo string, issue Issue, comments []Comment
|
|||||||
case lastCommentBy == "":
|
case lastCommentBy == "":
|
||||||
reason = "no-commenter"
|
reason = "no-commenter"
|
||||||
}
|
}
|
||||||
if len(children) > 0 && anyOpen(children) {
|
if len(children) > 0 {
|
||||||
|
// 父追蹤項一律標 parent-tracking(agents #44,與 gitea.py 同步):
|
||||||
|
// 有 open 子項在推進時,父項留言閒置屬等待子項;子項全關閉時只剩
|
||||||
|
// 父項收尾(回報驗收並關閉),兩者都不是 assignee 停滯。統一標記
|
||||||
|
// 讓「父項收尾」規則能正確匹配,不退回通用原因(如 assignee-idle)。
|
||||||
reason = "parent-tracking"
|
reason = "parent-tracking"
|
||||||
}
|
}
|
||||||
item := Item{
|
item := Item{
|
||||||
@@ -606,15 +610,6 @@ func considerPull(src Source, org, repo string, pr PullRequest, comments []Comme
|
|||||||
return item, true
|
return item, true
|
||||||
}
|
}
|
||||||
|
|
||||||
func anyOpen(children []Child) bool {
|
|
||||||
for _, c := range children {
|
|
||||||
if c.State == "open" {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// round1 舍入到小數一位(對應 Python round(x, 1))。
|
// round1 舍入到小數一位(對應 Python round(x, 1))。
|
||||||
// 經 strconv 的十進位正確舍入(半偶數),與 Python 的浮點 round 行為一致,
|
// 經 strconv 的十進位正確舍入(半偶數),與 Python 的浮點 round 行為一致,
|
||||||
// 避免 (x*10+0.5) 截斷在 .x5 邊界產生偏差。
|
// 避免 (x*10+0.5) 截斷在 .x5 邊界產生偏差。
|
||||||
|
|||||||
@@ -42,6 +42,9 @@ func (f *fakeSource) OrganizationRepositories(org string) ([]Repo, error) {
|
|||||||
func (f *fakeSource) OpenIssues(owner, repo string) ([]Issue, error) {
|
func (f *fakeSource) OpenIssues(owner, repo string) ([]Issue, error) {
|
||||||
var out []Issue
|
var out []Issue
|
||||||
for _, i := range f.issues[owner+"/"+repo] {
|
for _, i := range f.issues[owner+"/"+repo] {
|
||||||
|
if i.State == "closed" {
|
||||||
|
continue // 對齊真實 API:OpenIssues 只回 open;closed 條目僅供 TryGetIssue 解引用。
|
||||||
|
}
|
||||||
out = append(out, i)
|
out = append(out, i)
|
||||||
}
|
}
|
||||||
// 對齊真實 API:issues 依 id(=number)遞增回傳,消除 map 迭代隨機性。
|
// 對齊真實 API:issues 依 id(=number)遞增回傳,消除 map 迭代隨機性。
|
||||||
@@ -245,6 +248,46 @@ func TestScanStalledNudgeCooldown(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestScanStalledParentWrapup(teai #47,同步 agents #44):子項全關閉的父追蹤項
|
||||||
|
// 不退回通用原因(assignee-idle),一律標 parent-tracking——「父項收尾」情境。
|
||||||
|
func TestScanStalledParentWrapup(t *testing.T) {
|
||||||
|
now := at(0)
|
||||||
|
// 父項 3 最後留言來自 assignee ceo(舊行為會誤標 assignee-idle);
|
||||||
|
// 唯一子項 7 已關閉,父子最後活動皆 5 小時前。
|
||||||
|
src := &fakeSource{
|
||||||
|
orgs: []string{"alterminal"},
|
||||||
|
repos: map[string][]Repo{"alterminal": repoWith("teai", true)},
|
||||||
|
issues: map[string]map[int64]Issue{
|
||||||
|
"alterminal/teai": {
|
||||||
|
3: {Number: 3, Title: "父", State: "open", Body: "子項:#7", CreatedAt: iso(-700), UpdatedAt: iso(-300), Assignees: []User{{UserName: "ceo"}}, Repository: &Repository{FullName: "alterminal/teai"}},
|
||||||
|
7: {Number: 7, Title: "子", State: "closed", CreatedAt: iso(-650), UpdatedAt: iso(-300), Repository: &Repository{FullName: "alterminal/teai"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
comments: map[string][]Comment{
|
||||||
|
"alterminal/teai/3": {{User: &User{UserName: "ceo"}, CreatedAt: iso(-300)}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
got, err := ScanStalled(src, "admin", 4.0, now)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ScanStalled: %v", err)
|
||||||
|
}
|
||||||
|
var parent *Item
|
||||||
|
for i := range got {
|
||||||
|
if got[i].Number == 3 {
|
||||||
|
parent = &got[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if parent == nil {
|
||||||
|
t.Fatalf("父項 3 應列入(5h 無活動),got %v", got)
|
||||||
|
}
|
||||||
|
if parent.Reason != "parent-tracking" {
|
||||||
|
t.Errorf("子項全關閉的父項 reason = %q, want parent-tracking", parent.Reason)
|
||||||
|
}
|
||||||
|
if len(parent.ChildActivity) == 0 {
|
||||||
|
t.Errorf("父項 3 應附 child_activity,got %v", parent.ChildActivity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestScanStalledParentTracking 父追蹤項:子項活動計入 last_activity、reason 標記。
|
// TestScanStalledParentTracking 父追蹤項:子項活動計入 last_activity、reason 標記。
|
||||||
func TestScanStalledParentTracking(t *testing.T) {
|
func TestScanStalledParentTracking(t *testing.T) {
|
||||||
now := at(0)
|
now := at(0)
|
||||||
|
|||||||
Reference in New Issue
Block a user