mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
386 lines
11 KiB
Go
386 lines
11 KiB
Go
package ghapi
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os/exec"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"fleetdm/gm/pkg/logger"
|
|
"fleetdm/gm/pkg/util"
|
|
)
|
|
|
|
// GetIssuesByMilestone returns issue numbers for a given milestone name. Limit controls max items.
|
|
func GetIssuesByMilestone(name string, limit int) ([]int, error) {
|
|
if limit <= 0 {
|
|
limit = 1000
|
|
}
|
|
// Use gh to list issues for the current repo by milestone
|
|
// Include closed/open (state all) to reflect full milestone scope
|
|
cmd := fmt.Sprintf("gh issue list --state all --milestone %q --limit %d --json number", name, limit)
|
|
out, err := RunCommandAndReturnOutput(cmd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var arr []struct {
|
|
Number int `json:"number"`
|
|
}
|
|
if err := json.Unmarshal(out, &arr); err != nil {
|
|
return nil, err
|
|
}
|
|
nums := make([]int, 0, len(arr))
|
|
for _, it := range arr {
|
|
nums = append(nums, it.Number)
|
|
}
|
|
return nums, nil
|
|
}
|
|
|
|
// GetIssuesByMilestoneWithTitles returns issue numbers and titles for a milestone.
|
|
func GetIssuesByMilestoneWithTitles(name string, limit int) ([]MilestoneIssue, error) {
|
|
if limit <= 0 {
|
|
limit = 1000
|
|
}
|
|
cmd := fmt.Sprintf("gh issue list --state all --milestone %q --limit %d --json number,title,labels", name, limit)
|
|
out, err := RunCommandAndReturnOutput(cmd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var arr []MilestoneIssue
|
|
if err := json.Unmarshal(out, &arr); err != nil {
|
|
return nil, err
|
|
}
|
|
return arr, nil
|
|
}
|
|
|
|
// ProjectInfo represents a GitHub Project (v2) basic descriptor used in reports.
|
|
type ProjectInfo struct {
|
|
ID int // project number (e.g., 58)
|
|
Title string // project title (e.g., g-mdm)
|
|
}
|
|
|
|
// MilestoneIssue represents a simple issue record for a milestone.
|
|
type MilestoneIssue struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
Labels []struct {
|
|
Name string `json:"name"`
|
|
} `json:"labels"`
|
|
}
|
|
|
|
// GetIssueProjects returns projects (id->title) that a specific issue belongs to.
|
|
func GetIssueProjects(issueNumber int) (map[int]string, error) {
|
|
owner, repo, err := getRepoOwnerAndName()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
query := `query($owner:String!,$repo:String!,$number:Int!){
|
|
repository(owner:$owner,name:$repo){
|
|
issue(number:$number){
|
|
projectItems(first:50){
|
|
nodes{
|
|
project{ number title }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
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 {
|
|
ProjectItems struct {
|
|
Nodes []struct {
|
|
Project struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
} `json:"project"`
|
|
} `json:"nodes"`
|
|
} `json:"projectItems"`
|
|
} `json:"issue"`
|
|
} `json:"repository"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(out, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
m := make(map[int]string)
|
|
for _, n := range resp.Data.Repository.Issue.ProjectItems.Nodes {
|
|
if n.Project.Number != 0 {
|
|
m[n.Project.Number] = n.Project.Title
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
|
|
// GetProjectsForIssues gathers the union of projects across the provided issues.
|
|
func GetProjectsForIssues(issueNumbers []int) ([]ProjectInfo, error) {
|
|
seen := make(map[int]string)
|
|
for _, num := range issueNumbers {
|
|
prjs, err := GetIssueProjects(num)
|
|
if err != nil {
|
|
// tolerate errors per-issue, continue accumulating from others
|
|
continue
|
|
}
|
|
for id, title := range prjs {
|
|
if _, ok := seen[id]; !ok {
|
|
seen[id] = title
|
|
}
|
|
}
|
|
}
|
|
list := make([]ProjectInfo, 0, len(seen))
|
|
for id, title := range seen {
|
|
list = append(list, ProjectInfo{ID: id, Title: title})
|
|
}
|
|
// stable order: by title then id
|
|
sort.Slice(list, func(i, j int) bool {
|
|
if list[i].Title == list[j].Title {
|
|
return list[i].ID < list[j].ID
|
|
}
|
|
return list[i].Title < list[j].Title
|
|
})
|
|
return list, nil
|
|
}
|
|
|
|
// GetIssueProjectStatuses returns a map of projectID -> Status value for an issue across given projects.
|
|
// If the issue is not present in a project or the Status is unset, the value will be an empty string.
|
|
type ProjectStatus struct {
|
|
Present bool // true if the issue is in this project
|
|
Status string // "" when unset
|
|
}
|
|
|
|
func GetIssueProjectStatuses(issueNumber int, projects []int) (map[int]ProjectStatus, error) {
|
|
// Single GraphQL query to fetch all project items and their Status field for this issue
|
|
owner, repo, err := getRepoOwnerAndName()
|
|
if err != nil {
|
|
// If repo cannot be determined, return absent for all
|
|
res := make(map[int]ProjectStatus, len(projects))
|
|
for _, pid := range projects {
|
|
res[pid] = ProjectStatus{Present: false, Status: ""}
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
query := `query($owner:String!,$repo:String!,$number:Int!){
|
|
repository(owner:$owner,name:$repo){
|
|
issue(number:$number){
|
|
projectItems(first:100){
|
|
nodes{
|
|
project{ number title }
|
|
fieldValues(first:50){
|
|
nodes{
|
|
__typename
|
|
... on ProjectV2ItemFieldSingleSelectValue{
|
|
field{ ... on ProjectV2FieldCommon{ name } }
|
|
name
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}`
|
|
cmd := fmt.Sprintf("gh api graphql -f query='%s' -f owner='%s' -f repo='%s' -F number=%d", query, owner, repo, issueNumber)
|
|
out, err := runCommandWithRetry(cmd, 5, 2*time.Second)
|
|
if err != nil {
|
|
// On error, default to absent for all requested projects
|
|
res := make(map[int]ProjectStatus, len(projects))
|
|
for _, pid := range projects {
|
|
res[pid] = ProjectStatus{Present: false, Status: ""}
|
|
}
|
|
return res, nil
|
|
}
|
|
var resp struct {
|
|
Data struct {
|
|
Repository struct {
|
|
Issue struct {
|
|
ProjectItems struct {
|
|
Nodes []struct {
|
|
Project struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
} `json:"project"`
|
|
FieldValues struct {
|
|
Nodes []struct {
|
|
Typename string `json:"__typename"`
|
|
Field struct {
|
|
Name string `json:"name"`
|
|
} `json:"field"`
|
|
Name string `json:"name"`
|
|
} `json:"nodes"`
|
|
} `json:"fieldValues"`
|
|
} `json:"nodes"`
|
|
} `json:"projectItems"`
|
|
} `json:"issue"`
|
|
} `json:"repository"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(out, &resp); err != nil {
|
|
res := make(map[int]ProjectStatus, len(projects))
|
|
for _, pid := range projects {
|
|
res[pid] = ProjectStatus{Present: false, Status: ""}
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// Build a map of project number -> status value
|
|
found := make(map[int]ProjectStatus)
|
|
for _, node := range resp.Data.Repository.Issue.ProjectItems.Nodes {
|
|
pid := node.Project.Number
|
|
statusVal := ""
|
|
for _, fv := range node.FieldValues.Nodes {
|
|
if strings.EqualFold(fv.Field.Name, "Status") && fv.Typename == "ProjectV2ItemFieldSingleSelectValue" {
|
|
statusVal = fv.Name
|
|
break
|
|
}
|
|
}
|
|
found[pid] = ProjectStatus{Present: true, Status: statusVal}
|
|
}
|
|
|
|
// Compose response for requested projects, marking absences with Present=false
|
|
res := make(map[int]ProjectStatus, len(projects))
|
|
for _, pid := range projects {
|
|
if ps, ok := found[pid]; ok {
|
|
res[pid] = ps
|
|
} else {
|
|
res[pid] = ProjectStatus{Present: false, Status: ""}
|
|
}
|
|
}
|
|
return res, nil
|
|
}
|
|
|
|
// runCommandWithRetry executes a shell command capturing combined output and retries with
|
|
// exponential backoff when a rate limit is detected in the output.
|
|
func runCommandWithRetry(command string, attempts int, baseDelay time.Duration) ([]byte, error) {
|
|
if attempts < 1 {
|
|
attempts = 1
|
|
}
|
|
delay := baseDelay
|
|
for i := 1; i <= attempts; i++ {
|
|
logger.Debugf("Running COMMAND (attempt %d/%d): %s", i, attempts, command)
|
|
cmd := exec.Command("bash", "-c", command)
|
|
var out bytes.Buffer
|
|
cmd.Stdout = &out
|
|
cmd.Stderr = &out
|
|
err := cmd.Run()
|
|
if err == nil {
|
|
return out.Bytes(), nil
|
|
}
|
|
outStr := out.String()
|
|
logger.Errorf("Error running command (attempt %d): %s", i, outStr)
|
|
if i == attempts || !looksLikeRateLimit(outStr) {
|
|
return nil, err
|
|
}
|
|
logger.Infof("Rate limit detected; backing off for %s before retry", delay)
|
|
time.Sleep(delay)
|
|
// Exponential backoff with cap
|
|
if delay < 30*time.Second {
|
|
delay = delay * 2
|
|
if delay > 30*time.Second {
|
|
delay = 30 * time.Second
|
|
}
|
|
}
|
|
}
|
|
return nil, fmt.Errorf("command failed after %d attempts", attempts)
|
|
}
|
|
|
|
func looksLikeRateLimit(s string) bool {
|
|
ls := strings.ToLower(s)
|
|
if strings.Contains(ls, "rate limit") || strings.Contains(ls, "graphql_rate_limit") || strings.Contains(ls, "429") {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// GetMilestoneOpenIssueCount returns the count of OPEN issues for the given milestone title in the current repo.
|
|
// This uses GitHub GraphQL search to retrieve the issueCount efficiently without listing all issues.
|
|
func GetMilestoneOpenIssueCount(title string) (int, error) {
|
|
owner, repo, err := getRepoOwnerAndName()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
// Build a search query limited to the current repository, milestone title, and open issues.
|
|
q := fmt.Sprintf("repo:%s/%s is:issue is:open milestone:\"%s\"", owner, repo, title)
|
|
gql := `query($q:String!){ search(type: ISSUE, query: $q, first: 1){ issueCount } }`
|
|
cmd := fmt.Sprintf("gh api graphql -f query='%s' -f q='%s'", gql, util.EscapeSingleQuotes(q))
|
|
out, err := runCommandWithRetry(cmd, 5, 2*time.Second)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
var resp struct {
|
|
Data struct {
|
|
Search struct {
|
|
IssueCount int `json:"issueCount"`
|
|
} `json:"search"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(out, &resp); err != nil {
|
|
return 0, err
|
|
}
|
|
return resp.Data.Search.IssueCount, nil
|
|
}
|
|
|
|
// escape moved to util.EscapeSingleQuotes
|
|
|
|
// RepoMilestone represents a repository milestone (from REST API).
|
|
type RepoMilestone struct {
|
|
Number int `json:"number"`
|
|
Title string `json:"title"`
|
|
State string `json:"state"`
|
|
}
|
|
|
|
// ListRepoMilestones returns milestones for the current repository.
|
|
// When includeClosed is false, only open milestones are returned; otherwise all states are included.
|
|
func ListRepoMilestones(includeClosed bool) ([]RepoMilestone, error) {
|
|
owner, repo, err := getRepoOwnerAndName()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
state := "open"
|
|
if includeClosed {
|
|
state = "all"
|
|
}
|
|
// Paginate to ensure we gather more than one page if present
|
|
all := make([]RepoMilestone, 0)
|
|
for page := 1; page <= 10; page++ { // hard cap to avoid accidental infinite loops
|
|
cmd := fmt.Sprintf("gh api repos/%s/%s/milestones?state=%s&per_page=100&page=%d", owner, repo, state, page)
|
|
out, err := RunCommandAndReturnOutput(cmd)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var arr []RepoMilestone
|
|
if err := json.Unmarshal(out, &arr); err != nil {
|
|
return nil, err
|
|
}
|
|
if len(arr) == 0 {
|
|
break
|
|
}
|
|
all = append(all, arr...)
|
|
if len(arr) < 100 {
|
|
break
|
|
}
|
|
}
|
|
// Sort open first by title, then closed by title
|
|
sort.Slice(all, func(i, j int) bool {
|
|
si := strings.ToLower(all[i].State)
|
|
sj := strings.ToLower(all[j].State)
|
|
if si != sj {
|
|
if si == "open" {
|
|
return true
|
|
}
|
|
if sj == "open" {
|
|
return false
|
|
}
|
|
}
|
|
return strings.ToLower(all[i].Title) < strings.ToLower(all[j].Title)
|
|
})
|
|
return all, nil
|
|
}
|