diff --git a/tools/github-manage/cmd/gm/main.go b/tools/github-manage/cmd/gm/main.go index e40342c7eb..6c961e98c6 100644 --- a/tools/github-manage/cmd/gm/main.go +++ b/tools/github-manage/cmd/gm/main.go @@ -41,6 +41,8 @@ func main() { rootCmd.AddCommand(projectCmd) rootCmd.AddCommand(estimatedCmd) rootCmd.AddCommand(sprintCmd) + rootCmd.AddCommand(milestoneCmd) + rootCmd.AddCommand(roadmapCmd) // Test command to test SetCurrentSprint functionality rootCmd.AddCommand(&cobra.Command{ diff --git a/tools/github-manage/cmd/gm/milestone.go b/tools/github-manage/cmd/gm/milestone.go new file mode 100644 index 0000000000..b2343467e9 --- /dev/null +++ b/tools/github-manage/cmd/gm/milestone.go @@ -0,0 +1,397 @@ +package main + +import ( + "fmt" + "sort" + "strings" + + "github.com/spf13/cobra" + + "fleetdm/gm/pkg/ghapi" +) + +// milestoneCmd is the parent command for milestone-related operations. +var milestoneCmd = &cobra.Command{ + Use: "milestone", + Short: "Milestone-related utilities", +} + +var ( + milestoneFormat string + milestoneStripEmojis bool + milestoneSummarySort string + milestoneIncludeClosed bool + milestoneIgnoreProject string + milestoneFilterLabels string +) + +var milestoneReportCmd = &cobra.Command{ + Use: "report ", + Short: "Print a table of issues and their project statuses for a milestone", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + milestoneName := args[0] + + // Fetch issues in milestone (with titles) + msIssues, err := ghapi.GetIssuesByMilestoneWithTitles(milestoneName, 1000) + if err != nil || len(msIssues) == 0 { + // If milestone doesn't exist or has no issues, list available milestones + miles, lerr := ghapi.ListRepoMilestones(milestoneIncludeClosed) + if lerr != nil { + return fmt.Errorf("failed to find milestone '%s' and also failed to list milestones: %v", milestoneName, lerr) + } + format := strings.ToLower(strings.TrimSpace(milestoneFormat)) + if format == "" { + format = "tsv" + } + // Helpful hint when filtering open-only yields none + var msg string + if len(miles) == 0 && !milestoneIncludeClosed { + msg = fmt.Sprintf("No open milestones found. Use --include-closed to include closed milestones. (Requested milestone: '%s')", milestoneName) + } else { + msg = fmt.Sprintf("No issues found for milestone '%s'. Available milestones:", milestoneName) + } + switch format { + case "tsv": + fmt.Println(msg) + fmt.Println(strings.Join([]string{"Title", "State"}, "\t")) + for _, m := range miles { + t := m.Title + if milestoneStripEmojis { + t = stripEmojis(t) + } + fmt.Println(strings.Join([]string{t, m.State}, "\t")) + } + case "md", "markdown": + fmt.Println(msg) + fmt.Printf("| %s |\n", strings.Join([]string{"Title", "State"}, " | ")) + fmt.Printf("| %s |\n", strings.Join([]string{"---", "---"}, " | ")) + for _, m := range miles { + t := m.Title + if milestoneStripEmojis { + t = stripEmojis(t) + } + fmt.Printf("| %s | %s |\n", t, m.State) + } + default: + return fmt.Errorf("unsupported --format %q (use: tsv or md)", milestoneFormat) + } + // Return error so callers can detect non-report condition + return fmt.Errorf("milestone '%s' not found or empty", milestoneName) + } + // Optional label filtering: require all specified labels to be present + if strings.TrimSpace(milestoneFilterLabels) != "" { + wantParts := strings.Split(milestoneFilterLabels, ",") + wants := make([]string, 0, len(wantParts)) + for _, wp := range wantParts { + w := strings.ToLower(strings.TrimSpace(wp)) + if w != "" { + wants = append(wants, w) + } + } + if len(wants) > 0 { + filtered := make([]ghapi.MilestoneIssue, 0, len(msIssues)) + issueLoop: + for _, mi := range msIssues { + labelSet := make(map[string]struct{}, len(mi.Labels)) + for _, l := range mi.Labels { + ln := strings.ToLower(strings.TrimSpace(l.Name)) + if ln != "" { + labelSet[ln] = struct{}{} + } + } + for _, want := range wants { + if _, ok := labelSet[want]; !ok { + continue issueLoop + } + } + filtered = append(filtered, mi) + } + msIssues = filtered + } + } + + // Extract numbers for project discovery (post filtering) + issueNums := make([]int, 0, len(msIssues)) + for _, it := range msIssues { + issueNums = append(issueNums, it.Number) + } + + // Determine all projects across these issues, dynamically + projects, _ := ghapi.GetProjectsForIssues(issueNums) + // Apply ignore-project filtering if provided + if strings.TrimSpace(milestoneIgnoreProject) != "" { + // build tokens (case-insensitive substring match) + parts := strings.Split(milestoneIgnoreProject, ",") + toks := make([]string, 0, len(parts)) + for _, p := range parts { + t := strings.ToLower(strings.TrimSpace(p)) + if t != "" { + toks = append(toks, t) + } + } + if len(toks) > 0 { + filtered := make([]ghapi.ProjectInfo, 0, len(projects)) + for _, p := range projects { + title := p.Title + if title == "" { + // Without a title we can't match by name; keep it + filtered = append(filtered, p) + continue + } + name := strings.ToLower(stripEmojis(title)) + exclude := false + for _, tok := range toks { + if tok != "" && strings.Contains(name, tok) { + exclude = true + break + } + } + if !exclude { + filtered = append(filtered, p) + } + } + projects = filtered + } + } + headers := make([]string, 0, len(projects)+2) + headers = append(headers, "Number") + for _, p := range projects { + if p.Title != "" { + title := p.Title + if milestoneStripEmojis { + title = stripEmojis(title) + } + headers = append(headers, title) + } else { + headers = append(headers, fmt.Sprintf("%d", p.ID)) + } + } + // Final column header for issue title + headers = append(headers, "Title") + + format := strings.ToLower(strings.TrimSpace(milestoneFormat)) + if format == "" { + format = "tsv" + } + + switch format { + case "tsv": + // Print header as TSV + // Heading line first + fmt.Printf("Milestone\treport\t%s\n", milestoneName) + fmt.Println(strings.Join(headers, "\t")) + case "md", "markdown": + // Heading line first + fmt.Printf("|Milestone report %s|\n", milestoneName) + // Markdown header + fmt.Printf("| %s |\n", strings.Join(headers, " | ")) + // Separator row + seps := make([]string, len(headers)) + for i := range seps { + seps[i] = "---" + } + fmt.Printf("| %s |\n", strings.Join(seps, " | ")) + default: + return fmt.Errorf("unsupported --format %q (use: tsv or md)", milestoneFormat) + } + + // Aggregator: projectID -> status -> count (only when Present) + agg := make(map[int]map[string]int, len(projects)) + + // For each issue, gather statuses per project + for _, mi := range msIssues { + num := mi.Number + // Build the list of project IDs in header order + pids := make([]int, 0, len(projects)) + for _, p := range projects { + pids = append(pids, p.ID) + } + statuses, _ := ghapi.GetIssueProjectStatuses(num, pids) + row := []string{fmt.Sprintf("%d", num)} + for _, pid := range pids { + ps, ok := statuses[pid] + if !ok || !ps.Present { + row = append(row, "-") + continue + } + if strings.TrimSpace(ps.Status) == "" { + cell := "No Status" + if milestoneStripEmojis { + cell = stripEmojis(cell) + } + row = append(row, cell) + if agg[pid] == nil { + agg[pid] = make(map[string]int) + } + agg[pid]["No Status"]++ + } else { + cell := ps.Status + if milestoneStripEmojis { + cell = stripEmojis(cell) + } + row = append(row, cell) + if agg[pid] == nil { + agg[pid] = make(map[string]int) + } + agg[pid][ps.Status]++ + } + } + // Append truncated title column + title := mi.Title + if milestoneStripEmojis { + title = stripEmojis(title) + } + row = append(row, truncateTitle(title, 25)) + if format == "tsv" { + fmt.Println(strings.Join(row, "\t")) + } else { + fmt.Printf("| %s |\n", strings.Join(row, " | ")) + } + } + + // Build and print summary rows: Project, Status, Count + type sumRow struct { + Project string + ProjectID int + Status string + Count int + } + rows := make([]sumRow, 0) + // helper: title by pid + getProjTitle := func(pid int) string { + for _, p := range projects { + if p.ID == pid { + return p.Title + } + } + return fmt.Sprintf("%d", pid) + } + for pid, m := range agg { + title := getProjTitle(pid) + for status, c := range m { + rows = append(rows, sumRow{Project: title, ProjectID: pid, Status: status, Count: c}) + } + } + if len(rows) > 0 { + // sorting: default by count asc; if --summary-sort name, sort by project name (emoji-stripped), then status + key := strings.ToLower(strings.TrimSpace(milestoneSummarySort)) + if key == "" { + key = "count" + } + sort.Slice(rows, func(i, j int) bool { + if key == "name" { + pi := plainForSort(rows[i].Project) + pj := plainForSort(rows[j].Project) + if pi != pj { + return pi < pj + } + si := plainForSort(rows[i].Status) + sj := plainForSort(rows[j].Status) + if si != sj { + return si < sj + } + if rows[i].Count != rows[j].Count { + return rows[i].Count < rows[j].Count + } + return rows[i].ProjectID < rows[j].ProjectID + } + if rows[i].Count != rows[j].Count { + return rows[i].Count < rows[j].Count + } + pi := plainForSort(rows[i].Project) + pj := plainForSort(rows[j].Project) + if pi != pj { + return pi < pj + } + si := plainForSort(rows[i].Status) + sj := plainForSort(rows[j].Status) + if si != sj { + return si < sj + } + return rows[i].ProjectID < rows[j].ProjectID + }) + + if format == "tsv" { + fmt.Println() + fmt.Println("Summary") + fmt.Println(strings.Join([]string{"Project", "Status", "Count"}, "\t")) + for _, r := range rows { + proj := r.Project + stat := r.Status + if milestoneStripEmojis { + proj = stripEmojis(proj) + stat = stripEmojis(stat) + } + fmt.Println(strings.Join([]string{proj, stat, fmt.Sprintf("%d", r.Count)}, "\t")) + } + } else { + fmt.Println() + fmt.Println("Summary") + fmt.Printf("| %s |\n", strings.Join([]string{"Project", "Status", "Count"}, " | ")) + fmt.Printf("| %s |\n", strings.Join([]string{"---", "---", "---"}, " | ")) + for _, r := range rows { + proj := r.Project + stat := r.Status + if milestoneStripEmojis { + proj = stripEmojis(proj) + stat = stripEmojis(stat) + } + fmt.Printf("| %s | %s | %d |\n", proj, stat, r.Count) + } + } + } + return nil + }, +} + +func init() { + milestoneCmd.AddCommand(milestoneReportCmd) + milestoneReportCmd.Flags().StringVar(&milestoneFormat, "format", "tsv", "Output format: tsv (default) or md") + milestoneReportCmd.Flags().BoolVar(&milestoneStripEmojis, "strip-emojis", false, "Strip emojis from project titles and statuses") + milestoneReportCmd.Flags().StringVar(&milestoneSummarySort, "summary-sort", "count", "Summary sort: count (default) or name") + milestoneReportCmd.Flags().BoolVar(&milestoneIncludeClosed, "include-closed", false, "Include closed milestones when listing available milestones") + milestoneReportCmd.Flags().StringVar(&milestoneIgnoreProject, "ignore-project", "", "Comma-separated substrings to exclude matching project titles (case-insensitive). Example: 'qa,cust' excludes ':help-qa', ':help-customers', and 'Customer requests (open)'.") + milestoneReportCmd.Flags().StringVar(&milestoneFilterLabels, "filter-labels", "", "Comma-separated list of labels; only issues containing ALL of these labels are included (case-insensitive). Example: 'story,customer-numa'.") +} + +// stripEmojis removes common emoji and pictographic characters from a string, +// including variation selectors and zero-width joiners, leaving readable text. +func stripEmojis(s string) string { + var b strings.Builder + for _, r := range s { + // Skip variation selector and zero-width joiners/spaces + if r == 0xFE0F || r == 0x200D || r == 0x200C || r == 0x200B { + continue + } + // Common emoji blocks and symbols/pictographs + if (r >= 0x1F300 && r <= 0x1FAFF) || // Misc symbols & pictographs to Supplemental symbols + (r >= 0x2600 && r <= 0x27BF) { // Misc symbols + Dingbats + continue + } + b.WriteRune(r) + } + return strings.TrimSpace(b.String()) +} + +// plainForSort returns a simplified string without emojis, lowercased, for consistent sorting +func plainForSort(s string) string { + return strings.ToLower(stripEmojis(s)) +} + +// truncateTitle truncates a string to maxRunes characters (by rune count) and appends +// "..." if the original was longer. The ellipsis is not counted toward maxRunes. +func truncateTitle(s string, maxRunes int) string { + if maxRunes <= 0 { + return "" + } + count := 0 + for idx := range s { + if count == maxRunes { + // idx is byte index at rune boundary for the first runes + return s[:idx] + "..." + } + count++ + } + return s +} diff --git a/tools/github-manage/cmd/gm/project.go b/tools/github-manage/cmd/gm/project.go index 03d411f511..05fe7533a4 100644 --- a/tools/github-manage/cmd/gm/project.go +++ b/tools/github-manage/cmd/gm/project.go @@ -34,6 +34,7 @@ func init() { projectCmd.Flags().IntP("limit", "l", 100, "Maximum number of items to fetch") estimatedCmd.Flags().IntP("limit", "l", 500, "Maximum number of items to fetch from drafting project") sprintCmd.Flags().IntP("limit", "l", 100, "Maximum number of items to fetch") + sprintCmd.Flags().BoolP("previous", "p", false, "Show previous sprint instead of current") } var estimatedCmd = &cobra.Command{ @@ -62,7 +63,7 @@ var estimatedCmd = &cobra.Command{ // current iteration (using the already implemented @current logic when setting sprint). var sprintCmd = &cobra.Command{ Use: "sprint [project-id-or-alias]", - Short: "Get GitHub issues in the current sprint for a project", + Short: "Get GitHub issues in the current sprint for a project (use -p for previous sprint)", Args: cobra.ExactArgs(1), Run: func(cmd *cobra.Command, args []string) { projectID, err := ghapi.ResolveProjectID(args[0]) @@ -77,6 +78,13 @@ var sprintCmd = &cobra.Command{ return } + prev, _ := cmd.Flags().GetBool("previous") + if prev { + // Pass a mode hint via the search parameter + tui.RunTUI(tui.SprintCommand, projectID, limit, "previous") + return + } + tui.RunTUI(tui.SprintCommand, projectID, limit, "") }, } diff --git a/tools/github-manage/cmd/gm/roadmap.go b/tools/github-manage/cmd/gm/roadmap.go new file mode 100644 index 0000000000..5f50a1cde2 --- /dev/null +++ b/tools/github-manage/cmd/gm/roadmap.go @@ -0,0 +1,160 @@ +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") +} diff --git a/tools/github-manage/pkg/ghapi/estimates.go b/tools/github-manage/pkg/ghapi/estimates.go new file mode 100644 index 0000000000..618312434a --- /dev/null +++ b/tools/github-manage/pkg/ghapi/estimates.go @@ -0,0 +1,285 @@ +package ghapi + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "sync" +) + +// DefaultEstimateSourceProjects returns the default set of projects to scan for estimates +// when syncing to Roadmap: drafting and product group projects. +func DefaultEstimateSourceProjects() []int { + // unique list; ignore roadmap 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 +} diff --git a/tools/github-manage/pkg/ghapi/milestone.go b/tools/github-manage/pkg/ghapi/milestone.go new file mode 100644 index 0000000000..b2717423d3 --- /dev/null +++ b/tools/github-manage/pkg/ghapi/milestone.go @@ -0,0 +1,355 @@ +package ghapi + +import ( + "bytes" + "encoding/json" + "fmt" + "os/exec" + "sort" + "strings" + "time" + + "fleetdm/gm/pkg/logger" +) + +// GetIssuesByMilestone returns issue numbers for a given milestone name. Limit controls max items. +func GetIssuesByMilestone(name string, limit int) ([]int, error) { + if limit <= 0 { + limit = 1000 + } + // Use gh to list issues for the current repo by milestone + // Include closed/open (state all) to reflect full milestone scope + cmd := fmt.Sprintf("gh issue list --state all --milestone %q --limit %d --json number", name, limit) + out, err := RunCommandAndReturnOutput(cmd) + if err != nil { + return nil, err + } + var arr []struct { + Number int `json:"number"` + } + if err := json.Unmarshal(out, &arr); err != nil { + return nil, err + } + nums := make([]int, 0, len(arr)) + for _, it := range arr { + nums = append(nums, it.Number) + } + return nums, nil +} + +// GetIssuesByMilestoneWithTitles returns issue numbers and titles for a milestone. +func GetIssuesByMilestoneWithTitles(name string, limit int) ([]MilestoneIssue, error) { + if limit <= 0 { + limit = 1000 + } + cmd := fmt.Sprintf("gh issue list --state all --milestone %q --limit %d --json number,title,labels", name, limit) + out, err := RunCommandAndReturnOutput(cmd) + if err != nil { + return nil, err + } + var arr []MilestoneIssue + if err := json.Unmarshal(out, &arr); err != nil { + return nil, err + } + return arr, nil +} + +// ProjectInfo represents a GitHub Project (v2) basic descriptor used in reports. +type ProjectInfo struct { + ID int // project number (e.g., 58) + Title string // project title (e.g., g-mdm) +} + +// MilestoneIssue represents a simple issue record for a milestone. +type MilestoneIssue struct { + Number int `json:"number"` + Title string `json:"title"` + Labels []struct { + Name string `json:"name"` + } `json:"labels"` +} + +// GetIssueProjects returns projects (id->title) that a specific issue belongs to. +func GetIssueProjects(issueNumber int) (map[int]string, error) { + 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:50){ + nodes{ + project{ number title } + } + } + } + } + }` + 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"` + Title string `json:"title"` + } `json:"project"` + } `json:"nodes"` + } `json:"projectItems"` + } `json:"issue"` + } `json:"repository"` + } `json:"data"` + } + if err := json.Unmarshal(out, &resp); err != nil { + return nil, err + } + m := make(map[int]string) + for _, n := range resp.Data.Repository.Issue.ProjectItems.Nodes { + if n.Project.Number != 0 { + m[n.Project.Number] = n.Project.Title + } + } + return m, nil +} + +// GetProjectsForIssues gathers the union of projects across the provided issues. +func GetProjectsForIssues(issueNumbers []int) ([]ProjectInfo, error) { + seen := make(map[int]string) + for _, num := range issueNumbers { + prjs, err := GetIssueProjects(num) + if err != nil { + // tolerate errors per-issue, continue accumulating from others + continue + } + for id, title := range prjs { + if _, ok := seen[id]; !ok { + seen[id] = title + } + } + } + list := make([]ProjectInfo, 0, len(seen)) + for id, title := range seen { + list = append(list, ProjectInfo{ID: id, Title: title}) + } + // stable order: by title then id + sort.Slice(list, func(i, j int) bool { + if list[i].Title == list[j].Title { + return list[i].ID < list[j].ID + } + return list[i].Title < list[j].Title + }) + return list, nil +} + +// GetIssueProjectStatuses returns a map of projectID -> Status value for an issue across given projects. +// If the issue is not present in a project or the Status is unset, the value will be an empty string. +type ProjectStatus struct { + Present bool // true if the issue is in this project + Status string // "" when unset +} + +func GetIssueProjectStatuses(issueNumber int, projects []int) (map[int]ProjectStatus, error) { + // Single GraphQL query to fetch all project items and their Status field for this issue + owner, repo, err := getRepoOwnerAndName() + if err != nil { + // If repo cannot be determined, return absent for all + res := make(map[int]ProjectStatus, len(projects)) + for _, pid := range projects { + res[pid] = ProjectStatus{Present: false, Status: ""} + } + return res, nil + } + + query := `query($owner:String!,$repo:String!,$number:Int!){ + repository(owner:$owner,name:$repo){ + issue(number:$number){ + projectItems(first:100){ + nodes{ + project{ number title } + fieldValues(first:50){ + nodes{ + __typename + ... on ProjectV2ItemFieldSingleSelectValue{ + field{ ... on ProjectV2FieldCommon{ name } } + 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 := runCommandWithRetry(cmd, 5, 2*time.Second) + if err != nil { + // On error, default to absent for all requested projects + res := make(map[int]ProjectStatus, len(projects)) + for _, pid := range projects { + res[pid] = ProjectStatus{Present: false, Status: ""} + } + return res, nil + } + var resp struct { + Data struct { + Repository struct { + Issue struct { + ProjectItems struct { + Nodes []struct { + Project struct { + Number int `json:"number"` + Title string `json:"title"` + } `json:"project"` + FieldValues struct { + Nodes []struct { + Typename string `json:"__typename"` + Field struct { + Name string `json:"name"` + } `json:"field"` + Name string `json:"name"` + } `json:"nodes"` + } `json:"fieldValues"` + } `json:"nodes"` + } `json:"projectItems"` + } `json:"issue"` + } `json:"repository"` + } `json:"data"` + } + if err := json.Unmarshal(out, &resp); err != nil { + res := make(map[int]ProjectStatus, len(projects)) + for _, pid := range projects { + res[pid] = ProjectStatus{Present: false, Status: ""} + } + return res, nil + } + + // Build a map of project number -> status value + found := make(map[int]ProjectStatus) + for _, node := range resp.Data.Repository.Issue.ProjectItems.Nodes { + pid := node.Project.Number + statusVal := "" + for _, fv := range node.FieldValues.Nodes { + if strings.EqualFold(fv.Field.Name, "Status") && fv.Typename == "ProjectV2ItemFieldSingleSelectValue" { + statusVal = fv.Name + break + } + } + found[pid] = ProjectStatus{Present: true, Status: statusVal} + } + + // Compose response for requested projects, marking absences with Present=false + res := make(map[int]ProjectStatus, len(projects)) + for _, pid := range projects { + if ps, ok := found[pid]; ok { + res[pid] = ps + } else { + res[pid] = ProjectStatus{Present: false, Status: ""} + } + } + return res, nil +} + +// runCommandWithRetry executes a shell command capturing combined output and retries with +// exponential backoff when a rate limit is detected in the output. +func runCommandWithRetry(command string, attempts int, baseDelay time.Duration) ([]byte, error) { + if attempts < 1 { + attempts = 1 + } + delay := baseDelay + for i := 1; i <= attempts; i++ { + logger.Debugf("Running COMMAND (attempt %d/%d): %s", i, attempts, command) + cmd := exec.Command("bash", "-c", command) + var out bytes.Buffer + cmd.Stdout = &out + cmd.Stderr = &out + err := cmd.Run() + if err == nil { + return out.Bytes(), nil + } + outStr := out.String() + logger.Errorf("Error running command (attempt %d): %s", i, outStr) + if i == attempts || !looksLikeRateLimit(outStr) { + return nil, err + } + logger.Infof("Rate limit detected; backing off for %s before retry", delay) + time.Sleep(delay) + // Exponential backoff with cap + if delay < 30*time.Second { + delay = delay * 2 + if delay > 30*time.Second { + delay = 30 * time.Second + } + } + } + return nil, fmt.Errorf("command failed after %d attempts", attempts) +} + +func looksLikeRateLimit(s string) bool { + ls := strings.ToLower(s) + if strings.Contains(ls, "rate limit") || strings.Contains(ls, "graphql_rate_limit") || strings.Contains(ls, "429") { + return true + } + return false +} + +// RepoMilestone represents a repository milestone (from REST API). +type RepoMilestone struct { + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` +} + +// ListRepoMilestones returns milestones for the current repository. +// When includeClosed is false, only open milestones are returned; otherwise all states are included. +func ListRepoMilestones(includeClosed bool) ([]RepoMilestone, error) { + owner, repo, err := getRepoOwnerAndName() + if err != nil { + return nil, err + } + state := "open" + if includeClosed { + state = "all" + } + // Paginate to ensure we gather more than one page if present + all := make([]RepoMilestone, 0) + for page := 1; page <= 10; page++ { // hard cap to avoid accidental infinite loops + cmd := fmt.Sprintf("gh api repos/%s/%s/milestones?state=%s&per_page=100&page=%d", owner, repo, state, page) + out, err := RunCommandAndReturnOutput(cmd) + if err != nil { + return nil, err + } + var arr []RepoMilestone + if err := json.Unmarshal(out, &arr); err != nil { + return nil, err + } + if len(arr) == 0 { + break + } + all = append(all, arr...) + if len(arr) < 100 { + break + } + } + // Sort open first by title, then closed by title + sort.Slice(all, func(i, j int) bool { + si := strings.ToLower(all[i].State) + sj := strings.ToLower(all[j].State) + if si != sj { + if si == "open" { + return true + } + if sj == "open" { + return false + } + } + return strings.ToLower(all[i].Title) < strings.ToLower(all[j].Title) + }) + return all, nil +} diff --git a/tools/github-manage/pkg/ghapi/projects.go b/tools/github-manage/pkg/ghapi/projects.go index 7294d98110..47f853ffb8 100644 --- a/tools/github-manage/pkg/ghapi/projects.go +++ b/tools/github-manage/pkg/ghapi/projects.go @@ -7,26 +7,31 @@ import ( "fmt" "strconv" "strings" + "time" "fleetdm/gm/pkg/logger" ) var Aliases = map[string]int{ - "mdm": 58, - "g-mdm": 58, - "draft": 67, - "drafting": 67, - "g-software": 70, - "soft": 70, - "g-orchestration": 71, - "orch": 71, + "mdm": 58, + "g-mdm": 58, + "draft": 67, + "drafting": 67, + "g-software": 70, + "soft": 70, + "g-orchestration": 71, + "orch": 71, + "sec": 97, + "g-security-compliance": 97, + "roadmap": 87, } // ProjectLabels maps project IDs to their corresponding label filters for the drafting project var ProjectLabels = map[int]string{ - 58: "#g-mdm", // mdm project - 70: "#g-software", // g-software project - 71: "#g-orchestration", // g-orchestration project + 58: "#g-mdm", // mdm project + 70: "#g-software", // g-software project + 71: "#g-orchestration", // g-orchestration project + 97: "#g-security-compliance", // g-security-compliance project } // ResolveProjectID resolves a project identifier (alias or numeric string) to a project ID. @@ -68,7 +73,7 @@ func ParseJSONtoProjectItems(jsonData []byte, limit int) ([]ProjectItem, int, er // GetProjectItemsWithTotal retrieves project items and returns the server reported total count. func GetProjectItemsWithTotal(projectID, limit int) ([]ProjectItem, int, error) { - results, err := RunCommandAndReturnOutput(fmt.Sprintf("gh project item-list --owner fleetdm --format json --limit %d %d", limit, projectID)) + results, err := runCommandWithRetry(fmt.Sprintf("gh project item-list --owner fleetdm --format json --limit %d %d", limit, projectID), 5, 2*time.Second) if err != nil { return nil, 0, err } @@ -122,6 +127,47 @@ func GetCurrentSprintItems(projectID, limit int) ([]ProjectItem, error) { // bac return items, err } +// GetPreviousSprintItemsWithTotal returns only the items in the previous sprint for a project. +// It mirrors GetCurrentSprintItemsWithTotal but selects the iteration immediately before current +// based on the iteration field configuration ordering. +func GetPreviousSprintItemsWithTotal(projectID, limit int) ([]ProjectItem, int, error) { + items, total, err := GetProjectItemsWithTotal(projectID, limit) + if err != nil { + return nil, 0, err + } + projectNodeID, err := getProjectNodeID(projectID) + if err != nil { + return nil, total, fmt.Errorf("failed to get project node id: %v", err) + } + sprintField, err := LookupProjectFieldName(projectID, "sprint") + if err != nil { + return []ProjectItem{}, total, nil // no sprint field + } + // Get current iteration details (ID, start date, duration) + curID, curStart, curDuration, err := getCurrentIterationDetails(projectNodeID, sprintField.ID) + if err != nil || curID == "" || curStart == "" || curDuration <= 0 { + return []ProjectItem{}, total, fmt.Errorf("failed to get current iteration details: %v", err) + } + // Compute previous iteration start date as ISO date string + prevStart, err := computePrevStartDate(curStart, curDuration) + if err != nil { + return []ProjectItem{}, total, fmt.Errorf("failed to compute previous iteration start date: %v", err) + } + var filtered []ProjectItem + for _, it := range items { + if it.Sprint != nil && strings.TrimSpace(it.Sprint.StartDate) == prevStart { + filtered = append(filtered, it) + } + } + return filtered, total, nil +} + +// Backward compatible helper +func GetPreviousSprintItems(projectID, limit int) ([]ProjectItem, error) { + items, _, err := GetPreviousSprintItemsWithTotal(projectID, limit) + return items, err +} + // GetProjectFields retrieves all fields for a specific project. func GetProjectFields(projectID int) (map[string]ProjectField, error) { // Run the command to get project fields @@ -556,6 +602,12 @@ func getProjectItemFieldValue(itemID string, projectID int, fieldName string) (s } name } + ... on ProjectV2ItemFieldIterationValue { + field { ... on ProjectV2FieldCommon { id } } + title + startDate + duration + } } } } @@ -580,6 +632,7 @@ func getProjectItemFieldValue(itemID string, projectID int, fieldName string) (s Number *float64 `json:"number,omitempty"` Text *string `json:"text,omitempty"` Name *string `json:"name,omitempty"` + Title *string `json:"title,omitempty"` } `json:"nodes"` } `json:"fieldValues"` } `json:"node"` @@ -603,6 +656,9 @@ func getProjectItemFieldValue(itemID string, projectID int, fieldName string) (s if fieldValue.Name != nil { return *fieldValue.Name, nil } + if fieldValue.Title != nil { + return *fieldValue.Title, nil + } } } @@ -707,3 +763,170 @@ func getCurrentIterationID(projectNodeID, fieldID string) (string, error) { return "", fmt.Errorf("no iterations found for field") } + +// getCurrentIterationDetails returns ID, startDate (YYYY-MM-DD), and duration (days) for the current iteration. +func getCurrentIterationDetails(projectNodeID, fieldID string) (string, string, int, error) { + // Same query shape as getCurrentIterationID + query := fmt.Sprintf(`{ + node(id: "%s") { + ... on ProjectV2 { + fields(first: 20) { + nodes { + ... on ProjectV2IterationField { + id + name + configuration { + iterations { + id + title + startDate + duration + } + } + } + } + } + } + } + }`, projectNodeID) + + command := fmt.Sprintf(`gh api graphql -f query='%s'`, query) + output, err := RunCommandAndReturnOutput(command) + if err != nil { + return "", "", 0, fmt.Errorf("failed to query iterations: %v", err) + } + + var response struct { + Data struct { + Node struct { + Fields struct { + Nodes []struct { + ID string `json:"id"` + Name string `json:"name"` + Configuration struct { + Iterations []struct { + ID string `json:"id"` + Title string `json:"title"` + StartDate string `json:"startDate"` + Duration int `json:"duration"` + } `json:"iterations"` + } `json:"configuration"` + } `json:"nodes"` + } `json:"fields"` + } `json:"node"` + } `json:"data"` + } + if err := json.Unmarshal(output, &response); err != nil { + return "", "", 0, fmt.Errorf("failed to parse iterations response: %v", err) + } + for _, field := range response.Data.Node.Fields.Nodes { + if field.ID == fieldID { + if len(field.Configuration.Iterations) > 0 { + it := field.Configuration.Iterations[0] + return it.ID, it.StartDate, it.Duration, nil + } + } + } + return "", "", 0, fmt.Errorf("no iterations found for field") +} + +// computePrevStartDate takes an ISO date string (YYYY-MM-DD) and a duration in days and returns the previous +// iteration start date in the same format. +func computePrevStartDate(curStart string, duration int) (string, error) { + t, err := time.Parse("2006-01-02", strings.TrimSpace(curStart)) + if err != nil { + return "", err + } + prev := t.AddDate(0, 0, -duration) + return prev.Format("2006-01-02"), nil +} + +// getPreviousIterationID attempts to find the iteration ID immediately prior to the current one. +// It relies on the ordering returned by the iteration field configuration. If a previous iteration +// is not present in the returned list, this will return an error. +func getPreviousIterationID(projectNodeID, fieldID string) (string, error) { + // Reuse the same query as getCurrentIterationID to retrieve iterations + query := fmt.Sprintf(`{ + node(id: "%s") { + ... on ProjectV2 { + fields(first: 20) { + nodes { + ... on ProjectV2IterationField { + id + name + configuration { + iterations { + id + title + startDate + duration + } + } + } + } + } + } + } + }`, projectNodeID) + + command := fmt.Sprintf(`gh api graphql -f query='%s'`, query) + output, err := RunCommandAndReturnOutput(command) + if err != nil { + return "", fmt.Errorf("failed to query iterations: %v", err) + } + logger.Debugf("Iterations query response (previous): %s", string(output)) + + var response struct { + Data struct { + Node struct { + Fields struct { + Nodes []struct { + ID string `json:"id"` + Name string `json:"name"` + Configuration struct { + Iterations []struct { + ID string `json:"id"` + Title string `json:"title"` + StartDate string `json:"startDate"` + Duration int `json:"duration"` + } `json:"iterations"` + } `json:"configuration"` + } `json:"nodes"` + } `json:"fields"` + } `json:"node"` + } `json:"data"` + } + + if err := json.Unmarshal(output, &response); err != nil { + return "", fmt.Errorf("failed to parse iterations response: %v", err) + } + + var targetField *struct { + ID string `json:"id"` + Name string `json:"name"` + Configuration struct { + Iterations []struct { + ID string `json:"id"` + Title string `json:"title"` + StartDate string `json:"startDate"` + Duration int `json:"duration"` + } `json:"iterations"` + } `json:"configuration"` + } + for _, field := range response.Data.Node.Fields.Nodes { + if field.ID == fieldID { + targetField = &field + break + } + } + if targetField == nil { + return "", fmt.Errorf("iteration field with ID %s not found", fieldID) + } + + iters := targetField.Configuration.Iterations + if len(iters) >= 2 { + logger.Infof("Selected previous iteration: %s (ID: %s)", iters[1].Title, iters[1].ID) + return iters[1].ID, nil + } + return "", fmt.Errorf("no previous iteration available") +} diff --git a/tools/github-manage/pkg/ghapi/projects_test.go b/tools/github-manage/pkg/ghapi/projects_test.go index 3d8bcf8e2b..9d768a97b1 100644 --- a/tools/github-manage/pkg/ghapi/projects_test.go +++ b/tools/github-manage/pkg/ghapi/projects_test.go @@ -124,14 +124,16 @@ func TestParseJSONtoProjectItems(t *testing.T) { func TestAliases(t *testing.T) { expectedAliases := map[string]int{ - "mdm": 58, - "g-mdm": 58, - "draft": 67, - "drafting": 67, - "g-software": 70, - "soft": 70, - "g-orchestration": 71, - "orch": 71, + "mdm": 58, + "g-mdm": 58, + "draft": 67, + "drafting": 67, + "g-software": 70, + "soft": 70, + "g-orchestration": 71, + "orch": 71, + "sec": 97, + "g-security-compliance": 97, } if !reflect.DeepEqual(Aliases, expectedAliases) { diff --git a/tools/github-manage/pkg/ghapi/workflows.go b/tools/github-manage/pkg/ghapi/workflows.go index 7793d624e9..2c142b1167 100644 --- a/tools/github-manage/pkg/ghapi/workflows.go +++ b/tools/github-manage/pkg/ghapi/workflows.go @@ -1,6 +1,10 @@ package ghapi -import "fleetdm/gm/pkg/logger" +import ( + "strings" + + "fleetdm/gm/pkg/logger" +) // ActionType represents the type of action to be performed on an issue. type ActionType string @@ -127,6 +131,26 @@ func CreateBulkSetSprintAction(issues []Issue, projectID int) []Action { return actions } +// CreateBulkMoveToCurrentSprintIfNotReadyQA creates actions to set current sprint for issues whose +// status does NOT contain "ready" or "qa" (case-insensitive) in the given project. +func CreateBulkMoveToCurrentSprintIfNotReadyQA(issues []Issue, projectID int) []Action { + var filtered []Issue + for _, is := range issues { + s := strings.ToLower(strings.TrimSpace(is.Status)) + if s == "" { + // No status -> include (move to current sprint) + filtered = append(filtered, is) + continue + } + if strings.Contains(s, "ready") || strings.Contains(s, "qa") { + // Skip statuses with ready or qa + continue + } + filtered = append(filtered, is) + } + return CreateBulkSetSprintAction(filtered, projectID) +} + func CreateBulkSprintKickoffActions(issues []Issue, sourceProjectID, projectID int) []Action { logger.Infof("Creating sprint kickoff actions for %d issues (source project: %d, target project: %d)", len(issues), sourceProjectID, projectID) diff --git a/tools/github-manage/pkg/tui/hotkeys.go b/tools/github-manage/pkg/tui/hotkeys.go index f9f9577388..eca3b5dc0d 100644 --- a/tools/github-manage/pkg/tui/hotkeys.go +++ b/tools/github-manage/pkg/tui/hotkeys.go @@ -244,7 +244,7 @@ func (m *model) HandleHotkeys(msg tea.Msg) (tea.Model, tea.Cmd) { m.labelInput = "" case BulkDemoSummary: return m, m.executeWorkflow() - case BulkSprintKickoff, BulkKickOutOfSprint: + case BulkSprintKickoff, BulkKickOutOfSprint, BulkMoveToCurrentSprint: if m.projectID != 0 { // Use the provided project ID return m, m.executeWorkflow() diff --git a/tools/github-manage/pkg/tui/state_change.go b/tools/github-manage/pkg/tui/state_change.go index 72768897f3..5fac8a962f 100644 --- a/tools/github-manage/pkg/tui/state_change.go +++ b/tools/github-manage/pkg/tui/state_change.go @@ -35,6 +35,7 @@ func (m *model) HandleStateChange(msg tea.Msg) (tea.Model, tea.Cmd) { // Update viewport size when window is resized m.detailViewport.Width = msg.Width - 4 m.detailViewport.Height = msg.Height - 6 + m.termWidth = msg.Width case processTaskMsg: // This case is no longer used since we've moved to AsyncManager // Ignore these messages diff --git a/tools/github-manage/pkg/tui/ui.go b/tools/github-manage/pkg/tui/ui.go index 7b1f93c55f..a9c68b7eb2 100644 --- a/tools/github-manage/pkg/tui/ui.go +++ b/tools/github-manage/pkg/tui/ui.go @@ -50,6 +50,7 @@ const ( BulkMilestoneClose BulkKickOutOfSprint BulkDemoSummary + BulkMoveToCurrentSprint ) var WorkflowTypeValues = []string{ @@ -59,6 +60,7 @@ var WorkflowTypeValues = []string{ "Bulk Milestone Close", "Bulk Kick Out Of Sprint", "Bulk Demo Summary", + "Move to current sprint", } type TaskStatus int @@ -137,6 +139,8 @@ type model struct { detailViewport viewport.Model glamourRenderer *glamour.TermRenderer issueContent string + // Terminal sizing + termWidth int // Command-specific parameters commandType CommandType projectID int @@ -203,6 +207,8 @@ func RunTUI(commandType CommandType, projectID int, limit int, search string) { fmt.Println("Unsupported command type for TUI") return } + // Carry over the search string as a generic mode hint (e.g., for sprint: "previous") + mm.search = search p := tea.NewProgram(&mm) if _, err := p.Run(); err != nil { fmt.Printf("Error running Bubble Tea program: %v\n", err) @@ -343,6 +349,17 @@ func fetchSprintItems(projectID, limit int) tea.Cmd { } } +func fetchPreviousSprintItems(projectID, limit int) tea.Cmd { + return func() tea.Msg { + items, total, err := ghapi.GetPreviousSprintItemsWithTotal(projectID, limit) + if err != nil { + return err + } + issues := ghapi.ConvertItemsToIssues(items) + return issuesLoadedMsg{issues: issues, totalAvailable: total, rawFetched: limit} + } +} + // newview add command type / fetcher to switch func (m model) Init() tea.Cmd { var fetchCmd tea.Cmd @@ -354,7 +371,12 @@ func (m model) Init() tea.Cmd { case EstimatedCommand: fetchCmd = fetchEstimatedItems(m.projectID, m.limit) case SprintCommand: - fetchCmd = fetchSprintItems(m.projectID, m.limit) + // Use the search field as a mode hint; when "previous", fetch previous sprint instead of current + if strings.EqualFold(strings.TrimSpace(m.search), "previous") || strings.EqualFold(strings.TrimSpace(m.search), "prev") { + fetchCmd = fetchPreviousSprintItems(m.projectID, m.limit) + } else { + fetchCmd = fetchSprintItems(m.projectID, m.limit) + } default: fetchCmd = fetchIssues("") } @@ -735,6 +757,39 @@ func (m *model) executeWorkflow() tea.Cmd { Progress: 0.0, }) } + case BulkMoveToCurrentSprint: + projectID := m.projectID + if projectID == 0 && m.projectInput != "" { + resolvedID, err := ghapi.ResolveProjectID(m.projectInput) + if err != nil { + return func() tea.Msg { + return workflowCompleteMsg{ + success: false, + message: fmt.Sprintf("Failed to resolve project ID: %v", err), + } + } + } + projectID = resolvedID + } + if projectID == 0 { + return func() tea.Msg { + return workflowCompleteMsg{ + success: false, + message: "Project ID is required for this workflow", + } + } + } + actions = ghapi.CreateBulkMoveToCurrentSprintIfNotReadyQA(selectedIssues, projectID) + for i, issue := range selectedIssues { + // Only tasks for those that will be acted on; description indicates conditional nature + desc := fmt.Sprintf("If not 'ready' or 'qa', set current sprint for #%d in project %d", issue.Number, projectID) + m.tasks = append(m.tasks, WorkflowTask{ + ID: i, + Description: desc, + Status: TaskPending, + Progress: 0.0, + }) + } } // For all workflows, start async workflow diff --git a/tools/github-manage/pkg/tui/view.go b/tools/github-manage/pkg/tui/view.go index 6d79999d70..db8115a826 100644 --- a/tools/github-manage/pkg/tui/view.go +++ b/tools/github-manage/pkg/tui/view.go @@ -364,10 +364,124 @@ func (m model) RenderWorkflowSelection() string { } s += fmt.Sprintf("%s %s %s\n", cursor, selected, workflow) } - s += "\nPress 'enter' to select, 'esc' to cancel.\n" + // Detailed description for the currently-selected workflow + s += "\nDescription:\n" + width := m.termWidth + if width <= 0 { + width = 80 + } + // Add a small margin to avoid bumping screen edges + wrapWidth := width - 2 + if wrapWidth < 20 { + wrapWidth = 20 + } + s += wrapTextPreserveBullets(m.selectedWorkflowDescription(), wrapWidth) + s += "\n\nPress 'enter' to select, 'esc' to cancel.\n" return s } +// selectedWorkflowDescription returns a helpful description for the currently-selected workflow, +// including any required inputs and contextual hints. +func (m model) selectedWorkflowDescription() string { + w := WorkflowType(m.workflowCursor) + // Base descriptions per workflow + var desc string + switch w { + case BulkAddLabel: + desc = "Add a label to all selected issues. You'll be prompted to type the label name." + case BulkRemoveLabel: + desc = "Remove a label from all selected issues. You'll be prompted to type the label name." + case BulkSprintKickoff: + desc = "Add selected issues to a project and set initial sprint kickoff fields (status, estimate sync, labels)." + case BulkMilestoneClose: + desc = "Generate a release summary from selected issues (features/bugs) suitable for milestone close notes." + case BulkKickOutOfSprint: + desc = "Remove selected issues from a project and reset sprint-related fields (status, labels)." + case BulkDemoSummary: + desc = "Generate a markdown summary of selected issues grouped by feature and bug, with assignees." + case BulkMoveToCurrentSprint: + desc = "For each selected issue, if its Status does not contain 'ready' or 'qa' (case-insensitive), set its sprint to the project's current iteration." + default: + desc = "Select a workflow to see details." + } + + // Input requirements/hints + var hints []string + switch w { + case BulkAddLabel, BulkRemoveLabel: + hints = append(hints, "Input required: label name") + case BulkSprintKickoff, BulkKickOutOfSprint, BulkMoveToCurrentSprint: + hints = append(hints, "Input required: project ID or alias") + if m.projectID != 0 { + hints = append(hints, fmt.Sprintf("Current project in context: %d", m.projectID)) + } else if m.projectInput != "" { + hints = append(hints, fmt.Sprintf("Pending project input: %s", m.projectInput)) + } + } + + if len(hints) > 0 { + desc += "\n- " + strings.Join(hints, "\n- ") + } + return desc +} + +// wrapTextPreserveBullets wraps the input text to the given width, preserving +// existing newlines and adding continuation indentation for bullet points. +func wrapTextPreserveBullets(text string, width int) string { + if width <= 0 { + return text + } + lines := strings.Split(text, "\n") + var out []string + for _, line := range lines { + out = append(out, wrapLineWithPrefix(line, width)) + } + return strings.Join(out, "\n") +} + +// wrapLineWithPrefix wraps a single line. If it starts with "- ", it keeps +// the bullet for the first line and indents continuation lines with two spaces. +func wrapLineWithPrefix(line string, width int) string { + if len(line) <= width { + return line + } + bullet := "" + contIndent := "" + content := line + if strings.HasPrefix(line, "- ") { + bullet = "- " + contIndent = " " + content = strings.TrimPrefix(line, "- ") + } + words := strings.Fields(content) + if len(words) == 0 { + return line + } + var wrapped []string + // first line with bullet (if any) + current := bullet + spaceLeft := width - len(current) + for _, w := range words { + if len(w)+1 > spaceLeft { // +1 for space + // commit current line + wrapped = append(wrapped, strings.TrimRight(current, " ")) + // start new line with continuation indent + current = contIndent + w + " " + spaceLeft = width - len(contIndent) - len(w) - 1 + if spaceLeft < 0 { + spaceLeft = 0 + } + } else { + current += w + " " + spaceLeft -= len(w) + 1 + } + } + if strings.TrimSpace(current) != "" { + wrapped = append(wrapped, strings.TrimRight(current, " ")) + } + return strings.Join(wrapped, "\n") +} + func (m model) RenderLabelInput() string { workflowName := "Add Label" if m.workflowType == BulkRemoveLabel {