feat(milestone): 增加里程碑进度分析快捷命令
This commit is contained in:
parent
0a5bad01c8
commit
72eea1bc77
24
README.md
24
README.md
|
|
@ -97,6 +97,7 @@ The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans
|
|||
|----------|-------------|
|
||||
| 📦 Repo | List, create, fork, delete repositories, view repo info, insights, and interactions |
|
||||
| 🐛 Issue | Create, update, close, batch close, comment on issues |
|
||||
| 🎯 Milestone | List, create, inspect, summarize, update, close, and reopen milestones |
|
||||
| 🔖 Label | Create, list, update, delete issue labels |
|
||||
| 🔀 PR | Create, merge, review pull requests, view changed files |
|
||||
| 👥 Member | List, add, remove repository members, change roles, create and accept invite links |
|
||||
|
|
@ -344,6 +345,29 @@ issue detail endpoints provide those fields.
|
|||
tracker, version, assignee, tag, and schedule metadata before sending updates,
|
||||
which avoids clearing required fields on the server by accident.
|
||||
|
||||
### Milestone Management
|
||||
|
||||
```bash
|
||||
# List milestones
|
||||
gitlink-cli milestone +list --owner Gitlink --repo forgeplus
|
||||
|
||||
# View a milestone and linked issues
|
||||
gitlink-cli milestone +view --owner Gitlink --repo forgeplus --id 2438 --limit 20
|
||||
|
||||
# Build a milestone progress report by ID
|
||||
gitlink-cli milestone +report --owner Gitlink --repo forgeplus --id 2438 --sample-limit 3
|
||||
|
||||
# Build a milestone progress report by name
|
||||
gitlink-cli milestone +report --owner Gitlink --repo forgeplus --name "v1.0"
|
||||
|
||||
# Create a milestone
|
||||
gitlink-cli milestone +create --owner Gitlink --repo forgeplus --name "v1.0" --description "Scope for v1.0" --due-date 2026-07-01
|
||||
```
|
||||
|
||||
`milestone +report` resolves a milestone by `--id` or `--name`, fetches all
|
||||
linked issues across pages, and summarizes close readiness, assignee/tag gaps,
|
||||
status distribution, and sample open issues.
|
||||
|
||||
### Label Management
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
# milestone +report shortcut
|
||||
|
||||
## Overview
|
||||
|
||||
This change adds a new `gitlink-cli milestone +report` shortcut for repository
|
||||
maintainers and AI agents who need a quick milestone health summary before a
|
||||
release or iteration close-out.
|
||||
|
||||
The command accepts either `--id` or `--name`, resolves the target milestone,
|
||||
collects all linked issues across pages, and outputs a structured report with:
|
||||
|
||||
- milestone metadata and completion percentage
|
||||
- total/open/closed issue counts
|
||||
- close-readiness blockers and warnings
|
||||
- open issue breakdown by status, priority, assignee, and tag
|
||||
- sample open issues for recent activity, unassigned work, and commented threads
|
||||
|
||||
## Why it matters
|
||||
|
||||
The existing milestone shortcuts cover CRUD and status changes, but they do not
|
||||
help a maintainer answer practical questions such as:
|
||||
|
||||
- Is this milestone ready to close?
|
||||
- How many open issues are still unassigned?
|
||||
- Which priorities or tags dominate the remaining work?
|
||||
- Which open issues should I inspect first?
|
||||
|
||||
`milestone +report` turns those checks into one command and keeps the output
|
||||
machine-friendly for scripts and AI agents.
|
||||
|
||||
## Pagination safeguard
|
||||
|
||||
The report implementation now fetches milestone issues until the API-reported
|
||||
total is fully collected, instead of assuming the server always honors the
|
||||
requested `limit`.
|
||||
|
||||
This avoids undercounting when the service caps each response page below the
|
||||
requested size. For filtered milestone issue views, the implementation uses the
|
||||
matching filtered totals (`opened_issues_count` / `closed_issues_count`) so it
|
||||
does not over-fetch extra pages.
|
||||
|
||||
## Example commands
|
||||
|
||||
```bash
|
||||
gitlink-cli milestone +report --owner Gitlink --repo forgeplus --id 2438 --sample-limit 3
|
||||
gitlink-cli milestone +report --owner Gitlink --repo forgeplus --name "v1.0"
|
||||
```
|
||||
|
||||
## Files changed
|
||||
|
||||
- `shortcuts/milestone/milestone.go`
|
||||
- `shortcuts/milestone/report.go`
|
||||
- `shortcuts/milestone/report_test.go`
|
||||
- `README.md`
|
||||
|
||||
## Validation
|
||||
|
||||
```bash
|
||||
go test ./shortcuts/milestone/...
|
||||
go test ./shortcuts/...
|
||||
go test ./...
|
||||
go build ./...
|
||||
go run . milestone +report --owner Gitlink --repo forgeplus --id 2438 --sample-limit 3 --format json
|
||||
```
|
||||
|
|
@ -102,6 +102,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
return ctx.Output(env)
|
||||
},
|
||||
},
|
||||
newMilestoneReportShortcut(),
|
||||
{
|
||||
Name: "update",
|
||||
Description: "Update a milestone",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,713 @@
|
|||
package milestone
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const milestoneReportPageSize = 100
|
||||
|
||||
var milestoneNow = time.Now
|
||||
|
||||
type milestoneReport struct {
|
||||
Repository string `json:"repository" yaml:"repository"`
|
||||
Milestone milestoneReportMetadata `json:"milestone" yaml:"milestone"`
|
||||
Summary milestoneReportSummary `json:"summary" yaml:"summary"`
|
||||
Readiness milestoneReportReadiness `json:"readiness" yaml:"readiness"`
|
||||
Breakdown milestoneReportBreakdown `json:"breakdown" yaml:"breakdown"`
|
||||
Samples milestoneReportSamples `json:"samples" yaml:"samples"`
|
||||
}
|
||||
|
||||
type milestoneReportMetadata struct {
|
||||
ID int `json:"id" yaml:"id"`
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Description string `json:"description,omitempty" yaml:"description,omitempty"`
|
||||
Status string `json:"status" yaml:"status"`
|
||||
DueDate string `json:"due_date,omitempty" yaml:"due_date,omitempty"`
|
||||
CreatedAt string `json:"created_at,omitempty" yaml:"created_at,omitempty"`
|
||||
UpdatedAt string `json:"updated_at,omitempty" yaml:"updated_at,omitempty"`
|
||||
CompletionPct float64 `json:"completion_percent" yaml:"completion_percent"`
|
||||
}
|
||||
|
||||
type milestoneReportSummary struct {
|
||||
TotalIssues int `json:"total_issues" yaml:"total_issues"`
|
||||
OpenIssues int `json:"open_issues" yaml:"open_issues"`
|
||||
ClosedIssues int `json:"closed_issues" yaml:"closed_issues"`
|
||||
UnassignedOpenIssues int `json:"unassigned_open_issues" yaml:"unassigned_open_issues"`
|
||||
UntaggedOpenIssues int `json:"untagged_open_issues" yaml:"untagged_open_issues"`
|
||||
CommentedOpenIssues int `json:"commented_open_issues" yaml:"commented_open_issues"`
|
||||
Overdue bool `json:"overdue" yaml:"overdue"`
|
||||
DaysUntilDue *int `json:"days_until_due,omitempty" yaml:"days_until_due,omitempty"`
|
||||
SampleLimit int `json:"sample_limit" yaml:"sample_limit"`
|
||||
}
|
||||
|
||||
type milestoneReportReadiness struct {
|
||||
ReadyToClose bool `json:"ready_to_close" yaml:"ready_to_close"`
|
||||
Blockers []string `json:"blockers,omitempty" yaml:"blockers,omitempty"`
|
||||
Warnings []string `json:"warnings,omitempty" yaml:"warnings,omitempty"`
|
||||
}
|
||||
|
||||
type milestoneReportBreakdown struct {
|
||||
AllStatuses []milestoneCountItem `json:"all_statuses" yaml:"all_statuses"`
|
||||
OpenPriorities []milestoneCountItem `json:"open_priorities" yaml:"open_priorities"`
|
||||
OpenAssignees []milestoneCountItem `json:"open_assignees" yaml:"open_assignees"`
|
||||
OpenTags []milestoneCountItem `json:"open_tags" yaml:"open_tags"`
|
||||
}
|
||||
|
||||
type milestoneCountItem struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Count int `json:"count" yaml:"count"`
|
||||
}
|
||||
|
||||
type milestoneReportSamples struct {
|
||||
RecentOpenIssues []milestoneIssueSample `json:"recent_open_issues" yaml:"recent_open_issues"`
|
||||
UnassignedOpenIssues []milestoneIssueSample `json:"unassigned_open_issues" yaml:"unassigned_open_issues"`
|
||||
MostCommentedOpenIssues []milestoneIssueSample `json:"most_commented_open_issues" yaml:"most_commented_open_issues"`
|
||||
}
|
||||
|
||||
type milestoneIssueSample struct {
|
||||
Number int `json:"number" yaml:"number"`
|
||||
DatabaseID int `json:"database_id" yaml:"database_id"`
|
||||
Title string `json:"title" yaml:"title"`
|
||||
Status string `json:"status,omitempty" yaml:"status,omitempty"`
|
||||
Priority string `json:"priority,omitempty" yaml:"priority,omitempty"`
|
||||
Author string `json:"author,omitempty" yaml:"author,omitempty"`
|
||||
Assignees []string `json:"assignees,omitempty" yaml:"assignees,omitempty"`
|
||||
Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"`
|
||||
CommentCount int `json:"comment_count" yaml:"comment_count"`
|
||||
UpdatedAt string `json:"updated_at,omitempty" yaml:"updated_at,omitempty"`
|
||||
UpdatedAtUnix int64 `json:"-" yaml:"-"`
|
||||
}
|
||||
|
||||
type milestoneIssuesPage struct {
|
||||
Milestone map[string]interface{}
|
||||
TotalIssues int
|
||||
OpenIssues int
|
||||
ClosedIssues int
|
||||
Issues []map[string]interface{}
|
||||
}
|
||||
|
||||
type milestoneListPage struct {
|
||||
TotalCount int
|
||||
Items []map[string]interface{}
|
||||
}
|
||||
|
||||
func newMilestoneReportShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "report",
|
||||
Description: "Summarize milestone progress, open issue risks, and close readiness",
|
||||
Flags: []common.Flag{
|
||||
{Name: "id", Short: "i", Usage: "Milestone ID"},
|
||||
{Name: "name", Short: "n", Usage: "Milestone name"},
|
||||
{Name: "sample-limit", Usage: "Number of sample issues to include in each section", Default: "5"},
|
||||
},
|
||||
Run: func(ctx *common.RuntimeContext) error {
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return err
|
||||
}
|
||||
report, err := generateMilestoneReport(ctx, ctx.Arg("id"), ctx.Arg("name"), ctx.Arg("sample-limit"))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return ctx.OutputData(report)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func generateMilestoneReport(ctx *common.RuntimeContext, id, name, sampleLimitArg string) (*milestoneReport, error) {
|
||||
if strings.TrimSpace(id) != "" && strings.TrimSpace(name) != "" {
|
||||
return nil, fmt.Errorf("use either --id or --name, not both")
|
||||
}
|
||||
if strings.TrimSpace(id) == "" && strings.TrimSpace(name) == "" {
|
||||
return nil, fmt.Errorf("required flag --id or --name is missing")
|
||||
}
|
||||
|
||||
sampleLimit, err := parseMilestoneReportSampleLimit(sampleLimitArg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
milestoneID := strings.TrimSpace(id)
|
||||
if milestoneID == "" {
|
||||
resolvedID, err := resolveMilestoneIDByName(ctx, strings.TrimSpace(name))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
milestoneID = strconv.Itoa(resolvedID)
|
||||
}
|
||||
|
||||
allPage, err := fetchMilestoneIssuesPage(ctx, milestoneID, "", 1, milestoneReportPageSize)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch milestone report: %w", err)
|
||||
}
|
||||
allIssues, err := fetchAllMilestoneIssues(ctx, milestoneID, "")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch milestone issues: %w", err)
|
||||
}
|
||||
openIssues, err := fetchAllMilestoneIssues(ctx, milestoneID, "opened")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch open milestone issues: %w", err)
|
||||
}
|
||||
|
||||
meta := buildMilestoneReportMetadata(allPage.Milestone)
|
||||
report := &milestoneReport{
|
||||
Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo),
|
||||
Milestone: meta,
|
||||
Summary: buildMilestoneReportSummary(
|
||||
meta,
|
||||
allPage.TotalIssues,
|
||||
allPage.OpenIssues,
|
||||
allPage.ClosedIssues,
|
||||
openIssues,
|
||||
sampleLimit,
|
||||
),
|
||||
Breakdown: buildMilestoneReportBreakdown(allIssues, openIssues),
|
||||
Samples: buildMilestoneReportSamples(openIssues, sampleLimit),
|
||||
}
|
||||
report.Readiness = buildMilestoneReportReadiness(report.Milestone, report.Summary)
|
||||
return report, nil
|
||||
}
|
||||
|
||||
func parseMilestoneReportSampleLimit(value string) (int, error) {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return 5, nil
|
||||
}
|
||||
limit, err := strconv.Atoi(trimmed)
|
||||
if err != nil || limit <= 0 {
|
||||
return 0, fmt.Errorf("--sample-limit must be a positive integer")
|
||||
}
|
||||
return limit, nil
|
||||
}
|
||||
|
||||
func resolveMilestoneIDByName(ctx *common.RuntimeContext, name string) (int, error) {
|
||||
milestones, err := fetchAllMilestones(ctx)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("resolve milestone by name: %w", err)
|
||||
}
|
||||
exact := []map[string]interface{}{}
|
||||
fuzzy := []map[string]interface{}{}
|
||||
target := strings.ToLower(strings.TrimSpace(name))
|
||||
for _, milestone := range milestones {
|
||||
milestoneName := strings.TrimSpace(stringValue(milestone["name"]))
|
||||
if milestoneName == "" {
|
||||
continue
|
||||
}
|
||||
lowerName := strings.ToLower(milestoneName)
|
||||
switch {
|
||||
case lowerName == target:
|
||||
exact = append(exact, milestone)
|
||||
case strings.Contains(lowerName, target):
|
||||
fuzzy = append(fuzzy, milestone)
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case len(exact) == 1:
|
||||
return intValue(exact[0]["id"]), nil
|
||||
case len(exact) > 1:
|
||||
return 0, fmt.Errorf("milestone name %q is ambiguous: %s", name, joinMilestoneMatches(exact))
|
||||
case len(fuzzy) == 1:
|
||||
return intValue(fuzzy[0]["id"]), nil
|
||||
case len(fuzzy) > 1:
|
||||
return 0, fmt.Errorf("milestone name %q matched multiple milestones: %s", name, joinMilestoneMatches(fuzzy))
|
||||
default:
|
||||
return 0, fmt.Errorf("milestone %q not found", name)
|
||||
}
|
||||
}
|
||||
|
||||
func fetchAllMilestones(ctx *common.RuntimeContext) ([]map[string]interface{}, error) {
|
||||
all := []map[string]interface{}{}
|
||||
for page := 1; ; page++ {
|
||||
pageData, err := fetchMilestoneListPage(ctx, page, milestoneReportPageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(pageData.Items) == 0 {
|
||||
break
|
||||
}
|
||||
all = append(all, pageData.Items...)
|
||||
if pageData.TotalCount > 0 {
|
||||
if len(all) >= pageData.TotalCount {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(pageData.Items) < milestoneReportPageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func fetchMilestoneListPage(ctx *common.RuntimeContext, page, limit int) (*milestoneListPage, error) {
|
||||
q := url.Values{}
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
q.Set("limit", strconv.Itoa(limit))
|
||||
env, err := ctx.CallAPIWithQuery("GET", milestonePath(ctx), q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("milestone list response did not contain an object")
|
||||
}
|
||||
return &milestoneListPage{
|
||||
TotalCount: firstMilestoneInt(data, "total_count"),
|
||||
Items: objectSlice(data["milestones"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func fetchAllMilestoneIssues(ctx *common.RuntimeContext, id, category string) ([]map[string]interface{}, error) {
|
||||
all := []map[string]interface{}{}
|
||||
for page := 1; ; page++ {
|
||||
pageData, err := fetchMilestoneIssuesPage(ctx, id, category, page, milestoneReportPageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(pageData.Issues) == 0 {
|
||||
break
|
||||
}
|
||||
all = append(all, pageData.Issues...)
|
||||
targetTotal := milestoneIssuesExpectedTotal(pageData, category)
|
||||
if targetTotal > 0 {
|
||||
if len(all) >= targetTotal {
|
||||
break
|
||||
}
|
||||
continue
|
||||
}
|
||||
if len(pageData.Issues) < milestoneReportPageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func milestoneIssuesExpectedTotal(pageData *milestoneIssuesPage, category string) int {
|
||||
switch strings.ToLower(strings.TrimSpace(category)) {
|
||||
case "opened", "open", "opening":
|
||||
if pageData.OpenIssues > 0 {
|
||||
return pageData.OpenIssues
|
||||
}
|
||||
case "closed", "close":
|
||||
if pageData.ClosedIssues > 0 {
|
||||
return pageData.ClosedIssues
|
||||
}
|
||||
}
|
||||
return pageData.TotalIssues
|
||||
}
|
||||
|
||||
func fetchMilestoneIssuesPage(ctx *common.RuntimeContext, id, category string, page, limit int) (*milestoneIssuesPage, error) {
|
||||
q := url.Values{}
|
||||
q.Set("page", strconv.Itoa(page))
|
||||
q.Set("limit", strconv.Itoa(limit))
|
||||
setQueryIfPresent(q, "category", category)
|
||||
|
||||
env, err := ctx.CallAPIWithQuery("GET", milestoneItemPath(ctx, id), q)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data, ok := env.Data.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("milestone view response did not contain an object")
|
||||
}
|
||||
|
||||
return &milestoneIssuesPage{
|
||||
Milestone: objectValue(data["milestone"]),
|
||||
TotalIssues: firstMilestoneInt(data, "total_issues_count", "total_count"),
|
||||
OpenIssues: firstMilestoneInt(data, "opened_issues_count", "open_issues_count"),
|
||||
ClosedIssues: firstMilestoneInt(data, "closed_issues_count", "close_issues_count"),
|
||||
Issues: objectSlice(data["issues"]),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func buildMilestoneReportMetadata(milestone map[string]interface{}) milestoneReportMetadata {
|
||||
return milestoneReportMetadata{
|
||||
ID: intValue(milestone["id"]),
|
||||
Name: stringValue(milestone["name"]),
|
||||
Description: stringValue(milestone["description"]),
|
||||
Status: stringValue(milestone["status"]),
|
||||
DueDate: stringValue(milestone["effective_date"]),
|
||||
CreatedAt: stringValue(milestone["created_at"]),
|
||||
UpdatedAt: firstMilestoneString(milestone, "updated_on", "updated_at"),
|
||||
CompletionPct: roundMilestonePercent(floatValue(milestone["percent"])),
|
||||
}
|
||||
}
|
||||
|
||||
func buildMilestoneReportSummary(meta milestoneReportMetadata, totalIssues, openIssues, closedIssues int, openItems []map[string]interface{}, sampleLimit int) milestoneReportSummary {
|
||||
unassigned := 0
|
||||
untagged := 0
|
||||
commented := 0
|
||||
for _, issue := range openItems {
|
||||
if len(issueUserNames(issue["assigners"])) == 0 {
|
||||
unassigned++
|
||||
}
|
||||
if len(issueTagNames(issue["tags"])) == 0 {
|
||||
untagged++
|
||||
}
|
||||
if intValue(issue["comment_journals_count"]) > 0 {
|
||||
commented++
|
||||
}
|
||||
}
|
||||
|
||||
summary := milestoneReportSummary{
|
||||
TotalIssues: totalIssues,
|
||||
OpenIssues: openIssues,
|
||||
ClosedIssues: closedIssues,
|
||||
UnassignedOpenIssues: unassigned,
|
||||
UntaggedOpenIssues: untagged,
|
||||
CommentedOpenIssues: commented,
|
||||
SampleLimit: sampleLimit,
|
||||
}
|
||||
|
||||
if meta.DueDate != "" {
|
||||
if dueDate := parseMilestoneTime(meta.DueDate); !dueDate.IsZero() {
|
||||
days := milestoneDaysUntil(dueDate)
|
||||
summary.DaysUntilDue = &days
|
||||
summary.Overdue = days < 0
|
||||
}
|
||||
}
|
||||
return summary
|
||||
}
|
||||
|
||||
func buildMilestoneReportReadiness(meta milestoneReportMetadata, summary milestoneReportSummary) milestoneReportReadiness {
|
||||
readiness := milestoneReportReadiness{}
|
||||
if strings.EqualFold(meta.Status, "closed") {
|
||||
readiness.ReadyToClose = true
|
||||
return readiness
|
||||
}
|
||||
if summary.OpenIssues == 0 {
|
||||
readiness.ReadyToClose = true
|
||||
} else {
|
||||
readiness.Blockers = append(readiness.Blockers, fmt.Sprintf("%d open issues remain", summary.OpenIssues))
|
||||
}
|
||||
if summary.UnassignedOpenIssues > 0 {
|
||||
readiness.Warnings = append(readiness.Warnings, fmt.Sprintf("%d open issues have no assignee", summary.UnassignedOpenIssues))
|
||||
}
|
||||
if summary.UntaggedOpenIssues > 0 {
|
||||
readiness.Warnings = append(readiness.Warnings, fmt.Sprintf("%d open issues have no tags", summary.UntaggedOpenIssues))
|
||||
}
|
||||
if meta.DueDate == "" {
|
||||
readiness.Warnings = append(readiness.Warnings, "milestone has no due date")
|
||||
} else if summary.Overdue && summary.DaysUntilDue != nil {
|
||||
readiness.Warnings = append(readiness.Warnings, fmt.Sprintf("milestone is overdue by %d days", -(*summary.DaysUntilDue)))
|
||||
}
|
||||
return readiness
|
||||
}
|
||||
|
||||
func buildMilestoneReportBreakdown(allIssues, openIssues []map[string]interface{}) milestoneReportBreakdown {
|
||||
return milestoneReportBreakdown{
|
||||
AllStatuses: countSorted(allIssues, issueStatusName),
|
||||
OpenPriorities: countSorted(openIssues, issuePriorityName),
|
||||
OpenAssignees: countSortedMulti(openIssues, func(item map[string]interface{}) []string { return issueUserNames(item["assigners"]) }),
|
||||
OpenTags: countSortedMulti(openIssues, func(item map[string]interface{}) []string { return issueTagNames(item["tags"]) }),
|
||||
}
|
||||
}
|
||||
|
||||
func buildMilestoneReportSamples(openIssues []map[string]interface{}, sampleLimit int) milestoneReportSamples {
|
||||
recent := buildIssueSamples(openIssues, sampleLimit, func(left, right milestoneIssueSample) bool {
|
||||
if left.UpdatedAtUnix != right.UpdatedAtUnix {
|
||||
return left.UpdatedAtUnix > right.UpdatedAtUnix
|
||||
}
|
||||
return left.Number < right.Number
|
||||
})
|
||||
unassigned := buildIssueSamples(filterMilestoneIssues(openIssues, func(item map[string]interface{}) bool {
|
||||
return len(issueUserNames(item["assigners"])) == 0
|
||||
}), sampleLimit, func(left, right milestoneIssueSample) bool {
|
||||
if left.UpdatedAtUnix != right.UpdatedAtUnix {
|
||||
return left.UpdatedAtUnix > right.UpdatedAtUnix
|
||||
}
|
||||
return left.Number < right.Number
|
||||
})
|
||||
commented := buildIssueSamples(filterMilestoneIssues(openIssues, func(item map[string]interface{}) bool {
|
||||
return intValue(item["comment_journals_count"]) > 0
|
||||
}), sampleLimit, func(left, right milestoneIssueSample) bool {
|
||||
if left.CommentCount != right.CommentCount {
|
||||
return left.CommentCount > right.CommentCount
|
||||
}
|
||||
if left.UpdatedAtUnix != right.UpdatedAtUnix {
|
||||
return left.UpdatedAtUnix > right.UpdatedAtUnix
|
||||
}
|
||||
return left.Number < right.Number
|
||||
})
|
||||
|
||||
return milestoneReportSamples{
|
||||
RecentOpenIssues: recent,
|
||||
UnassignedOpenIssues: unassigned,
|
||||
MostCommentedOpenIssues: commented,
|
||||
}
|
||||
}
|
||||
|
||||
func buildIssueSamples(items []map[string]interface{}, sampleLimit int, less func(left, right milestoneIssueSample) bool) []milestoneIssueSample {
|
||||
samples := make([]milestoneIssueSample, 0, len(items))
|
||||
for _, item := range items {
|
||||
samples = append(samples, normalizeMilestoneIssueSample(item))
|
||||
}
|
||||
sort.Slice(samples, func(i, j int) bool {
|
||||
return less(samples[i], samples[j])
|
||||
})
|
||||
if len(samples) > sampleLimit {
|
||||
samples = samples[:sampleLimit]
|
||||
}
|
||||
for i := range samples {
|
||||
samples[i].UpdatedAtUnix = 0
|
||||
}
|
||||
return samples
|
||||
}
|
||||
|
||||
func normalizeMilestoneIssueSample(item map[string]interface{}) milestoneIssueSample {
|
||||
updatedAt := firstMilestoneString(item, "updated_at", "updated_on")
|
||||
updatedAtUnix := int64(0)
|
||||
if parsed := parseMilestoneTime(updatedAt); !parsed.IsZero() {
|
||||
updatedAtUnix = parsed.Unix()
|
||||
}
|
||||
return milestoneIssueSample{
|
||||
Number: firstMilestoneInt(item, "project_issues_index", "number"),
|
||||
DatabaseID: intValue(item["id"]),
|
||||
Title: firstMilestoneString(item, "subject", "title"),
|
||||
Status: issueStatusName(item),
|
||||
Priority: issuePriorityName(item),
|
||||
Author: userDisplayName(objectValue(item["author"])),
|
||||
Assignees: issueUserNames(item["assigners"]),
|
||||
Tags: issueTagNames(item["tags"]),
|
||||
CommentCount: intValue(item["comment_journals_count"]),
|
||||
UpdatedAt: updatedAt,
|
||||
UpdatedAtUnix: updatedAtUnix,
|
||||
}
|
||||
}
|
||||
|
||||
func filterMilestoneIssues(items []map[string]interface{}, keep func(item map[string]interface{}) bool) []map[string]interface{} {
|
||||
filtered := make([]map[string]interface{}, 0, len(items))
|
||||
for _, item := range items {
|
||||
if keep(item) {
|
||||
filtered = append(filtered, item)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func countSorted(items []map[string]interface{}, name func(item map[string]interface{}) string) []milestoneCountItem {
|
||||
counts := map[string]int{}
|
||||
for _, item := range items {
|
||||
key := strings.TrimSpace(name(item))
|
||||
if key == "" {
|
||||
key = "Unspecified"
|
||||
}
|
||||
counts[key]++
|
||||
}
|
||||
return sortMilestoneCounts(counts)
|
||||
}
|
||||
|
||||
func countSortedMulti(items []map[string]interface{}, names func(item map[string]interface{}) []string) []milestoneCountItem {
|
||||
counts := map[string]int{}
|
||||
for _, item := range items {
|
||||
values := names(item)
|
||||
if len(values) == 0 {
|
||||
counts["Unspecified"]++
|
||||
continue
|
||||
}
|
||||
for _, value := range values {
|
||||
key := strings.TrimSpace(value)
|
||||
if key == "" {
|
||||
key = "Unspecified"
|
||||
}
|
||||
counts[key]++
|
||||
}
|
||||
}
|
||||
return sortMilestoneCounts(counts)
|
||||
}
|
||||
|
||||
func sortMilestoneCounts(counts map[string]int) []milestoneCountItem {
|
||||
items := make([]milestoneCountItem, 0, len(counts))
|
||||
for name, count := range counts {
|
||||
items = append(items, milestoneCountItem{Name: name, Count: count})
|
||||
}
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
if items[i].Count != items[j].Count {
|
||||
return items[i].Count > items[j].Count
|
||||
}
|
||||
return items[i].Name < items[j].Name
|
||||
})
|
||||
return items
|
||||
}
|
||||
|
||||
func issueStatusName(item map[string]interface{}) string {
|
||||
if name := firstMilestoneString(item, "status_name"); name != "" {
|
||||
return name
|
||||
}
|
||||
return firstMilestoneString(objectValue(item["status"]), "name")
|
||||
}
|
||||
|
||||
func issuePriorityName(item map[string]interface{}) string {
|
||||
if name := firstMilestoneString(item, "priority_name"); name != "" {
|
||||
return name
|
||||
}
|
||||
return firstMilestoneString(objectValue(item["priority"]), "name")
|
||||
}
|
||||
|
||||
func issueUserNames(value interface{}) []string {
|
||||
users := objectSlice(value)
|
||||
names := make([]string, 0, len(users))
|
||||
for _, user := range users {
|
||||
if name := userDisplayName(user); name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func userDisplayName(user map[string]interface{}) string {
|
||||
if name := strings.TrimSpace(stringValue(user["name"])); name != "" {
|
||||
return name
|
||||
}
|
||||
return strings.TrimSpace(stringValue(user["login"]))
|
||||
}
|
||||
|
||||
func issueTagNames(value interface{}) []string {
|
||||
tags := objectSlice(value)
|
||||
names := make([]string, 0, len(tags))
|
||||
for _, tag := range tags {
|
||||
if name := strings.TrimSpace(stringValue(tag["name"])); name != "" {
|
||||
names = append(names, name)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func milestoneDaysUntil(dueDate time.Time) int {
|
||||
now := milestoneNow()
|
||||
nowDate := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location())
|
||||
due := time.Date(dueDate.Year(), dueDate.Month(), dueDate.Day(), 0, 0, 0, 0, now.Location())
|
||||
return int(math.Round(due.Sub(nowDate).Hours() / 24))
|
||||
}
|
||||
|
||||
func parseMilestoneTime(value string) time.Time {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
for _, layout := range []string{
|
||||
time.RFC3339,
|
||||
time.RFC3339Nano,
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02",
|
||||
} {
|
||||
if parsed, err := time.ParseInLocation(layout, trimmed, time.Local); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func roundMilestonePercent(value float64) float64 {
|
||||
return math.Round(value*10000) / 100
|
||||
}
|
||||
|
||||
func joinMilestoneMatches(items []map[string]interface{}) string {
|
||||
parts := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
parts = append(parts, fmt.Sprintf("%d:%s", intValue(item["id"]), stringValue(item["name"])))
|
||||
}
|
||||
sort.Strings(parts)
|
||||
return strings.Join(parts, ", ")
|
||||
}
|
||||
|
||||
func firstMilestoneString(item map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value := strings.TrimSpace(stringValue(item[key])); value != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstMilestoneInt(item map[string]interface{}, keys ...string) int {
|
||||
for _, key := range keys {
|
||||
if value, ok := item[key]; ok {
|
||||
return intValue(value)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func objectValue(value interface{}) map[string]interface{} {
|
||||
item, _ := value.(map[string]interface{})
|
||||
return item
|
||||
}
|
||||
|
||||
func objectSlice(value interface{}) []map[string]interface{} {
|
||||
switch typed := value.(type) {
|
||||
case []map[string]interface{}:
|
||||
return append([]map[string]interface{}(nil), typed...)
|
||||
case []interface{}:
|
||||
items := make([]map[string]interface{}, 0, len(typed))
|
||||
for _, raw := range typed {
|
||||
if item, ok := raw.(map[string]interface{}); ok {
|
||||
items = append(items, item)
|
||||
}
|
||||
}
|
||||
return items
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func stringValue(value interface{}) string {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return typed
|
||||
case fmt.Stringer:
|
||||
return typed.String()
|
||||
case nil:
|
||||
return ""
|
||||
default:
|
||||
return fmt.Sprintf("%v", typed)
|
||||
}
|
||||
}
|
||||
|
||||
func intValue(value interface{}) int {
|
||||
switch typed := value.(type) {
|
||||
case int:
|
||||
return typed
|
||||
case int32:
|
||||
return int(typed)
|
||||
case int64:
|
||||
return int(typed)
|
||||
case float64:
|
||||
return int(typed)
|
||||
case float32:
|
||||
return int(typed)
|
||||
case string:
|
||||
n, _ := strconv.Atoi(strings.TrimSpace(typed))
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func floatValue(value interface{}) float64 {
|
||||
switch typed := value.(type) {
|
||||
case float64:
|
||||
return typed
|
||||
case float32:
|
||||
return float64(typed)
|
||||
case int:
|
||||
return float64(typed)
|
||||
case int32:
|
||||
return float64(typed)
|
||||
case int64:
|
||||
return float64(typed)
|
||||
case string:
|
||||
n, _ := strconv.ParseFloat(strings.TrimSpace(typed), 64)
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,369 @@
|
|||
package milestone
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestGenerateMilestoneReportByID(t *testing.T) {
|
||||
oldNow := milestoneNow
|
||||
milestoneNow = func() time.Time {
|
||||
return time.Date(2026, 6, 11, 10, 0, 0, 0, time.Local)
|
||||
}
|
||||
defer func() { milestoneNow = oldNow }()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/milestones/7.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
|
||||
page := r.URL.Query().Get("page")
|
||||
if page == "" {
|
||||
page = "1"
|
||||
}
|
||||
|
||||
switch r.URL.Query().Get("category") {
|
||||
case "":
|
||||
if page != "1" {
|
||||
t.Fatalf("unexpected extra all-issues page request: %s", page)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"milestone": map[string]interface{}{
|
||||
"id": 7,
|
||||
"name": "v1.0",
|
||||
"description": "first release",
|
||||
"effective_date": "2026-06-09",
|
||||
"status": "open",
|
||||
"created_at": "2026-05-01 09:00",
|
||||
"updated_on": "2026-06-10 08:30",
|
||||
"percent": 0.6666667,
|
||||
"issues_count": 3,
|
||||
"opened_issues_count": 2,
|
||||
"close_issues_count": 1,
|
||||
},
|
||||
"total_issues_count": 3,
|
||||
"opened_issues_count": 2,
|
||||
"closed_issues_count": 1,
|
||||
"issues": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 101,
|
||||
"project_issues_index": 11,
|
||||
"subject": "login failed",
|
||||
"status_name": "Open",
|
||||
"priority_name": "High",
|
||||
"author": map[string]interface{}{"name": "Alice"},
|
||||
"assigners": []interface{}{map[string]interface{}{"name": "Bob"}},
|
||||
"tags": []interface{}{map[string]interface{}{"name": "bug"}},
|
||||
"comment_journals_count": 2,
|
||||
"updated_at": "2026-06-10 09:30",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 102,
|
||||
"project_issues_index": 12,
|
||||
"subject": "docs cleanup",
|
||||
"status_name": "Open",
|
||||
"priority_name": "Normal",
|
||||
"author": map[string]interface{}{"login": "carol"},
|
||||
"assigners": []interface{}{},
|
||||
"tags": []interface{}{},
|
||||
"comment_journals_count": 0,
|
||||
"updated_at": "2026-06-08 08:00",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 103,
|
||||
"project_issues_index": 13,
|
||||
"subject": "closed bug",
|
||||
"status_name": "Closed",
|
||||
"priority_name": "Normal",
|
||||
"author": map[string]interface{}{"name": "Dave"},
|
||||
"assigners": []interface{}{map[string]interface{}{"name": "Bob"}},
|
||||
"tags": []interface{}{map[string]interface{}{"name": "cleanup"}},
|
||||
"comment_journals_count": 1,
|
||||
"updated_at": "2026-06-07 12:00",
|
||||
},
|
||||
},
|
||||
})
|
||||
case "opened":
|
||||
if page != "1" {
|
||||
t.Fatalf("opened issues should stop after first page, got page %s", page)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"milestone": map[string]interface{}{
|
||||
"id": 7,
|
||||
"name": "v1.0",
|
||||
"effective_date": "2026-06-09",
|
||||
"status": "open",
|
||||
"percent": 0.6666667,
|
||||
},
|
||||
"total_issues_count": 3,
|
||||
"opened_issues_count": 2,
|
||||
"closed_issues_count": 1,
|
||||
"issues": []interface{}{
|
||||
map[string]interface{}{
|
||||
"id": 101,
|
||||
"project_issues_index": 11,
|
||||
"subject": "login failed",
|
||||
"status_name": "Open",
|
||||
"priority_name": "High",
|
||||
"author": map[string]interface{}{"name": "Alice"},
|
||||
"assigners": []interface{}{map[string]interface{}{"name": "Bob"}},
|
||||
"tags": []interface{}{map[string]interface{}{"name": "bug"}},
|
||||
"comment_journals_count": 2,
|
||||
"updated_at": "2026-06-10 09:30",
|
||||
},
|
||||
map[string]interface{}{
|
||||
"id": 102,
|
||||
"project_issues_index": 12,
|
||||
"subject": "docs cleanup",
|
||||
"status_name": "Open",
|
||||
"priority_name": "Normal",
|
||||
"author": map[string]interface{}{"login": "carol"},
|
||||
"assigners": []interface{}{},
|
||||
"tags": []interface{}{},
|
||||
"comment_journals_count": 0,
|
||||
"updated_at": "2026-06-08 08:00",
|
||||
},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected category: %q", r.URL.Query().Get("category"))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
}
|
||||
|
||||
report, err := generateMilestoneReport(ctx, "7", "", "2")
|
||||
if err != nil {
|
||||
t.Fatalf("generateMilestoneReport failed: %v", err)
|
||||
}
|
||||
|
||||
assertEqual(t, report.Repository, "owner/repo")
|
||||
assertEqual(t, report.Milestone.ID, 7)
|
||||
assertEqual(t, report.Milestone.Name, "v1.0")
|
||||
assertEqual(t, report.Milestone.CompletionPct, 66.67)
|
||||
assertEqual(t, report.Summary.TotalIssues, 3)
|
||||
assertEqual(t, report.Summary.OpenIssues, 2)
|
||||
assertEqual(t, report.Summary.ClosedIssues, 1)
|
||||
assertEqual(t, report.Summary.UnassignedOpenIssues, 1)
|
||||
assertEqual(t, report.Summary.UntaggedOpenIssues, 1)
|
||||
assertEqual(t, report.Summary.CommentedOpenIssues, 1)
|
||||
if report.Summary.DaysUntilDue == nil || *report.Summary.DaysUntilDue != -2 {
|
||||
t.Fatalf("unexpected days_until_due: %+v", report.Summary.DaysUntilDue)
|
||||
}
|
||||
if !report.Summary.Overdue {
|
||||
t.Fatal("expected overdue milestone")
|
||||
}
|
||||
if report.Readiness.ReadyToClose {
|
||||
t.Fatal("milestone should not be ready to close")
|
||||
}
|
||||
if len(report.Readiness.Blockers) != 1 || !strings.Contains(report.Readiness.Blockers[0], "2 open issues remain") {
|
||||
t.Fatalf("unexpected blockers: %+v", report.Readiness.Blockers)
|
||||
}
|
||||
if len(report.Readiness.Warnings) != 3 {
|
||||
t.Fatalf("unexpected warnings: %+v", report.Readiness.Warnings)
|
||||
}
|
||||
assertEqual(t, report.Breakdown.AllStatuses[0].Name, "Open")
|
||||
assertEqual(t, report.Breakdown.AllStatuses[0].Count, 2)
|
||||
assertEqual(t, report.Breakdown.OpenPriorities[0].Name, "High")
|
||||
assertEqual(t, report.Breakdown.OpenPriorities[0].Count, 1)
|
||||
if len(report.Samples.RecentOpenIssues) != 2 || report.Samples.RecentOpenIssues[0].Number != 11 {
|
||||
t.Fatalf("unexpected recent_open_issues: %+v", report.Samples.RecentOpenIssues)
|
||||
}
|
||||
if len(report.Samples.UnassignedOpenIssues) != 1 || report.Samples.UnassignedOpenIssues[0].Number != 12 {
|
||||
t.Fatalf("unexpected unassigned_open_issues: %+v", report.Samples.UnassignedOpenIssues)
|
||||
}
|
||||
if len(report.Samples.MostCommentedOpenIssues) != 1 || report.Samples.MostCommentedOpenIssues[0].Number != 11 {
|
||||
t.Fatalf("unexpected most_commented_open_issues: %+v", report.Samples.MostCommentedOpenIssues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateMilestoneReportByNameResolvesExactMatch(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/owner/repo/milestones.json":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"total_count": 2,
|
||||
"milestones": []interface{}{
|
||||
map[string]interface{}{"id": 7, "name": "v1.0"},
|
||||
map[string]interface{}{"id": 8, "name": "v2.0"},
|
||||
},
|
||||
})
|
||||
case "/v1/owner/repo/milestones/7.json":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"milestone": map[string]interface{}{
|
||||
"id": 7,
|
||||
"name": "v1.0",
|
||||
"effective_date": "2026-07-01",
|
||||
"status": "open",
|
||||
"percent": 1,
|
||||
},
|
||||
"total_issues_count": 0,
|
||||
"opened_issues_count": 0,
|
||||
"closed_issues_count": 0,
|
||||
"issues": []interface{}{},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
}
|
||||
|
||||
report, err := generateMilestoneReport(ctx, "", "v1.0", "3")
|
||||
if err != nil {
|
||||
t.Fatalf("generateMilestoneReport by name failed: %v", err)
|
||||
}
|
||||
assertEqual(t, report.Milestone.ID, 7)
|
||||
assertEqual(t, report.Milestone.Name, "v1.0")
|
||||
if !report.Readiness.ReadyToClose {
|
||||
t.Fatal("milestone with zero open issues should be ready to close")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateMilestoneReportByNameAmbiguous(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/owner/repo/milestones.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"total_count": 2,
|
||||
"milestones": []interface{}{
|
||||
map[string]interface{}{"id": 7, "name": "release-v1"},
|
||||
map[string]interface{}{"id": 8, "name": "release-v1-hotfix"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
}
|
||||
|
||||
_, err := generateMilestoneReport(ctx, "", "release", "5")
|
||||
if err == nil || !strings.Contains(err.Error(), "matched multiple") {
|
||||
t.Fatalf("expected ambiguous milestone error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchAllMilestoneIssuesFollowsReportedTotalAcrossPages(t *testing.T) {
|
||||
pages := []string{}
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/milestones/7.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if r.URL.Query().Get("category") != "" {
|
||||
t.Fatalf("unexpected category: %q", r.URL.Query().Get("category"))
|
||||
}
|
||||
|
||||
page := r.URL.Query().Get("page")
|
||||
pages = append(pages, page)
|
||||
switch page {
|
||||
case "1":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"milestone": map[string]interface{}{"id": 7, "name": "v1.0"},
|
||||
"total_issues_count": 3,
|
||||
"opened_issues_count": 2,
|
||||
"closed_issues_count": 1,
|
||||
"issues": []interface{}{
|
||||
map[string]interface{}{"id": 101, "subject": "one"},
|
||||
map[string]interface{}{"id": 102, "subject": "two"},
|
||||
},
|
||||
})
|
||||
case "2":
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"milestone": map[string]interface{}{"id": 7, "name": "v1.0"},
|
||||
"total_issues_count": 3,
|
||||
"opened_issues_count": 2,
|
||||
"closed_issues_count": 1,
|
||||
"issues": []interface{}{
|
||||
map[string]interface{}{"id": 103, "subject": "three"},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected page request: %s", page)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
}
|
||||
|
||||
issues, err := fetchAllMilestoneIssues(ctx, "7", "")
|
||||
if err != nil {
|
||||
t.Fatalf("fetchAllMilestoneIssues failed: %v", err)
|
||||
}
|
||||
if len(issues) != 3 {
|
||||
t.Fatalf("expected 3 issues, got %d", len(issues))
|
||||
}
|
||||
if strings.Join(pages, ",") != "1,2" {
|
||||
t.Fatalf("unexpected pages fetched: %v", pages)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMilestoneReportShortcutRequiresSelector(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatalf("report shortcut should fail before API call: %s %s", r.Method, r.URL.Path)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
err := runMilestoneShortcut(t, server, "report", map[string]string{})
|
||||
if err == nil {
|
||||
t.Fatal("expected missing selector error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMilestoneReportShortcutRuns(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/milestones/7.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
writeJSON(t, w, map[string]interface{}{
|
||||
"milestone": map[string]interface{}{
|
||||
"id": 7,
|
||||
"name": "v1.0",
|
||||
"effective_date": "2026-07-01",
|
||||
"status": "open",
|
||||
"percent": 1,
|
||||
},
|
||||
"total_issues_count": 0,
|
||||
"opened_issues_count": 0,
|
||||
"closed_issues_count": 0,
|
||||
"issues": []interface{}{},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if err := runMilestoneShortcut(t, server, "report", map[string]string{"id": "7", "sample-limit": "2"}); err != nil {
|
||||
t.Fatalf("report shortcut failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMilestoneReportSampleLimit(t *testing.T) {
|
||||
if _, err := parseMilestoneReportSampleLimit("0"); err == nil {
|
||||
t.Fatal("expected validation error for zero sample-limit")
|
||||
}
|
||||
if got, err := parseMilestoneReportSampleLimit("3"); err != nil || got != 3 {
|
||||
t.Fatalf("parseMilestoneReportSampleLimit = %d, %v; want 3, nil", got, err)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue