iterOrgRepos(issue 掃描,供 Mine 與 members --has-work)不再跳過 empty=true 的倉庫——Gitea 允許空倉庫持有 open issue(「初始化項目」 型工作),行為基準 gitea.py 的 for_all_repositories 掃 issue 時 本來就不檢查 empty;teai 先前過度套用了空倉庫跳過條件,導致 teai next 對 colony-tick-demo issue #1 回 null。 PR 掃描(iterOpenPulls)、stalled、milestone overview 維持跳過 空倉庫不變(與 gitea.py 一致)。附回歸測試兩則。
366 lines
10 KiB
Go
366 lines
10 KiB
Go
// 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)。空倉庫(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 {
|
||
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 == "" {
|
||
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
|
||
}
|