// 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 } // commentsFor 取回 issue/PR 的留言;number 缺漏(0)時直接視為無留言, // 不打 API(gitea.py 在 number 為 None 時跳過留言檢查的語義—— // 避免拿 0 去 issues/0/comments 觸發 404 中斷整個掃描)。 func (s *Scanner) commentsFor(owner, repo string, number int64) ([]Comment, error) { if number <= 0 { return nil, nil } return s.Client.ListComments(owner, repo, number) } // 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.commentsFor(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.commentsFor(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.commentsFor(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 }