mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
- **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 ```
182 lines
5.3 KiB
Go
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
|
|
}
|