mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
gkarr gm updates pre sprint (#37446)
This commit is contained in:
parent
b095b72bf1
commit
4003ac22de
16 changed files with 510 additions and 81 deletions
|
|
@ -80,6 +80,15 @@ chmod +x gm
|
|||
|
||||
# View estimated tickets
|
||||
./gm estimated mdm --limit 25
|
||||
|
||||
# Pre-sprint report for one or more teams
|
||||
# Single team (alias or project id)
|
||||
./gm pre-sprint report mdm
|
||||
# Multiple teams (comma-separated)
|
||||
./gm pre-sprint report mdm,soft --limit 1000
|
||||
|
||||
# CSV format for spreadsheet use (outputs values per team in provided order)
|
||||
./gm pre-sprint report mdm,soft --format csv
|
||||
```
|
||||
|
||||
## 🎮 Interactive Controls
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ func main() {
|
|||
rootCmd.AddCommand(sprintCmd)
|
||||
rootCmd.AddCommand(milestoneCmd)
|
||||
rootCmd.AddCommand(roadmapCmd)
|
||||
rootCmd.AddCommand(preSprintCmd)
|
||||
|
||||
// Test command to test SetCurrentSprint functionality
|
||||
rootCmd.AddCommand(&cobra.Command{
|
||||
|
|
|
|||
|
|
@ -3,11 +3,14 @@ package main
|
|||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"fleetdm/gm/pkg/ghapi"
|
||||
"fleetdm/gm/pkg/tui"
|
||||
"fleetdm/gm/pkg/util"
|
||||
)
|
||||
|
||||
// milestoneCmd is the parent command for milestone-related operations.
|
||||
|
|
@ -58,7 +61,7 @@ var milestoneReportCmd = &cobra.Command{
|
|||
for _, m := range miles {
|
||||
t := m.Title
|
||||
if milestoneStripEmojis {
|
||||
t = stripEmojis(t)
|
||||
t = util.StripEmojis(t)
|
||||
}
|
||||
fmt.Println(strings.Join([]string{t, m.State}, "\t"))
|
||||
}
|
||||
|
|
@ -69,7 +72,7 @@ var milestoneReportCmd = &cobra.Command{
|
|||
for _, m := range miles {
|
||||
t := m.Title
|
||||
if milestoneStripEmojis {
|
||||
t = stripEmojis(t)
|
||||
t = util.StripEmojis(t)
|
||||
}
|
||||
fmt.Printf("| %s | %s |\n", t, m.State)
|
||||
}
|
||||
|
|
@ -139,7 +142,7 @@ var milestoneReportCmd = &cobra.Command{
|
|||
filtered = append(filtered, p)
|
||||
continue
|
||||
}
|
||||
name := strings.ToLower(stripEmojis(title))
|
||||
name := strings.ToLower(util.StripEmojis(title))
|
||||
exclude := false
|
||||
for _, tok := range toks {
|
||||
if tok != "" && strings.Contains(name, tok) {
|
||||
|
|
@ -160,7 +163,7 @@ var milestoneReportCmd = &cobra.Command{
|
|||
if p.Title != "" {
|
||||
title := p.Title
|
||||
if milestoneStripEmojis {
|
||||
title = stripEmojis(title)
|
||||
title = util.StripEmojis(title)
|
||||
}
|
||||
headers = append(headers, title)
|
||||
} else {
|
||||
|
|
@ -218,7 +221,7 @@ var milestoneReportCmd = &cobra.Command{
|
|||
if strings.TrimSpace(ps.Status) == "" {
|
||||
cell := "No Status"
|
||||
if milestoneStripEmojis {
|
||||
cell = stripEmojis(cell)
|
||||
cell = util.StripEmojis(cell)
|
||||
}
|
||||
row = append(row, cell)
|
||||
if agg[pid] == nil {
|
||||
|
|
@ -228,7 +231,7 @@ var milestoneReportCmd = &cobra.Command{
|
|||
} else {
|
||||
cell := ps.Status
|
||||
if milestoneStripEmojis {
|
||||
cell = stripEmojis(cell)
|
||||
cell = util.StripEmojis(cell)
|
||||
}
|
||||
row = append(row, cell)
|
||||
if agg[pid] == nil {
|
||||
|
|
@ -240,9 +243,9 @@ var milestoneReportCmd = &cobra.Command{
|
|||
// Append truncated title column
|
||||
title := mi.Title
|
||||
if milestoneStripEmojis {
|
||||
title = stripEmojis(title)
|
||||
title = util.StripEmojis(title)
|
||||
}
|
||||
row = append(row, truncateTitle(title, 25))
|
||||
row = append(row, util.TruncateTitle(title, 25))
|
||||
if format == "tsv" {
|
||||
fmt.Println(strings.Join(row, "\t"))
|
||||
} else {
|
||||
|
|
@ -281,13 +284,13 @@ var milestoneReportCmd = &cobra.Command{
|
|||
}
|
||||
sort.Slice(rows, func(i, j int) bool {
|
||||
if key == "name" {
|
||||
pi := plainForSort(rows[i].Project)
|
||||
pj := plainForSort(rows[j].Project)
|
||||
pi := util.PlainForSort(rows[i].Project)
|
||||
pj := util.PlainForSort(rows[j].Project)
|
||||
if pi != pj {
|
||||
return pi < pj
|
||||
}
|
||||
si := plainForSort(rows[i].Status)
|
||||
sj := plainForSort(rows[j].Status)
|
||||
si := util.PlainForSort(rows[i].Status)
|
||||
sj := util.PlainForSort(rows[j].Status)
|
||||
if si != sj {
|
||||
return si < sj
|
||||
}
|
||||
|
|
@ -299,13 +302,13 @@ var milestoneReportCmd = &cobra.Command{
|
|||
if rows[i].Count != rows[j].Count {
|
||||
return rows[i].Count < rows[j].Count
|
||||
}
|
||||
pi := plainForSort(rows[i].Project)
|
||||
pj := plainForSort(rows[j].Project)
|
||||
pi := util.PlainForSort(rows[i].Project)
|
||||
pj := util.PlainForSort(rows[j].Project)
|
||||
if pi != pj {
|
||||
return pi < pj
|
||||
}
|
||||
si := plainForSort(rows[i].Status)
|
||||
sj := plainForSort(rows[j].Status)
|
||||
si := util.PlainForSort(rows[i].Status)
|
||||
sj := util.PlainForSort(rows[j].Status)
|
||||
if si != sj {
|
||||
return si < sj
|
||||
}
|
||||
|
|
@ -320,8 +323,8 @@ var milestoneReportCmd = &cobra.Command{
|
|||
proj := r.Project
|
||||
stat := r.Status
|
||||
if milestoneStripEmojis {
|
||||
proj = stripEmojis(proj)
|
||||
stat = stripEmojis(stat)
|
||||
proj = util.StripEmojis(proj)
|
||||
stat = util.StripEmojis(stat)
|
||||
}
|
||||
fmt.Println(strings.Join([]string{proj, stat, fmt.Sprintf("%d", r.Count)}, "\t"))
|
||||
}
|
||||
|
|
@ -334,8 +337,8 @@ var milestoneReportCmd = &cobra.Command{
|
|||
proj := r.Project
|
||||
stat := r.Status
|
||||
if milestoneStripEmojis {
|
||||
proj = stripEmojis(proj)
|
||||
stat = stripEmojis(stat)
|
||||
proj = util.StripEmojis(proj)
|
||||
stat = util.StripEmojis(stat)
|
||||
}
|
||||
fmt.Printf("| %s | %s | %d |\n", proj, stat, r.Count)
|
||||
}
|
||||
|
|
@ -345,8 +348,49 @@ var milestoneReportCmd = &cobra.Command{
|
|||
},
|
||||
}
|
||||
|
||||
// milestoneViewCmd shows the interactive issues UI populated by issues tied to a given milestone
|
||||
var milestoneViewCmd = &cobra.Command{
|
||||
Use: "view <milestone-id-or-title>",
|
||||
Short: "Interactive view of issues in a milestone",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
ident := strings.TrimSpace(args[0])
|
||||
if ident == "" {
|
||||
return fmt.Errorf("milestone identifier is required")
|
||||
}
|
||||
|
||||
// Resolve to milestone title: accept numeric id or title directly
|
||||
title := ident
|
||||
if n, err := strconv.Atoi(ident); err == nil {
|
||||
// Look up milestone by number across open+closed
|
||||
miles, lerr := ghapi.ListRepoMilestones(true)
|
||||
if lerr != nil {
|
||||
return fmt.Errorf("failed to list milestones: %v", lerr)
|
||||
}
|
||||
found := ""
|
||||
for _, m := range miles {
|
||||
if m.Number == n {
|
||||
found = m.Title
|
||||
break
|
||||
}
|
||||
}
|
||||
if found == "" {
|
||||
return fmt.Errorf("milestone #%d not found", n)
|
||||
}
|
||||
title = found
|
||||
}
|
||||
|
||||
// Run the TUI with milestone mode so we can enforce limit and warn when overflow
|
||||
lim, _ := cmd.Flags().GetInt("limit")
|
||||
tui.RunTUI(tui.MilestoneCommand, 0, lim, title)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
milestoneCmd.AddCommand(milestoneReportCmd)
|
||||
milestoneCmd.AddCommand(milestoneViewCmd)
|
||||
milestoneViewCmd.Flags().Int("limit", 300, "Maximum number of issues to fetch for the milestone (shows warning if more exist)")
|
||||
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")
|
||||
|
|
@ -354,44 +398,3 @@ func init() {
|
|||
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
|
||||
}
|
||||
|
|
|
|||
154
tools/github-manage/cmd/gm/presprint.go
Normal file
154
tools/github-manage/cmd/gm/presprint.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"fleetdm/gm/pkg/ghapi"
|
||||
"fleetdm/gm/pkg/util"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// preSprintCmd is the parent command for pre-sprint utilities
|
||||
var preSprintCmd = &cobra.Command{
|
||||
Use: "pre-sprint",
|
||||
Short: "Pre-sprint utilities",
|
||||
}
|
||||
|
||||
var (
|
||||
preSprintLimit int
|
||||
preSprintFormat string
|
||||
)
|
||||
|
||||
// preSprintReportCmd implements: gm pre-sprint report <project-id or alias[,alias...]>
|
||||
var preSprintReportCmd = &cobra.Command{
|
||||
Use: "report [project-id-or-alias]",
|
||||
Short: "Generate pre-sprint report for one or more teams",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
identList := strings.Split(args[0], ",")
|
||||
teamIDs := make([]int, 0, len(identList))
|
||||
teamLabels := make([]string, 0, len(identList))
|
||||
teamNames := make([]string, 0, len(identList))
|
||||
|
||||
for _, ident := range identList {
|
||||
ident = strings.TrimSpace(ident)
|
||||
if ident == "" {
|
||||
continue
|
||||
}
|
||||
pid, err := ghapi.ResolveProjectID(ident)
|
||||
if err != nil {
|
||||
fmt.Printf("Skipping '%s': %v\n", ident, err)
|
||||
continue
|
||||
}
|
||||
label, ok := ghapi.ProjectLabels[pid]
|
||||
if !ok {
|
||||
fmt.Printf("Skipping '%s' (project %d): no drafting label mapping. Supported projects: mdm, soft, orch, sec.\n", ident, pid)
|
||||
continue
|
||||
}
|
||||
teamIDs = append(teamIDs, pid)
|
||||
teamLabels = append(teamLabels, label)
|
||||
teamNames = append(teamNames, ident)
|
||||
}
|
||||
|
||||
if len(teamIDs) == 0 {
|
||||
return fmt.Errorf("no valid teams provided")
|
||||
}
|
||||
|
||||
// Resolve status option names from the drafting project once
|
||||
draftingProjectID := ghapi.Aliases["draft"]
|
||||
readyName, err := ghapi.FindFieldValueByName(draftingProjectID, "Status", "ready to estimate")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve 'ready to estimate' status in drafting project: %v", err)
|
||||
}
|
||||
estimatedName, err := ghapi.FindFieldValueByName(draftingProjectID, "Status", "estimated")
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to resolve 'estimated' status in drafting project: %v", err)
|
||||
}
|
||||
|
||||
stop := util.StartSpinner("Fetching drafting items")
|
||||
items, total, err := ghapi.GetProjectItemsWithTotal(draftingProjectID, preSprintLimit)
|
||||
stop()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get drafting project items: %v", err)
|
||||
}
|
||||
if total > preSprintLimit {
|
||||
fmt.Printf("Warning: Drafting project has %d items, but only fetched %d. Some items may be omitted.\n", total, preSprintLimit)
|
||||
}
|
||||
|
||||
// If CSV format, print header once
|
||||
if strings.EqualFold(preSprintFormat, "csv") {
|
||||
fmt.Println("\nunestimated,total sp,priority sp,customer sp,priority-customer overlap")
|
||||
}
|
||||
|
||||
// For each team, compute metrics from the same drafting items
|
||||
for idx := range teamIDs {
|
||||
teamLabel := strings.ToLower(strings.TrimSpace(teamLabels[idx]))
|
||||
teamName := teamNames[idx]
|
||||
|
||||
// Accumulators
|
||||
unestimatedBugs := 0
|
||||
estBugPoints := 0
|
||||
priorityBugPoints := 0
|
||||
customerBugPoints := 0
|
||||
overlapPoints := 0
|
||||
|
||||
for _, it := range items {
|
||||
// status filter
|
||||
if !util.StatusMatches(it.Status, readyName, estimatedName) {
|
||||
continue
|
||||
}
|
||||
// team label filter
|
||||
if !util.HasLabel(it.Labels, teamLabel) {
|
||||
continue
|
||||
}
|
||||
// bug filter
|
||||
isBug := util.HasLabel(it.Labels, "bug")
|
||||
if !isBug {
|
||||
continue
|
||||
}
|
||||
|
||||
est := it.Estimate
|
||||
if est == 0 {
|
||||
unestimatedBugs++
|
||||
}
|
||||
estBugPoints += est
|
||||
|
||||
isPriority := util.HasAnyLabel(it.Labels, "P0", "P1", "P2")
|
||||
isCustomer := util.HasLabelPrefix(it.Labels, "customer-")
|
||||
if isPriority {
|
||||
priorityBugPoints += est
|
||||
}
|
||||
if isCustomer {
|
||||
customerBugPoints += est
|
||||
}
|
||||
if isPriority && isCustomer {
|
||||
overlapPoints += est
|
||||
}
|
||||
}
|
||||
|
||||
if strings.EqualFold(preSprintFormat, "csv") {
|
||||
fmt.Printf("%d,%d,%d,%d,%d\n", unestimatedBugs, estBugPoints, priorityBugPoints, customerBugPoints, overlapPoints)
|
||||
} else {
|
||||
// Default 'out' format
|
||||
fmt.Printf("\nPre-sprint report for %s\n", teamName)
|
||||
fmt.Printf("Total number of unestimated bugs: %d\n", unestimatedBugs)
|
||||
fmt.Printf("Total story points of estimated bugs: %d\n", estBugPoints)
|
||||
fmt.Printf("Total story points of priority bugs: %d\n", priorityBugPoints)
|
||||
fmt.Printf("Total story points of customer reported bugs: %d\n", customerBugPoints)
|
||||
fmt.Printf("Total overlap of priority and customer bugs: %d\n", overlapPoints)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
func init() {
|
||||
preSprintCmd.AddCommand(preSprintReportCmd)
|
||||
preSprintReportCmd.Flags().IntVarP(&preSprintLimit, "limit", "l", 1000, "Maximum number of items to fetch from drafting project")
|
||||
preSprintReportCmd.Flags().StringVar(&preSprintFormat, "format", "out", "Output format: out (default) or csv")
|
||||
}
|
||||
|
||||
// Helpers moved to pkg/util
|
||||
|
|
@ -31,9 +31,9 @@ var projectCmd = &cobra.Command{
|
|||
}
|
||||
|
||||
func init() {
|
||||
projectCmd.Flags().IntP("limit", "l", 100, "Maximum number of items to fetch")
|
||||
projectCmd.Flags().IntP("limit", "l", 300, "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().IntP("limit", "l", 300, "Maximum number of items to fetch")
|
||||
sprintCmd.Flags().BoolP("previous", "p", false, "Show previous sprint instead of current")
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -227,12 +227,14 @@ func getIssueEstimatesAcrossProjects(issueNumber int) (map[int]int, error) {
|
|||
Issue struct {
|
||||
ProjectItems struct {
|
||||
Nodes []struct {
|
||||
Project struct{ Number int `json:"number"` } `json:"project"`
|
||||
FieldValues struct{
|
||||
Nodes []struct{
|
||||
Typename string `json:"__typename"`
|
||||
Project struct {
|
||||
Number int `json:"number"`
|
||||
} `json:"project"`
|
||||
FieldValues struct {
|
||||
Nodes []struct {
|
||||
Typename string `json:"__typename"`
|
||||
Number *float64 `json:"number,omitempty"`
|
||||
Field struct{
|
||||
Field struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"field"`
|
||||
} `json:"nodes"`
|
||||
|
|
|
|||
|
|
@ -37,6 +37,32 @@ func GetIssues(search string) ([]Issue, error) {
|
|||
return issues, nil
|
||||
}
|
||||
|
||||
// GetIssuesByMilestoneLimited returns up to 'limit' issues for the given milestone title.
|
||||
// It fetches limit+1 to detect whether there are more results than the limit; the returned
|
||||
// slice is trimmed to 'limit', and the boolean indicates if more were available.
|
||||
func GetIssuesByMilestoneLimited(title string, limit int) ([]Issue, bool, error) {
|
||||
if limit <= 0 {
|
||||
limit = 300
|
||||
}
|
||||
// Request one extra to detect overflow
|
||||
reqLimit := limit + 1
|
||||
cmd := fmt.Sprintf("gh issue list --milestone %q --json number,title,author,createdAt,updatedAt,state,labels,body --limit %d", title, reqLimit)
|
||||
out, err := RunCommandAndReturnOutput(cmd)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
issues, err := ParseJSONtoIssues(out)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
exceeded := false
|
||||
if len(issues) > limit {
|
||||
exceeded = true
|
||||
issues = issues[:limit]
|
||||
}
|
||||
return issues, exceeded, nil
|
||||
}
|
||||
|
||||
// AddLabelToIssue adds a label to an issue.
|
||||
func AddLabelToIssue(issueNumber int, label string) error {
|
||||
command := fmt.Sprintf("gh issue edit %d --add-label %s", issueNumber, label)
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import (
|
|||
"time"
|
||||
|
||||
"fleetdm/gm/pkg/logger"
|
||||
"fleetdm/gm/pkg/util"
|
||||
)
|
||||
|
||||
// GetIssuesByMilestone returns issue numbers for a given milestone name. Limit controls max items.
|
||||
|
|
@ -299,6 +300,36 @@ func looksLikeRateLimit(s string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// GetMilestoneOpenIssueCount returns the count of OPEN issues for the given milestone title in the current repo.
|
||||
// This uses GitHub GraphQL search to retrieve the issueCount efficiently without listing all issues.
|
||||
func GetMilestoneOpenIssueCount(title string) (int, error) {
|
||||
owner, repo, err := getRepoOwnerAndName()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
// Build a search query limited to the current repository, milestone title, and open issues.
|
||||
q := fmt.Sprintf("repo:%s/%s is:issue is:open milestone:\"%s\"", owner, repo, title)
|
||||
gql := `query($q:String!){ search(type: ISSUE, query: $q, first: 1){ issueCount } }`
|
||||
cmd := fmt.Sprintf("gh api graphql -f query='%s' -f q='%s'", gql, util.EscapeSingleQuotes(q))
|
||||
out, err := runCommandWithRetry(cmd, 5, 2*time.Second)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Search struct {
|
||||
IssueCount int `json:"issueCount"`
|
||||
} `json:"search"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out, &resp); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return resp.Data.Search.IssueCount, nil
|
||||
}
|
||||
|
||||
// escape moved to util.EscapeSingleQuotes
|
||||
|
||||
// RepoMilestone represents a repository milestone (from REST API).
|
||||
type RepoMilestone struct {
|
||||
Number int `json:"number"`
|
||||
|
|
|
|||
27
tools/github-manage/pkg/messages/catalog.go
Normal file
27
tools/github-manage/pkg/messages/catalog.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package messages
|
||||
|
||||
import "fmt"
|
||||
|
||||
// LoadingMessage returns a human-friendly loading message based on a simple key.
|
||||
// Keys: "issues", "project", "estimated", "sprint", "milestone".
|
||||
func LoadingMessage(key string, projectID int, hint string) string {
|
||||
switch key {
|
||||
case "issues":
|
||||
return "Fetching Issues..."
|
||||
case "project":
|
||||
return fmt.Sprintf("Fetching Project Items (ID: %d)...", projectID)
|
||||
case "estimated":
|
||||
return fmt.Sprintf("Fetching Estimated Tickets (Project: %d)...", projectID)
|
||||
case "sprint":
|
||||
return fmt.Sprintf("Fetching Sprint Items (Project: %d)...", projectID)
|
||||
case "milestone":
|
||||
return fmt.Sprintf("Fetching Milestone Issues (%s)...", hint)
|
||||
default:
|
||||
return "Fetching..."
|
||||
}
|
||||
}
|
||||
|
||||
// LimitExceeded returns the common banner message about items not shown due to limit.
|
||||
func LimitExceeded(missing, limit, total int) string {
|
||||
return fmt.Sprintf("⚠ %d items not shown (limit=%d, total=%d). Increase --limit to include all issues.", missing, limit, total)
|
||||
}
|
||||
|
|
@ -214,7 +214,7 @@ func (m *model) HandleHotkeys(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.cursor = 0
|
||||
m.adjustViewForCursor()
|
||||
}
|
||||
case "q", "ctrl+c":
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
default:
|
||||
// Add character to filter
|
||||
|
|
@ -227,6 +227,8 @@ func (m *model) HandleHotkeys(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
case WorkflowSelection:
|
||||
switch msg.String() {
|
||||
case "q", "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "j", "down":
|
||||
if m.workflowCursor < len(WorkflowTypeValues)-1 {
|
||||
m.workflowCursor++
|
||||
|
|
@ -261,6 +263,8 @@ func (m *model) HandleHotkeys(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
case LabelInput:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
if m.labelInput != "" {
|
||||
return m, m.executeWorkflow()
|
||||
|
|
@ -280,6 +284,8 @@ func (m *model) HandleHotkeys(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
case ProjectInput:
|
||||
switch msg.String() {
|
||||
case "ctrl+c":
|
||||
return m, tea.Quit
|
||||
case "enter":
|
||||
if m.projectInput != "" {
|
||||
return m, m.executeWorkflow()
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"fleetdm/gm/pkg/ghapi"
|
||||
"fleetdm/gm/pkg/logger"
|
||||
|
||||
"github.com/charmbracelet/bubbles/progress"
|
||||
"github.com/charmbracelet/bubbles/spinner"
|
||||
|
|
@ -22,6 +23,7 @@ const (
|
|||
ProjectCommand
|
||||
EstimatedCommand
|
||||
SprintCommand
|
||||
MilestoneCommand
|
||||
)
|
||||
|
||||
type WorkflowState int
|
||||
|
|
@ -202,6 +204,8 @@ func RunTUI(commandType CommandType, projectID int, limit int, search string) {
|
|||
mm = initializeModelForSprint(projectID, limit)
|
||||
case IssuesCommand:
|
||||
mm = initializeModelForIssues(search)
|
||||
case MilestoneCommand:
|
||||
mm = initializeModelForMilestone(search, limit)
|
||||
default:
|
||||
// error and exit
|
||||
fmt.Println("Unsupported command type for TUI")
|
||||
|
|
@ -304,6 +308,14 @@ func initializeModelForSprint(projectID, limit int) model {
|
|||
return m
|
||||
}
|
||||
|
||||
func initializeModelForMilestone(title string, limit int) model {
|
||||
m := initializeModel()
|
||||
m.commandType = MilestoneCommand
|
||||
m.limit = limit
|
||||
m.search = title // reuse search to carry milestone title
|
||||
return m
|
||||
}
|
||||
|
||||
// newview Add a corresponding issue fetcher
|
||||
func fetchIssues(search string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
|
|
@ -360,6 +372,27 @@ func fetchPreviousSprintItems(projectID, limit int) tea.Cmd {
|
|||
}
|
||||
}
|
||||
|
||||
func fetchMilestoneItems(title string, limit int) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
// Get open-issue count for this milestone to drive warning banner
|
||||
totalCount, cntErr := ghapi.GetMilestoneOpenIssueCount(title)
|
||||
// Fetch up to 'limit' open issues for the milestone
|
||||
issues, exceeded, err := ghapi.GetIssuesByMilestoneLimited(title, limit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Prefer accurate total from GraphQL; fallback to limit+1 sentinel when cntErr occurs
|
||||
total := totalCount
|
||||
if cntErr != nil {
|
||||
total = len(issues)
|
||||
if exceeded {
|
||||
total = limit + 1
|
||||
}
|
||||
}
|
||||
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
|
||||
|
|
@ -377,6 +410,8 @@ func (m model) Init() tea.Cmd {
|
|||
} else {
|
||||
fetchCmd = fetchSprintItems(m.projectID, m.limit)
|
||||
}
|
||||
case MilestoneCommand:
|
||||
fetchCmd = fetchMilestoneItems(m.search, m.limit)
|
||||
default:
|
||||
fetchCmd = fetchIssues("")
|
||||
}
|
||||
|
|
@ -504,8 +539,12 @@ func (m *model) executeWorkflow() tea.Cmd {
|
|||
assigneeOrUnassigned := func(issue ghapi.Issue) string {
|
||||
if len(issue.Assignees) > 0 {
|
||||
user, err := ghapi.GetUserName(issue.Assignees[0].Login)
|
||||
if err == nil && user.Name != "" {
|
||||
return user.Name
|
||||
if err == nil {
|
||||
if user.Name != "" {
|
||||
return user.Name
|
||||
} else {
|
||||
return user.Login
|
||||
}
|
||||
}
|
||||
}
|
||||
return "unassigned"
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"fleetdm/gm/pkg/ghapi"
|
||||
"fleetdm/gm/pkg/messages"
|
||||
)
|
||||
|
||||
func (m *model) generateIssueContent(issue ghapi.Issue) string {
|
||||
|
|
@ -75,18 +76,21 @@ func (m *model) generateIssueContent(issue ghapi.Issue) string {
|
|||
}
|
||||
|
||||
func (m model) RenderLoading() string {
|
||||
var loadingMessage string
|
||||
// Map command type to key for messages catalog
|
||||
key := "issues"
|
||||
switch m.commandType {
|
||||
// newview add if non default message is preferred when loading
|
||||
case IssuesCommand:
|
||||
loadingMessage = "Fetching Issues..."
|
||||
case ProjectCommand:
|
||||
loadingMessage = fmt.Sprintf("Fetching Project Items (ID: %d)...", m.projectID)
|
||||
key = "project"
|
||||
case EstimatedCommand:
|
||||
loadingMessage = fmt.Sprintf("Fetching Estimated Tickets (Project: %d)...", m.projectID)
|
||||
default:
|
||||
loadingMessage = "Fetching Issues..."
|
||||
key = "estimated"
|
||||
case SprintCommand:
|
||||
key = "sprint"
|
||||
case MilestoneCommand:
|
||||
key = "milestone"
|
||||
case IssuesCommand:
|
||||
key = "issues"
|
||||
}
|
||||
loadingMessage := messages.LoadingMessage(key, m.projectID, m.search)
|
||||
return fmt.Sprintf("\n%s %s\n\n", m.spinner.View(), loadingMessage)
|
||||
}
|
||||
|
||||
|
|
@ -252,7 +256,7 @@ func (m model) RenderIssueTable() string {
|
|||
warningBanner := ""
|
||||
if m.totalAvailable > 0 && m.totalAvailable > m.rawFetchedCount {
|
||||
missing := m.totalAvailable - m.rawFetchedCount
|
||||
warningBanner = errorStyle.Render(fmt.Sprintf("⚠ %d items not shown (limit=%d, total=%d). Increase --limit to include all issues.", missing, m.rawFetchedCount, m.totalAvailable)) + "\n\n"
|
||||
warningBanner = errorStyle.Render(messages.LimitExceeded(missing, m.rawFetchedCount, m.totalAvailable)) + "\n\n"
|
||||
}
|
||||
|
||||
s = warningBanner + headerText + fmt.Sprintf(" %-2d/%-2d Number Estimate Type Labels Title\n",
|
||||
|
|
@ -394,7 +398,7 @@ func (m model) selectedWorkflowDescription() string {
|
|||
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."
|
||||
desc = "Add to drafting and set to confirm and celebrate, removes :release label and adds :product label for selected issues (features/bugs). Does not filter by label, should only be used on stories, does not affect the milestone in any way."
|
||||
case BulkKickOutOfSprint:
|
||||
desc = "Remove selected issues from a project and reset sprint-related fields (status, labels)."
|
||||
case BulkDemoSummary:
|
||||
|
|
|
|||
36
tools/github-manage/pkg/util/labels.go
Normal file
36
tools/github-manage/pkg/util/labels.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
package util
|
||||
|
||||
import "strings"
|
||||
|
||||
// HasLabel returns true if labels contains a label equal to want (case-insensitive, trimmed).
|
||||
func HasLabel(labels []string, want string) bool {
|
||||
lw := strings.ToLower(strings.TrimSpace(want))
|
||||
for _, l := range labels {
|
||||
if strings.ToLower(strings.TrimSpace(l)) == lw {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasAnyLabel returns true if labels contains any of the wants.
|
||||
func HasAnyLabel(labels []string, wants ...string) bool {
|
||||
for _, w := range wants {
|
||||
if HasLabel(labels, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// HasLabelPrefix returns true if any label starts with the given prefix (case-insensitive).
|
||||
func HasLabelPrefix(labels []string, prefix string) bool {
|
||||
lp := strings.ToLower(strings.TrimSpace(prefix))
|
||||
for _, l := range labels {
|
||||
ll := strings.ToLower(strings.TrimSpace(l))
|
||||
if strings.HasPrefix(ll, lp) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
27
tools/github-manage/pkg/util/spinner.go
Normal file
27
tools/github-manage/pkg/util/spinner.go
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// StartSpinner prints an inline spinner until the returned stop function is called.
|
||||
func StartSpinner(msg string) func() {
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
chars := []rune{'|', '/', '-', '\\'}
|
||||
i := 0
|
||||
for {
|
||||
select {
|
||||
case <-done:
|
||||
fmt.Printf("\r%s... done.\n", msg)
|
||||
return
|
||||
default:
|
||||
fmt.Printf("\r%s %c", msg, chars[i%len(chars)])
|
||||
i++
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
return func() { close(done) }
|
||||
}
|
||||
14
tools/github-manage/pkg/util/status.go
Normal file
14
tools/github-manage/pkg/util/status.go
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
package util
|
||||
|
||||
import "strings"
|
||||
|
||||
// StatusMatches compares status to any of the provided names (case-insensitive, trimmed).
|
||||
func StatusMatches(status string, names ...string) bool {
|
||||
ls := strings.ToLower(strings.TrimSpace(status))
|
||||
for _, n := range names {
|
||||
if ls == strings.ToLower(strings.TrimSpace(n)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
50
tools/github-manage/pkg/util/strings.go
Normal file
50
tools/github-manage/pkg/util/strings.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// StripEmojis removes emoji and pictographic characters and certain joiners/variation selectors
|
||||
// to leave readable text.
|
||||
func StripEmojis(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
if r == 0xFE0F || r == 0x200D || r == 0x200C || r == 0x200B {
|
||||
continue
|
||||
}
|
||||
if (r >= 0x1F300 && r <= 0x1FAFF) || (r >= 0x2600 && r <= 0x27BF) {
|
||||
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 runes and appends "..." if longer.
|
||||
func TruncateTitle(s string, maxRunes int) string {
|
||||
if maxRunes <= 0 {
|
||||
return ""
|
||||
}
|
||||
count := 0
|
||||
for idx := range s {
|
||||
if count == maxRunes {
|
||||
return s[:idx] + "..."
|
||||
}
|
||||
count++
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// EscapeSingleQuotes escapes single quotes for safe inclusion inside single-quoted shell strings.
|
||||
func EscapeSingleQuotes(s string) string {
|
||||
return strings.ReplaceAll(s, "'", "'\\''")
|
||||
}
|
||||
|
||||
// SafeRuneLen returns runes count of a string.
|
||||
func SafeRuneLen(s string) int { return utf8.RuneCountInString(s) }
|
||||
Loading…
Reference in a new issue