internal/workflow + cli:gitea.py 工作流命令 Go 化(#6)

- internal/workflow:Client 介面+判定語義(最後留言、作者/審核者跟進、
  assignee 交集、最久未更新優先),含假 client 單元測試
- internal/workflow/giteaapi.go:以 internal/gitea(#5)實作 Client
- internal/cli:註冊 whoami/orgs/members/mine/pulls/next;全域選項允許
  出現在命令之後;結束碼 0/2/3
- 與 gitea.py 交叉驗證:next/mine/pulls/members(--has-work) 拾取一致
- 依 #5 分支 feature/issue5-gitea-client 為基底
This commit is contained in:
2026-09-10 08:39:35 +08:00
parent 6951817313
commit 5583a00bb5
10 changed files with 1604 additions and 6 deletions
+351
View File
@@ -0,0 +1,351 @@
// Package workflow 實作 AGENTS.md 工作流判定:跟進、審核、next 拾取與排序。
//
// 行為基準是 alterminal/agents 的 scripts/lib/gitea.py;判定規則以 AGENTS.md
// 為唯一來源,本套件只實作、不改義。API 存取以 Client 介面抽象,HTTP 實作
// 由 internal/gitea(#5 基礎建設)提供;測試以假 client 注入。
package workflow
import (
"sort"
)
// AgentsTeamName 是工作流團隊的名稱(AGENTS.md:調度成員由 Agents 團隊決定)。
const AgentsTeamName = "Agents"
// Client 抽象 teai 需要的 Gitea API 讀取操作(僅唯讀)。
// #5 的 internal/gitea HTTP 用戶端實作此介面;workflow 不依賴 HTTP 細節。
type Client interface {
// CurrentUser 回傳目前帳號名(GET /user)。
CurrentUser() (string, error)
// ListOrgs 回傳帳號所屬組織名(GET /user/orgs,分頁)。
ListOrgs() ([]string, error)
// ListTeams 回傳組織的團隊清單(GET /orgs/{org}/teams,分頁)。
ListTeams(org string) ([]Team, error)
// ListTeamMembers 回傳團隊成員帳號(GET /teams/{id}/members,分頁)。
ListTeamMembers(teamID int64) ([]string, error)
// ListOrgRepos 回傳組織的倉庫物件(GET /orgs/{org}/repos,分頁)。
ListOrgRepos(org string) ([]Repo, error)
// ListIssues 回傳倉庫的 open issues(GET /repos/{owner}/{repo}/issues?state=open&type=issues,
// 分頁;已排除帶 pull_request 標記的 PR)。assignee/留言過濾由 workflow 層負責。
ListIssues(owner, repo string) ([]Issue, error)
// ListPulls 回傳倉庫的 open PR(GET /repos/{owner}/{repo}/pulls?state=open,分頁)。
ListPulls(owner, repo string) ([]PullRequest, error)
// ListComments 回傳 issue/PR 的留言(GET /repos/{owner}/{repo}/issues/{n}/comments,分頁)。
ListComments(owner, repo string, number int64) ([]Comment, error)
}
// RepoPulls 是單一倉庫的 open PR 清單(掃描的中間產物)。
type RepoPulls struct {
Owner string
Repo string
Pulls []PullRequest
}
// PullItem 是跟進清單中的 PR,附掃描時的所屬倉庫(owner/repo),
// 供輸出時補齊 gitea.py 工作物件的 repo 欄位。
type PullItem struct {
Owner string
Repo string
Pull PullRequest
}
// Scanner 在 Client 之上組合出各命令的資料(whoami/orgs/members/mine/pulls/next)。
type Scanner struct {
Client Client
}
// Whoami 回傳目前帳號。
func (s *Scanner) Whoami() (string, error) {
return s.Client.CurrentUser()
}
// Orgs 回傳目前帳號所屬的組織(去重、排序)。
func (s *Scanner) Orgs() ([]string, error) {
names, err := s.Client.ListOrgs()
if err != nil {
return nil, err
}
seen := map[string]bool{}
out := []string{}
for _, n := range names {
if n != "" && !seen[n] {
seen[n] = true
out = append(out, n)
}
}
sort.Strings(out)
return out, nil
}
// agentsTeamID 找出組織內名為 Agents 的團隊 ID;沒有回 (0,false)。
func (s *Scanner) agentsTeamID(org string) (int64, bool, error) {
teams, err := s.Client.ListTeams(org)
if err != nil {
return 0, false, err
}
for _, team := range teams {
if team.Name == AgentsTeamName {
return team.ID, true, nil
}
}
return 0, false, nil
}
// Members 回傳跨組織 Agents 團隊成員(去重、排序)。
// hasWork 為 true 時只保留有未完成工作者(判定同 Next 的拾取規則)。
func (s *Scanner) Members(hasWork bool) ([]string, error) {
orgs, err := s.Orgs()
if err != nil {
return nil, err
}
seen := map[string]bool{}
names := []string{}
for _, org := range orgs {
teamID, ok, err := s.agentsTeamID(org)
if err != nil {
return nil, err
}
if !ok {
continue
}
members, err := s.Client.ListTeamMembers(teamID)
if err != nil {
return nil, err
}
for _, m := range members {
if m != "" && !seen[m] {
seen[m] = true
names = append(names, m)
}
}
}
sort.Strings(names)
if !hasWork {
return names, nil
}
busy, err := s.usernamesWithUnfinishedWork(names)
if err != nil {
return nil, err
}
out := make([]string, 0, len(names))
for _, name := range names {
if busy[name] {
out = append(out, name)
}
}
return out, nil
}
// iterOrgRepos 產出 (org, repoName);空倉庫跳過(沒有內容可掃)。
func (s *Scanner) iterOrgRepos() ([][2]string, error) {
orgs, err := s.Orgs()
if err != nil {
return nil, err
}
out := [][2]string{}
for _, org := range orgs {
repos, err := s.Client.ListOrgRepos(org)
if err != nil {
return nil, err
}
for _, repo := range repos {
if repo.Name == "" || repo.Empty {
continue
}
out = append(out, [2]string{org, repo.Name})
}
}
return out, nil
}
// iterOpenPulls 跨倉庫收集 open PR(附所屬 owner/repo)。
// 對應 gitea.py 的 _iter_open_pulls:空倉庫、明確停用 PR 的倉庫跳過。
func (s *Scanner) iterOpenPulls() ([]RepoPulls, error) {
orgs, err := s.Orgs()
if err != nil {
return nil, err
}
out := []RepoPulls{}
for _, org := range orgs {
repos, err := s.Client.ListOrgRepos(org)
if err != nil {
return nil, err
}
for _, repo := range repos {
if repo.Name == "" || repo.Empty {
continue
}
if repo.HasPullRequests != nil && !*repo.HasPullRequests {
continue
}
pulls, err := s.Client.ListPulls(org, repo.Name)
if err != nil {
return nil, err
}
if len(pulls) == 0 {
continue
}
out = append(out, RepoPulls{Owner: org, Repo: repo.Name, Pulls: pulls})
}
}
return out, nil
}
// orgIssue 是掃描到的 issue,附所屬 owner/repo。
type orgIssue struct {
Owner string
Repo string
Issue Issue
}
// collectMemberIssues 跨組織收集分派給 members(至少一人)且最後一則留言
// 不是 assignee 的 open issues(gitea.py 的 for_all_organizations)。
func (s *Scanner) collectMemberIssues(members []string) ([]orgIssue, error) {
pairs, err := s.iterOrgRepos()
if err != nil {
return nil, err
}
out := []orgIssue{}
for _, pair := range pairs {
owner, repo := pair[0], pair[1]
issues, err := s.Client.ListIssues(owner, repo)
if err != nil {
return nil, err
}
for i := range issues {
issue := &issues[i]
if !AssigneeOverlaps(issue, members) {
continue
}
comments, err := s.Client.ListComments(owner, repo, issue.issueNumber())
if err != nil {
return nil, err
}
if !LastCommentNotByAssignee(issue, comments) {
continue
}
out = append(out, orgIssue{Owner: owner, Repo: repo, Issue: *issue})
}
}
return out, nil
}
// Mine 回傳分派給目前帳號、且最後一則留言不是 assignee 的 open issues
// (跨所屬組織;gitea.py 的 list_my_followup_issues)。
func (s *Scanner) Mine() ([]Issue, error) {
username, err := s.Whoami()
if err != nil {
return nil, err
}
found, err := s.collectMemberIssues([]string{username})
if err != nil {
return nil, err
}
out := make([]Issue, 0, len(found))
for _, item := range found {
out = append(out, item.Issue)
}
return out, nil
}
// Pulls 回傳跟進清單(跨倉庫;gitea.py 的 list_followup_pulls 系列):
// - 預設:「有留言且最後一則不是作者」的 open PR。
// - mine=true:再限定作者是目前帳號(list_my_followup_pulls)。
// - reviewer=true:改為「自己是 requested reviewer、不是作者、最後一則
// 留言不是自己(含尚無留言)」(list_my_reviewer_pulls)。
//
// mine 與 reviewer 不可併用。
func (s *Scanner) Pulls(mine, reviewer bool) ([]PullItem, error) {
if mine && reviewer {
return nil, errIncompatibleFlags
}
username := ""
if mine || reviewer {
var err error
username, err = s.Whoami()
if err != nil {
return nil, err
}
}
repoPulls, err := s.iterOpenPulls()
if err != nil {
return nil, err
}
found := []PullItem{}
for _, rp := range repoPulls {
for i := range rp.Pulls {
pr := &rp.Pulls[i]
comments, err := s.Client.ListComments(rp.Owner, rp.Repo, pr.Number)
if err != nil {
return nil, err
}
var match bool
switch {
case reviewer:
match = NeedsReviewerFollowup(pr, comments, username)
case mine:
match = NeedsAuthorFollowup(pr, comments) && pr.Author() == username
default:
match = NeedsAuthorFollowup(pr, comments)
}
if match {
found = append(found, PullItem{Owner: rp.Owner, Repo: rp.Repo, Pull: *pr})
}
}
}
return found, nil
}
// usernamesWithUnfinishedWork 從待跟進 issue/PR 清單收集有未完成工作的
// 成員帳號(gitea.py 的 _usernames_with_unfinished_work;判定同 Next)。
func (s *Scanner) usernamesWithUnfinishedWork(members []string) (map[string]bool, error) {
memberSet := map[string]bool{}
for _, m := range members {
if m != "" {
memberSet[m] = true
}
}
busy := map[string]bool{}
if len(memberSet) == 0 {
return busy, nil
}
// issues:分派給任一成員、最後留言不是 assignee。
issues, err := s.collectMemberIssues(members)
if err != nil {
return nil, err
}
for _, item := range issues {
for _, name := range AssigneeNames(&item.Issue) {
if memberSet[name] {
busy[name] = true
}
}
}
// PR:作者待跟進/審核者待審。
repoPulls, err := s.iterOpenPulls()
if err != nil {
return nil, err
}
for _, rp := range repoPulls {
for i := range rp.Pulls {
pr := &rp.Pulls[i]
comments, err := s.Client.ListComments(rp.Owner, rp.Repo, pr.Number)
if err != nil {
return nil, err
}
if NeedsAuthorFollowup(pr, comments) {
if author := pr.Author(); memberSet[author] {
busy[author] = true
}
}
for _, reviewer := range ReviewerNames(pr) {
if memberSet[reviewer] && NeedsReviewerFollowup(pr, comments, reviewer) {
busy[reviewer] = true
}
}
}
}
return busy, nil
}