Update GitHub Manager to work with milestones and add forecast command (#39070)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user