mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
287 lines
8.2 KiB
Go
287 lines
8.2 KiB
Go
package ghapi
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
)
|
|
|
|
// DefaultEstimateSourceProjects returns the default set of projects to scan for estimates
|
|
// when syncing to Releases: drafting and product group projects.
|
|
func DefaultEstimateSourceProjects() []int {
|
|
// unique list; ignore releases itself (87) as a source
|
|
return []int{Aliases["draft"], Aliases["mdm"], Aliases["g-software"], Aliases["g-orchestration"], Aliases["g-security-compliance"]}
|
|
}
|
|
|
|
// GetEstimateFromProject returns the numeric estimate for an issue from a specific project.
|
|
// Second return indicates whether a non-zero estimate was found.
|
|
func GetEstimateFromProject(issueNumber int, projectID int) (int, bool, error) {
|
|
itemID, err := GetProjectItemID(issueNumber, projectID)
|
|
if err != nil {
|
|
return 0, false, err
|
|
}
|
|
val, err := GetProjectItemFieldValue(itemID, projectID, "Estimate")
|
|
if err != nil {
|
|
return 0, false, err
|
|
}
|
|
if val == "" || val == "0" {
|
|
return 0, false, nil
|
|
}
|
|
i, convErr := strconv.Atoi(val)
|
|
if convErr != nil {
|
|
return 0, false, fmt.Errorf("invalid estimate value '%s' for issue #%d in project %d", val, issueNumber, projectID)
|
|
}
|
|
return i, true, nil
|
|
}
|
|
|
|
// GetEstimateForIssueAcrossProjects scans the provided projects in order and returns
|
|
// the first non-zero estimate found for the issue, along with the project ID that provided it.
|
|
func GetEstimateForIssueAcrossProjects(issueNumber int, projects []int) (int, int, error) {
|
|
// Efficient path: single per-issue GraphQL to fetch all project item estimates.
|
|
m, err := getIssueEstimatesAcrossProjects(issueNumber)
|
|
if err == nil {
|
|
for _, pid := range projects {
|
|
if v, ok := m[pid]; ok && v > 0 {
|
|
return v, pid, nil
|
|
}
|
|
}
|
|
for pid, v := range m {
|
|
if v > 0 {
|
|
return v, pid, nil
|
|
}
|
|
}
|
|
return 0, 0, nil
|
|
}
|
|
// Fallback path (older approach) if GraphQL fails: check projects one by one
|
|
for _, pid := range projects {
|
|
itemID, e := GetProjectItemID(issueNumber, pid)
|
|
if e != nil {
|
|
continue
|
|
}
|
|
val, e := GetProjectItemFieldValue(itemID, pid, "Estimate")
|
|
if e != nil || val == "" || val == "0" {
|
|
continue
|
|
}
|
|
if i, convErr := strconv.Atoi(val); convErr == nil && i > 0 {
|
|
return i, pid, nil
|
|
}
|
|
}
|
|
return 0, 0, nil
|
|
}
|
|
|
|
// SumEstimatesFromSubIssues sums the estimates of direct sub-issues (one level)
|
|
// using the first-found estimate across the provided projects for each child.
|
|
func SumEstimatesFromSubIssues(issueNumber int, projects []int) (int, error) {
|
|
children, err := GetRelatedIssueNumbers(issueNumber)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if len(children) == 0 {
|
|
return 0, nil
|
|
}
|
|
sum := 0
|
|
for _, child := range children {
|
|
if est, _, _ := GetEstimateFromAnyProject(child, projects); est > 0 {
|
|
sum += est
|
|
}
|
|
}
|
|
return sum, nil
|
|
}
|
|
|
|
// IsIssueInProject checks whether an issue is a member of the given project.
|
|
func IsIssueInProject(issueNumber int, projectID int) bool {
|
|
_, err := GetProjectItemID(issueNumber, projectID)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
// SetEstimateInProject sets the Estimate field for the given issue in the specified project.
|
|
func SetEstimateInProject(issueNumber int, projectID int, estimate int) error {
|
|
// Defensive: never set zero/negative estimates; treat as "leave blank"
|
|
if estimate <= 0 {
|
|
return nil
|
|
}
|
|
itemID, err := GetProjectItemID(issueNumber, projectID)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to get project item for issue #%d in project %d: %v", issueNumber, projectID, err)
|
|
}
|
|
return SetProjectItemFieldValue(itemID, projectID, "Estimate", strconv.Itoa(estimate))
|
|
}
|
|
|
|
// IssueInSprint checks whether the given issue has the specified sprint title in any of the provided projects.
|
|
// Returns true along with the project ID where the match was found.
|
|
func IssueInSprint(issueNumber int, sprintTitle string, projects []int) (bool, int) {
|
|
st := strings.TrimSpace(sprintTitle)
|
|
if st == "" {
|
|
return false, 0
|
|
}
|
|
for _, pid := range projects {
|
|
itemID, err := GetProjectItemID(issueNumber, pid)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
title, err := GetProjectItemFieldValue(itemID, pid, "Sprint")
|
|
if err != nil || strings.TrimSpace(title) == "" {
|
|
continue
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(title), st) {
|
|
return true, pid
|
|
}
|
|
}
|
|
return false, 0
|
|
}
|
|
|
|
// GetIssueNumbersForSprint returns all issue numbers in the given project whose Sprint title matches.
|
|
func GetIssueNumbersForSprint(projectID int, sprintTitle string, limit int) ([]int, error) {
|
|
st := strings.TrimSpace(sprintTitle)
|
|
if st == "" {
|
|
return []int{}, nil
|
|
}
|
|
items, _, err := GetProjectItemsWithTotal(projectID, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
var nums []int
|
|
for _, it := range items {
|
|
if it.Content.Number == 0 || it.Sprint == nil {
|
|
continue
|
|
}
|
|
if strings.EqualFold(strings.TrimSpace(it.Sprint.Title), st) {
|
|
nums = append(nums, it.Content.Number)
|
|
}
|
|
}
|
|
return nums, nil
|
|
}
|
|
|
|
// GetIssueNumbersForSprintAcrossProjects builds a union set of issue numbers
|
|
// that are in the specified sprint across multiple projects.
|
|
func GetIssueNumbersForSprintAcrossProjects(projectIDs []int, sprintTitle string, limit int) (map[int]struct{}, error) {
|
|
out := make(map[int]struct{})
|
|
for _, pid := range projectIDs {
|
|
nums, err := GetIssueNumbersForSprint(pid, sprintTitle, limit)
|
|
if err != nil {
|
|
// Skip problematic project but continue others
|
|
continue
|
|
}
|
|
for _, n := range nums {
|
|
out[n] = struct{}{}
|
|
}
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// --- Efficient per-issue estimate discovery via GraphQL ---
|
|
|
|
var (
|
|
issueEstimateCache = make(map[int]map[int]int) // issue -> (projectNumber -> estimate)
|
|
issueEstimateCacheMu sync.RWMutex
|
|
)
|
|
|
|
// getIssueEstimatesAcrossProjects fetches all project items for the issue and returns
|
|
// a map of project number -> Estimate value (if present and >0).
|
|
func getIssueEstimatesAcrossProjects(issueNumber int) (map[int]int, error) {
|
|
// Cache lookup
|
|
issueEstimateCacheMu.RLock()
|
|
if m, ok := issueEstimateCache[issueNumber]; ok {
|
|
issueEstimateCacheMu.RUnlock()
|
|
return m, nil
|
|
}
|
|
issueEstimateCacheMu.RUnlock()
|
|
|
|
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:100){
|
|
nodes{
|
|
project{ number }
|
|
fieldValues(first:20){
|
|
nodes{
|
|
__typename
|
|
... on ProjectV2ItemFieldNumberValue{
|
|
number
|
|
field{ ... on ProjectV2FieldCommon{ 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 := 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"`
|
|
} `json:"project"`
|
|
FieldValues struct {
|
|
Nodes []struct {
|
|
Typename string `json:"__typename"`
|
|
Number *float64 `json:"number,omitempty"`
|
|
Field struct {
|
|
Name string `json:"name"`
|
|
} `json:"field"`
|
|
} `json:"nodes"`
|
|
} `json:"fieldValues"`
|
|
} `json:"nodes"`
|
|
} `json:"projectItems"`
|
|
} `json:"issue"`
|
|
} `json:"repository"`
|
|
} `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(out, &resp); err != nil {
|
|
return nil, err
|
|
}
|
|
result := make(map[int]int)
|
|
for _, it := range resp.Data.Repository.Issue.ProjectItems.Nodes {
|
|
pid := it.Project.Number
|
|
for _, fv := range it.FieldValues.Nodes {
|
|
if strings.EqualFold(fv.Field.Name, "Estimate") && fv.Number != nil {
|
|
v := int(*fv.Number)
|
|
if v > 0 {
|
|
result[pid] = v
|
|
}
|
|
}
|
|
}
|
|
}
|
|
issueEstimateCacheMu.Lock()
|
|
issueEstimateCache[issueNumber] = result
|
|
issueEstimateCacheMu.Unlock()
|
|
return result, nil
|
|
}
|
|
|
|
// GetEstimateFromAnyProject returns the first positive estimate found for the issue,
|
|
// preferring the provided project order when specified.
|
|
func GetEstimateFromAnyProject(issueNumber int, preferredProjects []int) (int, int, error) {
|
|
m, err := getIssueEstimatesAcrossProjects(issueNumber)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
for _, pid := range preferredProjects {
|
|
if v, ok := m[pid]; ok && v > 0 {
|
|
return v, pid, nil
|
|
}
|
|
}
|
|
for pid, v := range m {
|
|
if v > 0 {
|
|
return v, pid, nil
|
|
}
|
|
}
|
|
return 0, 0, nil
|
|
}
|