fleet/tools/github-manage/pkg/ghapi/estimates.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
}