// 套件 workflow 實作 gitea.py 工作流的核心判定邏輯(teai issue #7)。 // // 行為基準是 alterminal/agents 的 scripts/lib/gitea.py:xref 解析 // (#N、repo#N、owner/repo#N)與 stalled 停滯掃描(reason 分類、催促冷卻、 // 父追蹤項把子項活動計入 last_activity)。判定規則以 AGENTS.md 為準, // 本套件只實作、不改義。 // // 資料以 Source 介面注入:CLI 層負責拉取 API,測試用假資料源, // 邏輯本身離線可測(README 設計原則 4:可直接替換原型、交叉驗證)。 package workflow import ( "bytes" "encoding/json" "fmt" "regexp" "sort" "strconv" "strings" "time" "unicode" "unicode/utf8" ) // 常數與 gitea.py 保持一致(STALLED_DEFAULT_HOURS、NUDGE_COOLDOWN_HOURS)。 const ( // StalledDefaultHours 是 stalled 掃描的預設門檻(小時)。 StalledDefaultHours = 4.0 // NudgeCooldownHours 是「最後留言出自目前帳號」項目的催促冷卻(小時)。 NudgeCooldownHours = 24.0 ) // xrefTimelineTypes 對應 gitea.py 的 XREF_TIMELINE_TYPES:他處留言/issue // 提及本編號時產生的 timeline 引用事件。這類事件會刷新 updated_at,但不是 // 本 issue 的直接活動,directLastActivity 會予以排除(agents issue #34)。 var xrefTimelineTypes = map[string]bool{"comment_ref": true, "issue_ref": true} // xrefPattern 對應 gitea.py 的 XREF_PATTERN:#N、repo#N、owner/repo#N。 // // 前面不得是文數字/底線/斜線(避免切進 alterminal/bear#47 的後半段); // owner/repo 兩段之間必須有斜線(避免把 bear-cli#10 誤判成 cli#10)。 // Go 的 regexp 不支援 lookbehind,改在掃描時檢查前一字元。 var xrefPattern = regexp.MustCompile(`([A-Za-z0-9_.-]+(?:/[A-Za-z0-9_.-]+)?)?#(\d+)`) // Item 是 stalled 掃描的單筆輸出(欄位對應 gitea.py 的 find_stalled_work)。 type Item struct { Type string Repo string Number int64 Title string URL string Assignees []string Author string Reviewers []string LastCommentBy string LastActivityAt time.Time StalledHours float64 Reason string ChildActivity []Child } // MarshalJSON 輸出鍵集與鍵序與 gitea.py 完全一致的物件: // issue 帶 assignees;pull 帶 author 與 reviewers; // child_activity 僅在有子項摘要時輸出。 func (i Item) MarshalJSON() ([]byte, error) { var b strings.Builder b.WriteString(`{"type":`) writeJSON(&b, i.Type) b.WriteString(`,"repo":`) writeJSON(&b, i.Repo) b.WriteString(`,"number":`) fmt.Fprintf(&b, "%d", i.Number) b.WriteString(`,"title":`) writeJSON(&b, i.Title) b.WriteString(`,"url":`) writeJSON(&b, i.URL) if i.Type == "pull" { b.WriteString(`,"author":`) writeJSON(&b, i.Author) b.WriteString(`,"reviewers":`) writeJSON(&b, i.Reviewers) } else { b.WriteString(`,"assignees":`) writeJSON(&b, i.Assignees) } b.WriteString(`,"last_comment_by":`) writeJSON(&b, i.LastCommentBy) b.WriteString(`,"last_activity_at":`) writeJSON(&b, FormatMoment(i.LastActivityAt)) b.WriteString(`,"stalled_hours":`) // Python round(x, 1) 的 json 輸出是 48.0/10.4(repr 帶 .0); // %g 會輸出 48,極大值還會變 1e+06,改定點一位小數。 b.WriteString(strconv.FormatFloat(i.StalledHours, 'f', 1, 64)) b.WriteString(`,"reason":`) writeJSON(&b, i.Reason) if len(i.ChildActivity) > 0 { b.WriteString(`,"child_activity":`) writeJSON(&b, i.ChildActivity) } b.WriteString(`}`) return []byte(b.String()), nil } // FormatMoment 輸出與 Python datetime.isoformat() 一致的時刻字串 // (UTC 以 +00:00 表示,不用 Z;秒以下省略)。 func FormatMoment(t time.Time) string { if t.Nanosecond() != 0 { return t.Format("2006-01-02T15:04:05.999999999-07:00") } return t.Format("2006-01-02T15:04:05-07:00") } // writeJSON 以停用 HTML 轉義的編碼器輸出:Python json.dumps(ensure_ascii=False) // 不會把 <、>、& 轉成 \u003c 等逸出,json.Marshal 預設會,造成位元組不相容。 // Encoder 會在結尾補換行, trim 掉。 func writeJSON(b *strings.Builder, v any) { var buf bytes.Buffer enc := json.NewEncoder(&buf) enc.SetEscapeHTML(false) if err := enc.Encode(v); err != nil { return } b.Write(bytes.TrimSuffix(buf.Bytes(), []byte{'\n'})) } // Child 是父追蹤項的子項活動摘要。 type Child struct { Repo string `json:"repo"` Number int64 `json:"number"` Title string `json:"title"` State string `json:"state"` // LastActivityAt 用指標:子項無可解析時刻時 Python 輸出 null(不是 "")。 LastActivityAt *string `json:"last_activity_at"` } // XrefTarget 是解析後的引用目標(owner、repo、number)。 type XrefTarget struct { Owner string Repo string Number int64 } // Source 是 stalled 掃描需要的資料源(CLI 層以 API 實作;測試注入假資料)。 type Source interface { // MyOrganizations 回傳目前帳號所屬組織清單。 MyOrganizations() ([]string, error) // OrganizationRepositories 回傳組織的倉庫清單。 OrganizationRepositories(org string) ([]Repo, error) // OpenIssues 回傳倉庫的 open issues(Gitea 會混入 PR,由掃描端過濾)。 OpenIssues(owner, repo string) ([]Issue, error) // OpenPulls 回傳倉庫的 open pull requests。 OpenPulls(owner, repo string) ([]PullRequest, error) // IssueComments 回傳 issue/PR 的留言。 IssueComments(owner, repo string, number int64) ([]Comment, error) // TryGetIssue 寬容版單一 issue 查詢:失敗(含 404)回 (零值, false, nil)。 TryGetIssue(owner, repo string, number int64) (Issue, bool, error) // IssueTimeline 回傳 issue 的 timeline 事件(GET /repos/{o}/{r}/issues/{n}/timeline, // 分頁)。directLastActivity 用來排除跨 issue 引用事件(agents issue #34)。 IssueTimeline(owner, repo string, number int64) ([]TimelineEvent, error) } // iterXrefs 產出文字中的 (prefix, number) 配對(對應 _iter_issue_xrefs)。 // // Python 原版以 lookbehind (? 0 { prev, _ := utf8.DecodeLastRuneInString(text[:start]) if isWordChar(prev) || prev == '/' || prev == '#' { _, size := utf8.DecodeRuneInString(text[start:]) pos = start + size continue } } match := text[start:end] i := strings.IndexByte(match, '#') prefix := match[:i] number := match[i+1:] out = append(out, [2]string{prefix, number}) pos = end } return out } // isWordChar 對應 Python \w(Unicode):字母/數字/底線。 // 前一版只認 ASCII,中文緊鄰 #N 時(「修復#12」)與 Python 行為分歧。 func isWordChar(r rune) bool { return r == '_' || unicode.IsLetter(r) || unicode.IsDigit(r) } // ResolveXrefTargets 把內文/留言中的引用解析成目標清單(對應 _resolve_xref_targets)。 // // 單段 #N 指向父項所在倉庫;單段 repo#N 需 repo 存在於父項所屬組織 // (「PR#50」這類寫法因 repo 不存在而自動略過);owner/repo#N 需 owner // 為目前帳號所屬組織(跨倉庫子項,如 bear-cli#9 → bear#35 的同組織判定)。 // 去重、保留出現順序。 func ResolveXrefTargets(org, repo string, texts []string, myOrgs map[string]bool, orgRepos func(org string) ([]string, error)) ([]XrefTarget, error) { var resolved []XrefTarget seen := map[XrefTarget]bool{} var orgRepoSet map[string]bool for _, text := range texts { for _, pair := range iterXrefs(text) { prefix, numberStr := pair[0], pair[1] var number int64 if _, err := fmt.Sscanf(numberStr, "%d", &number); err != nil || number <= 0 { continue } var target XrefTarget switch { case strings.Contains(prefix, "/"): parts := strings.SplitN(prefix, "/", 2) owner, name := parts[0], parts[1] if owner != "" && name != "" && myOrgs[owner] { target = XrefTarget{owner, name, number} } case prefix != "": if orgRepoSet == nil { names, err := orgRepos(org) if err != nil { return nil, err } orgRepoSet = map[string]bool{} for _, n := range names { orgRepoSet[n] = true } } if orgRepoSet[prefix] { target = XrefTarget{org, prefix, number} } default: target = XrefTarget{org, repo, number} } if target != (XrefTarget{}) && !seen[target] { seen[target] = true resolved = append(resolved, target) } } } return resolved, nil } // childActivity 計算父追蹤項的子項活動(對應 _child_activity)。 // // 只採計向下引用:子項建立時間需晚於父項(父項內文回指更早的規劃 issue // 是上行引用,不採計)。資料源失敗(含編號不存在)靜默略過,不影響掃描。 func childActivity(src Source, org, repo string, parent Issue, comments []Comment, myOrgs map[string]bool, orgRepos func(string) ([]string, error)) (moments []time.Time, children []Child) { texts := []string{parent.Body} for _, c := range comments { texts = append(texts, c.Body) } targets, err := ResolveXrefTargets(org, repo, texts, myOrgs, orgRepos) if err != nil { return nil, nil } for _, t := range targets { child, ok, gerr := src.TryGetIssue(t.Owner, t.Repo, t.Number) if gerr != nil || !ok { continue } if t.Owner == org && t.Repo == repo && child.issueNumber() == parent.issueNumber() { continue // 自我引用 } parentCreated, pcOK := parseMoment(parent.CreatedAt) childCreated, ccOK := parseMoment(child.CreatedAt) if pcOK && ccOK && !childCreated.After(parentCreated) { continue // 上行引用(父項回指更早或同時的 issue);時間未知則不排除 } // 子項的直接活動:created_at、state 變更(closed_at)、留言(agents #32)。 // 原始 updated_at 不放入已知活動,另經 directLastActivity 核對後採計 //(與 considerIssue/considerPull 對本 issue 自身的處理同構)。 var ms []time.Time if m, ok := parseMoment(child.CreatedAt); ok { ms = append(ms, m) } if m, ok := parseMoment(child.ClosedAt); ok { ms = append(ms, m) } if cs, cerr := src.IssueComments(t.Owner, t.Repo, t.Number); cerr == nil { for _, c := range cs { if m, ok := parseMoment(c.CreatedAt); ok { ms = append(ms, m) } } } if childUpdated, ok := parseMoment(child.UpdatedAt); ok { // 子項 updated_at 可能被 comment_ref/issue_ref 引用事件刷新 //(agents #38):走與本 issue 自身相同的 directLastActivity 排除 // 邏輯(同源判定,不漂移);timeline 失敗時保守沿用 updated_at。 if d, ok := directLastActivity(src, t.Owner, t.Repo, t.Number, childUpdated, true, ms); ok { ms = append(ms, d) } } var last *string if len(ms) > 0 { latest := maxTime(ms) moments = append(moments, latest) // 與 Item 相同用 FormatMoment(Python isoformat:UTC 記 +00:00); // RFC3339 對 UTC 會輸出 Z,與基準不相容。 s := FormatMoment(latest) last = &s } children = append(children, Child{ Repo: t.Owner + "/" + t.Repo, Number: child.Number, Title: child.Title, State: child.State, LastActivityAt: last, }) } return moments, children } // TimelineEvent 是 issue timeline 的單一事件(僅取判斷所需欄位; // 對應 gitea.py _direct_last_activity 讀取的 timeline 物件)。 type TimelineEvent struct { Type string `json:"type"` CreatedAt string `json:"created_at"` } // directLastActivity 排除跨 issue 引用事件後,本 issue 的最後直接活動時刻 // (對應 gitea.py 的 _direct_last_activity;agents issues #34/#38/#40)。 // // 他處留言/issue 提及本編號(comment_ref/issue_ref)也會刷新 updated_at, // 直接採用會虛假刷新停滯時鐘(false negative)並低估 stalled_hours。 // updated_at 不晚於已知直接活動(留言、created_at、#32 納入的子項活動)時, // updated_at 本身可信,不必多打一次 timeline API;只有 updated_at 晚於全部 // 已知活動時才拉 timeline 核對:排除引用類事件後取最晚事件時刻;若最晚的 // 都是引用事件(本 issue 無直接活動可對應 updated_at),則回退已知直接活動 // 的最晚時刻(agents #40)。timeline 拉取失敗時沿用 updated_at(不中斷掃描、 // 寧可少報不誤報缺失活動)。 func directLastActivity(src Source, org, repo string, number int64, updatedAt time.Time, hasUpdated bool, known []time.Time) (time.Time, bool) { if !hasUpdated { return time.Time{}, false } var knownMax *time.Time for i := range known { if known[i].IsZero() { continue } if knownMax == nil || known[i].After(*knownMax) { m := known[i] knownMax = &m } } if knownMax == nil || !updatedAt.After(*knownMax) { return updatedAt, true } timeline, err := src.IssueTimeline(org, repo, number) if err != nil { return updatedAt, true } var moments []time.Time for _, e := range timeline { if xrefTimelineTypes[e.Type] { continue } if m, ok := parseMoment(e.CreatedAt); ok { moments = append(moments, m) } } if len(moments) == 0 { // timeline 全為引用事件:updated_at 完全由他處提及刷新,本 issue 沒有 // 可對應的直接活動。回退已知直接活動的最晚時刻,避免把被刷新的 // updated_at 當活動、低估停滯時數(agents #40);連已知活動都沒有時才 // 保守沿用 updated_at(不中斷掃描、寧可少報不誤報缺失活動)。 if knownMax != nil { return *knownMax, true } return updatedAt, true } latest := maxTime(moments) if latest.Before(updatedAt) { return latest, true } return updatedAt, true } func maxTime(ts []time.Time) time.Time { best := ts[0] for _, t := range ts[1:] { if t.After(best) { best = t } } return best } // ScanStalled 掃描跨組織的停滯 open issues/PR(對應 find_stalled_work)。 // // 判定:最後活動(留言時間與本身 updated_at 取較晚者,父追蹤項再加計 // 子項活動)距今超過 threshold 小時。刻意包含拾取死角形態:最後一則留言 // 出自 assignee/PR 作者本人且超過時限——工作休眠、無人催促。最後留言 // 出自目前帳號(username)的項目在冷卻期間不列入(已催促過);冷卻後 // 仍停滯會再出現。輸出依停滯時間由長到短排序。 func ScanStalled(src Source, username string, threshold float64, now time.Time) ([]Item, error) { orgs, err := src.MyOrganizations() if err != nil { return nil, err } myOrgs := make(map[string]bool, len(orgs)) for _, o := range orgs { myOrgs[o] = true } orgRepos := func(org string) ([]string, error) { repos, err := src.OrganizationRepositories(org) if err != nil { return nil, err } names := make([]string, 0, len(repos)) for _, r := range repos { if r.Name != "" { names = append(names, r.Name) } } return names, nil } var items []Item for _, org := range orgs { repos, err := src.OrganizationRepositories(org) if err != nil { continue } for _, r := range repos { if r.Name == "" || r.Empty { continue } // 停用 PR 的倉庫沒有 PR 可掃;issues 照常(gitea.py _iter_open_issues 不檢查此欄位)。 scanPulls := r.HasPullRequests == nil || *r.HasPullRequests issues, err := src.OpenIssues(org, r.Name) if err != nil { continue } for _, issue := range issues { if issue.PullRequest != nil { continue } comments, err := src.IssueComments(org, r.Name, issue.Number) if err != nil { continue } item, ok := considerIssue(src, org, r.Name, issue, comments, username, threshold, now, myOrgs, orgRepos) if ok { items = append(items, item) } } if !scanPulls { continue } pulls, err := src.OpenPulls(org, r.Name) if err != nil { continue } for _, pr := range pulls { comments, err := src.IssueComments(org, r.Name, pr.Number) if err != nil { continue } if item, ok := considerPull(src, org, r.Name, pr, comments, username, threshold, now); ok { items = append(items, item) } } } } sort.SliceStable(items, func(i, j int) bool { return items[i].StalledHours > items[j].StalledHours }) return items, nil } // considerIssue 對單一 issue 套用停滯判定(對應 _consider 的 issue 分支)。 func considerIssue(src Source, org, repo string, issue Issue, comments []Comment, username string, threshold float64, now time.Time, myOrgs map[string]bool, orgRepos func(string) ([]string, error)) (Item, bool) { childMoments, children := childActivity(src, org, repo, issue, comments, myOrgs, orgRepos) assignees := AssigneeNames(&issue) // 已知直接活動:留言、created_at、#32 納入的子項活動。 var known []time.Time if m, ok := parseMoment(issue.CreatedAt); ok { known = append(known, m) } for _, c := range comments { if m, ok := parseMoment(c.CreatedAt); ok { known = append(known, m) } } known = append(known, childMoments...) // 跨 issue 引用(comment_ref/issue_ref)也會刷新 updated_at(agents #34): // updated_at 晚於全部已知直接活動時,先排除引用事件核對後才採用, // 避免停滯時鐘被虛假刷新或低估 stalled_hours。 var moments []time.Time if m, ok := parseMoment(issue.UpdatedAt); ok { if d, ok2 := directLastActivity(src, org, repo, issue.issueNumber(), m, ok, known); ok2 { moments = append(moments, d) } } moments = append(moments, known...) if len(moments) == 0 { return Item{}, false } lastActivity := maxTime(moments) stalled := now.Sub(lastActivity).Hours() if stalled < threshold { return Item{}, false } last := LastComment(comments) lastCommentBy := "" if last != nil { lastCommentBy = last.Author() } if lastCommentBy == username { if m, ok := parseMoment(last.CreatedAt); ok && now.Sub(m).Hours() < NudgeCooldownHours { return Item{}, false } } reason := "waiting-outside" switch { case len(assignees) == 0: reason = "no-assignee" case lastCommentBy != "" && containsString(assignees, lastCommentBy): if IsDeliveryReport(issue.Body, last) { // 交付報告待確認(agents #47):assignee 已交付並交棒(回報他件/ // 請發起人確認關閉),停滯點在等待外部確認,不是 assignee 停滯。 reason = "waiting-outside" } else { reason = "assignee-idle" } case lastCommentBy == "": reason = "no-commenter" } if len(children) > 0 { // 父追蹤項一律標 parent-tracking(agents #44,與 gitea.py 同步): // 有 open 子項在推進時,父項留言閒置屬等待子項;子項全關閉時只剩 // 父項收尾(回報驗收並關閉),兩者都不是 assignee 停滯。統一標記 // 讓「父項收尾」規則能正確匹配,不退回通用原因(如 assignee-idle)。 reason = "parent-tracking" } item := Item{ Type: "issue", Repo: issue.FullName(), Number: issue.issueNumber(), Title: issue.Title, URL: issue.WebURL(), Assignees: assignees, LastCommentBy: lastCommentBy, LastActivityAt: lastActivity, StalledHours: round1(stalled), Reason: reason, } if len(children) > 0 { item.ChildActivity = children } return item, true } // considerPull 對單一 PR 套用停滯判定(對應 _consider 的 pull 分支)。 func considerPull(src Source, org, repo string, pr PullRequest, comments []Comment, username string, threshold float64, now time.Time) (Item, bool) { reviewers := ReviewerNames(&pr) author := pr.Author() // 已知直接活動:留言、created_at。 var known []time.Time if m, ok := parseMoment(pr.CreatedAt); ok { known = append(known, m) } for _, c := range comments { if m, ok := parseMoment(c.CreatedAt); ok { known = append(known, m) } } // 跨 issue 引用也會刷新 PR 的 updated_at(agents #34):與 issue 同一道 // directLastActivity 排除邏輯(同源判定,不漂移)。 var moments []time.Time if m, ok := parseMoment(pr.UpdatedAt); ok { if d, ok2 := directLastActivity(src, org, repo, pr.Number, m, ok, known); ok2 { moments = append(moments, d) } } moments = append(moments, known...) if len(moments) == 0 { return Item{}, false } lastActivity := maxTime(moments) stalled := now.Sub(lastActivity).Hours() if stalled < threshold { return Item{}, false } last := LastComment(comments) lastCommentBy := "" if last != nil { lastCommentBy = last.Author() } if lastCommentBy == username { if m, ok := parseMoment(last.CreatedAt); ok && now.Sub(m).Hours() < NudgeCooldownHours { return Item{}, false } } reason := "no-commenter" switch { case len(reviewers) == 0: reason = "no-reviewer" case lastCommentBy == author: if IsReviewHandoff(last, reviewers) { // 審核交棒(agents #51):作者已表明 PR 就緒並 @目前審核者, // 停滯點在審核者,不是作者停滯——球在審核者,讓既有 // reviewer 催促與 48 小時改派規則自然適用。 reason = "reviewer-idle" } else { reason = "author-idle" } case lastCommentBy != "" && containsString(reviewers, lastCommentBy): reason = "reviewer-idle" } item := Item{ Type: "pull", Repo: pr.FullName(), Number: pr.Number, Title: pr.Title, URL: pr.WebURL(), Author: author, Reviewers: reviewers, LastCommentBy: lastCommentBy, LastActivityAt: lastActivity, StalledHours: round1(stalled), Reason: reason, } return item, true } // round1 舍入到小數一位(對應 Python round(x, 1))。 // 經 strconv 的十進位正確舍入(半偶數),與 Python 的浮點 round 行為一致, // 避免 (x*10+0.5) 截斷在 .x5 邊界產生偏差。 func round1(v float64) float64 { r, err := strconv.ParseFloat(strconv.FormatFloat(v, 'f', 1, 64), 64) if err != nil { return v } return r } // ── CLI 接線用的高階入口(#7:teai stalled/teai xrefs)───────────────── // StalledSource 是 stalled/xrefs 命令需要的完整資料源: // Source(掃描用讀取)加上目前帳號與單一 issue 查詢。 // APIClient(giteaapi.go)實作此介面;測試以假資料源注入。 type StalledSource interface { Source // CurrentUser 回傳目前帳號名(催促冷卻判定用)。 CurrentUser() (string, error) // GetIssue 嚴格版單一 issue/PR 查詢;失敗回錯誤(xrefs 命令用, // 404 應回 ErrAPI 由 CLI 映射結束碼 3)。 GetIssue(owner, repo string, number int64) (Issue, error) } // RunStalled 是 teai stalled 的核心:解析目前帳號後跑 ScanStalled。 // hours <= 0 時採 StalledDefaultHours(對應 gitea.py stalled [--hours N] // 的預設 4 小時);旗標層已先攔下非正數,這裡是防禦。 func RunStalled(src StalledSource, hours float64, now time.Time) ([]Item, error) { if hours <= 0 { hours = StalledDefaultHours } username, err := src.CurrentUser() if err != nil { return nil, err } return ScanStalled(src, username, hours, now) } // XrefHit 是 teai xrefs 的單筆輸出:解析後的目標加上出處 // (README 規格:"body"=內文、"comment"=留言)。 type XrefHit struct { Owner string `json:"owner"` Repo string `json:"repo"` Number int64 `json:"number"` Source string `json:"source"` } // ResolveIssueXrefs 是 teai xrefs 的核心:取回單一 issue/PR 的內文與 // 全部留言,解析其中的 #N、repo#N、owner/repo#N 引用(規則同 // ResolveXrefTargets:單段 repo 僅在父項所屬組織有該倉庫時解析、 // owner/repo#N 僅在 owner 為所屬組織時解析),去重並保留出現順序, // 每筆附首次出現的出處(內文先於留言)。stalled 的父追蹤項判定 // 內部用同一套解析,不重複實作。 func ResolveIssueXrefs(src StalledSource, owner, repo string, number int64) ([]XrefHit, error) { issue, err := src.GetIssue(owner, repo, number) if err != nil { return nil, err } comments, err := src.IssueComments(owner, repo, number) if err != nil { return nil, err } orgs, err := src.MyOrganizations() if err != nil { return nil, err } myOrgs := make(map[string]bool, len(orgs)) for _, o := range orgs { myOrgs[o] = true } // 組織倉庫清單跨呼叫快取,避免逐則留言重打 API。 orgRepoCache := map[string][]string{} orgRepos := func(org string) ([]string, error) { if names, ok := orgRepoCache[org]; ok { return names, nil } repos, err := src.OrganizationRepositories(org) if err != nil { return nil, err } names := make([]string, 0, len(repos)) for _, r := range repos { if r.Name != "" { names = append(names, r.Name) } } orgRepoCache[org] = names return names, nil } type sourceText struct { text string source string } texts := []sourceText{{issue.Body, "body"}} for _, c := range comments { texts = append(texts, sourceText{c.Body, "comment"}) } var hits []XrefHit seen := map[XrefTarget]bool{} for _, t := range texts { targets, err := ResolveXrefTargets(owner, repo, []string{t.text}, myOrgs, orgRepos) if err != nil { return nil, err } for _, target := range targets { if seen[target] { continue } seen[target] = true hits = append(hits, XrefHit{ Owner: target.Owner, Repo: target.Repo, Number: target.Number, Source: t.source, }) } } return hits, nil }