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 }