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:
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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, "")
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user