fleet/tools/github-manage/pkg/ghapi/relations.go
George Karr ec2163768f
gkarr gm updates (#32625)
- **Adding sprint command to view items in current sprint and warning
when limit is too small**
- **Adding select / deselect all and demo output**
- **Added hotkey 's' to select all subissues along with the issue
selected**

Example of how I generated the demo docs for mdm this sprint
```
./gm sprint mdm --limit 200
'pressed l to select all issues'
'pressed w to select a workflow'
selected Bulk Demo workflow and hit enter
copy/paste features to features and bugs to bugs
modify gh usernames to @email and hit 'space' after every ) in the markdown to get it to trigger
hit tab for all issues listed under each user
...
profit
```
2025-09-05 16:28:36 -05:00

182 lines
5.3 KiB
Go

package ghapi
import (
"encoding/json"
"fmt"
"regexp"
"sync"
)
// relatedIssuesCache stores related issue lookups to avoid repeated API calls.
var relatedIssuesCache = struct {
sync.RWMutex
data map[int][]int
}{data: make(map[int][]int)}
// GetRelatedIssueNumbers attempts to discover "related" issues (sub-tasks) for the given
// issue by parsing its body for GitHub task list references such as:
// - [ ] #1234 Some task
// - [x] #5678 Done task
//
// The previous attempt used unsupported fields (trackingIssues / trackedInIssues) which
// are not exposed via `gh issue view --json` in the current GitHub CLI. This fallback
// is lightweight, fast, and avoids extra GraphQL complexity while still capturing the
// common parent -> child task relationships.
// NOTE: We only discover downward (children) links from the issue's own task list. We do
// not currently scan other issues' bodies to infer parents (that would require a repo-wide
// search and could be expensive). For the current selection toggle UX this is sufficient.
func GetRelatedIssueNumbers(issueNumber int) ([]int, error) {
// Cache check
relatedIssuesCache.RLock()
if cached, ok := relatedIssuesCache.data[issueNumber]; ok {
relatedIssuesCache.RUnlock()
return cached, nil
}
relatedIssuesCache.RUnlock()
// First attempt: GraphQL subIssues (official hierarchical issues feature)
related, err := getSubIssuesViaGraphQL(issueNumber)
if err == nil && len(related) > 0 {
relatedIssuesCache.Lock()
relatedIssuesCache.data[issueNumber] = related
relatedIssuesCache.Unlock()
return related, nil
}
// Fallback: parse task list references in the issue body.
bodyRelated := getTaskListIssueRefs(issueNumber)
relatedIssuesCache.Lock()
relatedIssuesCache.data[issueNumber] = bodyRelated
relatedIssuesCache.Unlock()
return bodyRelated, nil
}
// getSubIssuesViaGraphQL fetches sub-issue numbers using the GitHub GraphQL API.
// Returns (nil, error) on hard failures so caller can fallback.
func getSubIssuesViaGraphQL(issueNumber int) ([]int, error) {
owner, repo, err := getRepoOwnerAndName()
if err != nil {
return nil, err
}
// Single query: fetch issue by number and include subIssues.
// We request up to 100 which is well above typical usage; pagination can be added if needed.
query := `query($owner:String!,$repo:String!,$number:Int!){repository(owner:$owner,name:$repo){issue(number:$number){subIssues(first:100){nodes{number}}}}}`
cmd := fmt.Sprintf("gh api graphql -f query='%s' -f owner='%s' -f repo='%s' -F number=%d", query, owner, repo, issueNumber)
out, err := RunCommandAndReturnOutput(cmd)
if err != nil {
return nil, err
}
var resp struct {
Data struct {
Repository struct {
Issue struct {
SubIssues struct {
Nodes []struct {
Number int `json:"number"`
} `json:"nodes"`
} `json:"subIssues"`
} `json:"issue"`
} `json:"repository"`
} `json:"data"`
}
if jerr := json.Unmarshal(out, &resp); jerr != nil {
return nil, jerr
}
nodes := resp.Data.Repository.Issue.SubIssues.Nodes
if len(nodes) == 0 {
return []int{}, nil
}
related := make([]int, 0, len(nodes))
seen := make(map[int]struct{})
for _, n := range nodes {
if n.Number == 0 || n.Number == issueNumber {
continue
}
if _, ok := seen[n.Number]; ok {
continue
}
seen[n.Number] = struct{}{}
related = append(related, n.Number)
}
return related, nil
}
// getTaskListIssueRefs parses the issue body for task list style references as a fallback.
func getTaskListIssueRefs(issueNumber int) []int {
cmd := fmt.Sprintf("gh issue view %d --json number,body", issueNumber)
out, err := RunCommandAndReturnOutput(cmd)
if err != nil {
return []int{}
}
var parsed struct {
Number int `json:"number"`
Body string `json:"body"`
}
if err := json.Unmarshal(out, &parsed); err != nil {
return []int{}
}
re := regexp.MustCompile(`(?m)^\s*- \[[ xX]\] #([0-9]+)\b`)
matches := re.FindAllStringSubmatch(parsed.Body, -1)
if matches == nil {
return []int{}
}
seen := make(map[int]struct{})
related := make([]int, 0, len(matches))
for _, m := range matches {
if len(m) < 2 {
continue
}
var num int
fmt.Sscanf(m[1], "%d", &num)
if num == 0 || num == issueNumber {
continue
}
if _, exists := seen[num]; !exists {
seen[num] = struct{}{}
related = append(related, num)
}
}
return related
}
// repoOwnerNameCache caches owner/name lookups.
var repoOwnerNameCache struct {
sync.Mutex
owner string
name string
ok bool
}
// getRepoOwnerAndName uses `gh repo view` to discover the current repository owner/name once.
func getRepoOwnerAndName() (string, string, error) {
repoOwnerNameCache.Lock()
if repoOwnerNameCache.ok {
owner := repoOwnerNameCache.owner
name := repoOwnerNameCache.name
repoOwnerNameCache.Unlock()
return owner, name, nil
}
repoOwnerNameCache.Unlock()
cmd := "gh repo view --json owner,name"
out, err := RunCommandAndReturnOutput(cmd)
if err != nil {
return "", "", err
}
var parsed struct {
Owner struct {
Login string `json:"login"`
} `json:"owner"`
Name string `json:"name"`
}
if err := json.Unmarshal(out, &parsed); err != nil {
return "", "", err
}
repoOwnerNameCache.Lock()
repoOwnerNameCache.owner = parsed.Owner.Login
repoOwnerNameCache.name = parsed.Name
repoOwnerNameCache.ok = true
repoOwnerNameCache.Unlock()
return parsed.Owner.Login, parsed.Name, nil
}