Update GitHub Manager to work with milestones and add forecast command (#39070)

This commit is contained in:
Luke Heath
2026-01-30 17:01:11 -06:00
committed by GitHub
parent 10736b650f
commit 18a541444d
7 changed files with 325 additions and 170 deletions
+1 -1
View File
@@ -42,7 +42,7 @@ func main() {
rootCmd.AddCommand(estimatedCmd)
rootCmd.AddCommand(sprintCmd)
rootCmd.AddCommand(milestoneCmd)
rootCmd.AddCommand(roadmapCmd)
rootCmd.AddCommand(releasesCmd)
rootCmd.AddCommand(preSprintCmd)
// Test command to test SetCurrentSprint functionality
+314
View File
@@ -0,0 +1,314 @@
package main
import (
"fmt"
"sort"
"strings"
"fleetdm/gm/pkg/ghapi"
"github.com/spf13/cobra"
)
var releasesCmd = &cobra.Command{
Use: "releases",
Short: "Releases utilities",
}
var releasesSyncEstimatesCmd = &cobra.Command{
Use: "sync-estimates",
Short: "Sync estimates for issues on the Releases planning project from issues or sub-issues.",
Long: `Sync estimates for issues on the Releases planning project from issues or sub-issues.
This command copies estimate values from source projects (drafting, product group projects) to the
Release planning project. If no direct estimate is found, it sums estimates from sub-issues.
Usage modes:
1. Sync all Releases issues:
gm releases sync-estimates
2. Sync issues from a specific milestone:
gm releases sync-estimates --milestone "Fleet 4.77.0"
3. Sync a single issue:
gm releases sync-estimates --issue 12345
4. Sync a single issue in a milestone (validates milestone membership):
gm releases sync-estimates --issue 12345 --milestone "Fleet 4.77.0"
By default, issues with existing estimates are skipped. Use --overwrite to update them.`,
Run: func(cmd *cobra.Command, args []string) {
releasesProjectID := 87 // fleet Releases project
issueNum, _ := cmd.Flags().GetInt("issue")
overwrite, _ := cmd.Flags().GetBool("overwrite")
milestoneTitle, _ := cmd.Flags().GetString("milestone")
// Define candidate source projects for estimates
sources := ghapi.DefaultEstimateSourceProjects()
var targets []int
if issueNum > 0 {
fmt.Printf("Checking Releases membership for #%d...\n", issueNum)
targets = []int{issueNum}
// Ensure the specified issue is on Releases
if !ghapi.IsIssueInProject(issueNum, releasesProjectID) {
fmt.Printf("Error: issue #%d is not currently on the Releases project (%d)\n", issueNum, releasesProjectID)
return
}
fmt.Printf("Gathering estimates for #%d...\n", issueNum)
// Optional milestone filter for single issue
if milestoneTitle != "" {
fmt.Printf("Filtering by milestone '%s'...\n", milestoneTitle)
milestoneIssues, err := ghapi.GetIssuesByMilestone(milestoneTitle, 2000)
if err != nil {
fmt.Printf("Failed to get issues for milestone '%s': %v\n", milestoneTitle, err)
return
}
found := false
for _, num := range milestoneIssues {
if num == issueNum {
found = true
break
}
}
if !found {
fmt.Printf("Issue #%d is not in milestone '%s'; skipping.\n", issueNum, milestoneTitle)
return
}
fmt.Printf("Issue #%d matched milestone '%s'.\n", issueNum, milestoneTitle)
}
} else if milestoneTitle != "" {
// When milestone is specified, fetch milestone issues first
fmt.Printf("Finding issues in milestone '%s'...\n", milestoneTitle)
milestoneIssues, err := ghapi.GetIssuesByMilestone(milestoneTitle, 2000)
if err != nil {
fmt.Printf("Failed to get issues for milestone '%s': %v\n", milestoneTitle, err)
return
}
fmt.Printf("Found %d issues in milestone '%s'.\n", len(milestoneIssues), milestoneTitle)
// Fetch Releases project items to build a set for efficient filtering
fmt.Println("Fetching Releases project items...")
items, _, err := ghapi.GetProjectItemsWithTotal(releasesProjectID, 1000)
if err != nil {
fmt.Printf("Failed to fetch releases items: %v\n", err)
return
}
// Build set of issue numbers on Releases project
releasesSet := make(map[int]struct{})
for _, it := range items {
if it.Content.Number > 0 {
releasesSet[it.Content.Number] = struct{}{}
}
}
// Filter milestone issues to only those on Releases project
fmt.Println("Filtering to issues on Releases project...")
for _, num := range milestoneIssues {
if _, onReleases := releasesSet[num]; onReleases {
targets = append(targets, num)
}
}
fmt.Printf("Found %d issues from milestone '%s' on Releases project.\n", len(targets), milestoneTitle)
} else {
// No milestone specified, fetch all releases issues
fmt.Println("Finding all Releases issues...")
items, _, err := ghapi.GetProjectItemsWithTotal(releasesProjectID, 1000)
if err != nil {
fmt.Printf("Failed to fetch releases items: %v\n", err)
return
}
for _, it := range items {
if it.Content.Number > 0 {
targets = append(targets, it.Content.Number)
}
}
fmt.Printf("Found %d Releases issues.\n", len(targets))
}
// Make iteration stable for output
sort.Ints(targets)
var (
updated int
skipped int
errors int
)
for _, n := range targets {
fmt.Printf("\nGathering estimate for #%d...\n", n)
// Skip if releases already has estimate and --all-issues not provided
if !overwrite {
if val, ok, _ := ghapi.GetEstimateFromProject(n, releasesProjectID); ok && val > 0 {
skipped++
fmt.Printf("Skipping #%d: releases estimate already set to %d\n", n, val)
continue
}
}
// Primary: direct estimate from known projects
est, src, _ := ghapi.GetEstimateForIssueAcrossProjects(n, sources)
if est > 0 && src > 0 {
fmt.Printf("Found estimate %d in project %d.\n", est, src)
}
// Secondary: sum of sub-issue estimates
if est == 0 {
related, _ := ghapi.GetRelatedIssueNumbers(n)
if len(related) > 0 {
fmt.Printf("Found %d issues tied to #%d...\n", len(related), n)
} else {
fmt.Printf("No direct estimate found; checking sub-issues for #%d...\n", n)
}
sum, _ := ghapi.SumEstimatesFromSubIssues(n, sources)
est = sum
src = 0 // aggregated
if est > 0 {
fmt.Printf("Using aggregated sub-issue estimate: %d.\n", est)
}
}
if est == 0 {
fmt.Printf("No estimate found for #%d; leaving releases unchanged\n", n)
continue
}
if err := ghapi.SetEstimateInProject(n, releasesProjectID, est); err != nil {
errors++
fmt.Printf("Failed to set releases estimate for #%d: %v\n", n, err)
continue
}
updated++
if src == 0 {
fmt.Printf("Updated #%d: releases estimate set to %d (sum of sub-issues)\n", n, est)
} else {
fmt.Printf("Updated #%d: releases estimate set to %d (from project %d)\n", n, est, src)
}
}
fmt.Printf("\nSummary: %d updated, %d skipped, %d errors\n", updated, skipped, errors)
},
}
var releasesForecastCmd = &cobra.Command{
Use: "forecast",
Short: "Calculate effort forecast for a milestone based on t-shirt sizes",
Long: `Calculate effort forecast for a milestone based on t-shirt sizes.
This command retrieves all issues in a milestone on the Releases project, maps their
t-shirt sizes to numeric values, and provides a total forecast.
Size mapping:
XXS: 3
XS: 8
S: 25
M: 50
L: 75
XL: 100
Usage:
gm releases forecast --milestone "Fleet 4.83.0"`,
Run: func(cmd *cobra.Command, args []string) {
releasesProjectID := 87 // fleet Releases project
milestoneTitle, _ := cmd.Flags().GetString("milestone")
if milestoneTitle == "" {
fmt.Println("Error: --milestone flag is required")
return
}
// Size to numeric value mapping
sizeMap := map[string]int{
"XXS": 3,
"XS": 8,
"S": 25,
"M": 50,
"L": 75,
"XL": 100,
}
// Fetch milestone issues
fmt.Printf("Finding issues in milestone '%s'...\n", milestoneTitle)
milestoneIssues, err := ghapi.GetIssuesByMilestone(milestoneTitle, 2000)
if err != nil {
fmt.Printf("Failed to get issues for milestone '%s': %v\n", milestoneTitle, err)
return
}
fmt.Printf("Found %d issues in milestone '%s'.\n", len(milestoneIssues), milestoneTitle)
// Fetch Releases project items
fmt.Println("Fetching Releases project items...")
items, _, err := ghapi.GetProjectItemsWithTotal(releasesProjectID, 1000)
if err != nil {
fmt.Printf("Failed to fetch releases items: %v\n", err)
return
}
// Build map of issue number to project item ID for items on Releases project
issueToItemID := make(map[int]string)
for _, it := range items {
if it.Content.Number > 0 {
issueToItemID[it.Content.Number] = it.ID
}
}
// Calculate forecast for milestone issues on Releases project
total := 0
counted := 0
missing := 0
sizeBreakdown := make(map[string]int)
fmt.Println("\nProcessing issues...")
for _, num := range milestoneIssues {
itemID, exists := issueToItemID[num]
if !exists {
missing++
continue
}
// Get T-shirt size field value for this item
size, err := ghapi.GetProjectItemFieldValue(itemID, releasesProjectID, "T-shirt size")
if err != nil || size == "" {
missing++
continue
}
if value, ok := sizeMap[size]; ok {
total += value
counted++
sizeBreakdown[size]++
fmt.Printf(" #%d: %s (%d)\n", num, size, value)
} else {
fmt.Printf(" #%d: unknown size '%s' (skipped)\n", num, size)
missing++
}
}
// Display results
fmt.Println("\n" + strings.Repeat("=", 50))
fmt.Printf("Milestone: %s\n", milestoneTitle)
fmt.Printf("Total issues in milestone: %d\n", len(milestoneIssues))
fmt.Printf("Issues on Releases project with valid sizes: %d\n", counted)
fmt.Printf("Issues without size or not on Releases: %d\n", missing)
fmt.Println("\nSize breakdown:")
for _, size := range []string{"XXS", "XS", "S", "M", "L", "XL"} {
if count, ok := sizeBreakdown[size]; ok {
fmt.Printf(" %s: %d issue(s) = %d points\n", size, count, count*sizeMap[size])
}
}
fmt.Println("\n" + strings.Repeat("=", 50))
fmt.Printf("TOTAL FORECAST: %d points\n", total)
fmt.Println(strings.Repeat("=", 50))
},
}
func init() {
releasesCmd.AddCommand(releasesSyncEstimatesCmd)
releasesCmd.AddCommand(releasesForecastCmd)
releasesSyncEstimatesCmd.Flags().IntP("issue", "i", 0, "Only sync for the given issue number; must be on the Releases project")
releasesSyncEstimatesCmd.Flags().BoolP("overwrite", "o", false, "Overwrite existing Releases estimates (by default, issues with estimates are skipped)")
releasesSyncEstimatesCmd.Flags().StringP("milestone", "m", "", "Only process issues in this milestone, e.g., Fleet 4.77.0")
releasesForecastCmd.Flags().StringP("milestone", "m", "", "Milestone to forecast, e.g., Fleet 4.83.0 (required)")
}
-160
View File
@@ -1,160 +0,0 @@
package main
import (
"fmt"
"sort"
"strings"
"fleetdm/gm/pkg/ghapi"
"github.com/spf13/cobra"
)
var roadmapCmd = &cobra.Command{
Use: "roadmap",
Short: "Roadmap utilities",
}
var roadmapSyncEstimatesCmd = &cobra.Command{
Use: "sync-estimates",
Short: "Sync estimates for issues on the Roadmap project from drafting/sprint projects or sub-issues",
Run: func(cmd *cobra.Command, args []string) {
roadmapProjectID := 87 // fleet Roadmap project
issueNum, _ := cmd.Flags().GetInt("issue")
overwrite, _ := cmd.Flags().GetBool("overwrite")
sprintTitle, _ := cmd.Flags().GetString("sprint")
// Define candidate source projects for estimates
sources := ghapi.DefaultEstimateSourceProjects()
var targets []int
if issueNum > 0 {
fmt.Printf("Checking Roadmap membership for #%d...\n", issueNum)
targets = []int{issueNum}
// Ensure the specified issue is on Roadmap
if !ghapi.IsIssueInProject(issueNum, roadmapProjectID) {
fmt.Printf("Error: issue #%d is not currently on the Roadmap project (%d)\n", issueNum, roadmapProjectID)
return
}
fmt.Printf("Gathering estimates for #%d...\n", issueNum)
// Optional sprint filter for single issue
if sprintTitle != "" {
fmt.Printf("Filtering by sprint '%s'...\n", sprintTitle)
checkProjects := append(append([]int{}, sources...), roadmapProjectID)
sprintSet, _ := ghapi.GetIssueNumbersForSprintAcrossProjects(checkProjects, sprintTitle, 2000)
if _, ok := sprintSet[issueNum]; !ok {
fmt.Printf("Issue #%d is not in sprint '%s' across known projects; skipping.\n", issueNum, sprintTitle)
return
}
fmt.Printf("Issue #%d matched sprint '%s'.\n", issueNum, sprintTitle)
}
} else {
fmt.Println("Finding Roadmap issues...")
// Fetch all roadmap issues
items, _, err := ghapi.GetProjectItemsWithTotal(roadmapProjectID, 1000)
if err != nil {
fmt.Printf("Failed to fetch roadmap items: %v\n", err)
return
}
for _, it := range items {
if it.Content.Number > 0 {
targets = append(targets, it.Content.Number)
}
}
fmt.Printf("Found %d Roadmap issues.\n", len(targets))
// Apply sprint filter if provided
if sprintTitle != "" {
fmt.Printf("Filtering by sprint '%s'...\n", sprintTitle)
// Build sprint set from source projects only to avoid re-querying Roadmap (we already have its items)
sprintSet, _ := ghapi.GetIssueNumbersForSprintAcrossProjects(sources, sprintTitle, 2000)
filtered := make([]int, 0, len(targets))
for _, n := range targets {
// include if in sources' sprint set or roadmap item shows matching sprint
_, inSet := sprintSet[n]
if inSet {
filtered = append(filtered, n)
continue
}
// check roadmap item sprint title from prefetched items
for _, it := range items {
if it.Content.Number == n && it.Sprint != nil && strings.EqualFold(strings.TrimSpace(it.Sprint.Title), strings.TrimSpace(sprintTitle)) {
filtered = append(filtered, n)
break
}
}
}
targets = filtered
fmt.Printf("%d issues match sprint '%s'.\n", len(targets), sprintTitle)
}
}
// Make iteration stable for output
sort.Ints(targets)
var (
updated int
skipped int
errors int
)
for _, n := range targets {
fmt.Printf("\nGathering estimate for #%d...\n", n)
// Skip if roadmap already has estimate and --all-issues not provided
if !overwrite {
if val, ok, _ := ghapi.GetEstimateFromProject(n, roadmapProjectID); ok && val > 0 {
skipped++
fmt.Printf("Skipping #%d: roadmap estimate already set to %d\n", n, val)
continue
}
}
// Primary: direct estimate from known projects
est, src, _ := ghapi.GetEstimateForIssueAcrossProjects(n, sources)
if est > 0 && src > 0 {
fmt.Printf("Found estimate %d in project %d.\n", est, src)
}
// Secondary: sum of sub-issue estimates
if est == 0 {
related, _ := ghapi.GetRelatedIssueNumbers(n)
if len(related) > 0 {
fmt.Printf("Found %d issues tied to #%d...\n", len(related), n)
} else {
fmt.Printf("No direct estimate found; checking sub-issues for #%d...\n", n)
}
sum, _ := ghapi.SumEstimatesFromSubIssues(n, sources)
est = sum
src = 0 // aggregated
if est > 0 {
fmt.Printf("Using aggregated sub-issue estimate: %d.\n", est)
}
}
if est == 0 {
fmt.Printf("No estimate found for #%d; leaving roadmap unchanged\n", n)
continue
}
if err := ghapi.SetEstimateInProject(n, roadmapProjectID, est); err != nil {
errors++
fmt.Printf("Failed to set roadmap estimate for #%d: %v\n", n, err)
continue
}
updated++
if src == 0 {
fmt.Printf("Updated #%d: roadmap estimate set to %d (sum of sub-issues)\n", n, est)
} else {
fmt.Printf("Updated #%d: roadmap estimate set to %d (from project %d)\n", n, est, src)
}
}
fmt.Printf("\nSummary: %d updated, %d skipped, %d errors\n", updated, skipped, errors)
},
}
func init() {
roadmapCmd.AddCommand(roadmapSyncEstimatesCmd)
roadmapSyncEstimatesCmd.Flags().IntP("issue", "i", 0, "Only sync for the given issue number; must be on the Roadmap project")
roadmapSyncEstimatesCmd.Flags().BoolP("overwrite", "o", false, "Overwrite existing Roadmap estimates (by default, issues with estimates are skipped)")
roadmapSyncEstimatesCmd.Flags().StringP("sprint", "s", "", "Only process issues in this sprint (iteration title), e.g., 4.77.0")
}