gkarr gm new workflows (#35712)

- **Adding new command gm milestone report to veiw all statuses from all
projects for all issues tied to a milestone**
- **Add descriptions to workflow select and new workflows**
- **Adding new commands**
This commit is contained in:
George Karr
2025-11-14 10:13:22 -06:00
committed by GitHub
parent 1c1e19197c
commit 0f2b83fcc0
13 changed files with 1651 additions and 25 deletions
+2
View File
@@ -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{
+397
View File
@@ -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 <milestone-name>",
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
}
+9 -1
View File
@@ -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, "")
},
}
+160
View File
@@ -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")
}
+285
View File
@@ -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
}
+355
View File
@@ -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
}
+235 -12
View File
@@ -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")
}
+10 -8
View File
@@ -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) {
+25 -1
View File
@@ -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)
+1 -1
View File
@@ -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()
@@ -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
+56 -1
View File
@@ -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
+115 -1
View File
@@ -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 {