diff --git a/README.md b/README.md index 6fee31b..cc4cc06 100644 --- a/README.md +++ b/README.md @@ -567,9 +567,11 @@ gitlink-cli user +trends --login Mengz --keyword release --project gitlink-cli - `workflow +health` - `workflow +pr-summary` - `workflow +repo-report` +- `workflow +stale` `workflow +pr-summary` defaults to `table` when `--format` is omitted. `workflow +repo-report` defaults to `markdown` when `--format` is omitted. +`workflow +stale` defaults to `markdown` when `--format` is omitted. Examples: @@ -642,6 +644,12 @@ gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format ma # Repository workflow report from a local JSON file gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json + +# Stale issue / PR queue report by read-only GitLink fetch +gitlink-cli workflow +stale --owner Gitlink --repo gitlink-cli --stale-days 30 --top 15 --format markdown + +# Stale queue report from a local JSON file +gitlink-cli workflow +stale --from shortcuts/workflow/testdata/stale_input.json --format json ``` Output formats: @@ -657,6 +665,7 @@ Safety: - They do not depend on LLM APIs. - `workflow +pr-summary` does not comment, approve, reject, or merge pull requests. - `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes. +- `workflow +stale` scans stale issues and pull requests, classifies them into watch / stale / zombie buckets, and suggests next actions without remote writes. ### Raw API diff --git a/doc/changes/workflow-stale.md b/doc/changes/workflow-stale.md new file mode 100644 index 0000000..3067e57 --- /dev/null +++ b/doc/changes/workflow-stale.md @@ -0,0 +1,31 @@ +# Workflow Stale Report + +## Summary + +Adds a new read-only `workflow +stale` shortcut so maintainers can scan stale issues and pull requests without falling back to ad hoc scripts or Raw API calls. + +## What it does + +- Reads stale scan input from a local JSON file or fetches open queues directly from GitLink in read-only mode. +- Classifies each issue or pull request into `fresh`, `watch`, `stale`, or `zombie` buckets based on the last known activity time. +- Produces stable `json`, terminal-friendly `table`, and maintainer-friendly `markdown` output. +- Includes per-item next actions, suggested follow-up comments, queue-level recommendations, and fetch fallback notes. + +## API handling + +- Issue scan mode normalizes real GitLink fields such as `subject`, `status_name`, `status` objects, `issue_tags`, and `comment_journals_count`. +- PR scan mode uses list metadata first, then probes `/issues/{id}/journals` when the PR list lacks a reliable `updated_at` field. +- Journal fallback is best-effort: if probing fails, the report keeps the PR and records a note that age was estimated from creation time. + +## Tests + +- `go test ./shortcuts/workflow` +- `go test ./...` +- `go build ./...` +- `git diff --check` + +## 中文说明 + +新增只读命令 `workflow +stale`,用于扫描仓库中的陈旧 Issue / PR 队列,并输出维护者可直接使用的处理报告。它支持本地 JSON 输入,也支持直接读取 GitLink 开放队列;会按最后活动时间分成 `fresh`、`watch`、`stale`、`zombie` 四档,并给出逐条建议动作、建议跟进评论和队列级建议。 + +为了让结果更贴近 GitLink 真实接口,这次同时补强了 Issue 字段兼容性,支持 `subject`、`status_name`、`status` 对象、`issue_tags`、`comment_journals_count` 等字段。对 PR,命令会优先读取列表元数据;当列表缺少可靠的更新时间时,再回退查询 `/issues/{id}/journals` 推断最近活动时间,失败时保留条目并在报告里说明是按创建时间估算。 diff --git a/docs/pr-draft.md b/docs/pr-draft.md index c8b3189..b12ff57 100644 --- a/docs/pr-draft.md +++ b/docs/pr-draft.md @@ -2,12 +2,13 @@ ## Summary -This PR adds four read-only workflow commands for repository maintenance: +This PR adds five read-only workflow commands for repository maintenance: - `workflow +triage` - `workflow +health` - `workflow +pr-summary` - `workflow +repo-report` +- `workflow +stale` The commands provide rule-based, explainable analysis with stable `json`, concise `table`, and copy-friendly `markdown` output. @@ -52,6 +53,12 @@ without introducing LLM dependencies or remote write behavior. - Produces a repository workflow report with score, risk level, recommendations, and reasoning - Supports partial read-only remote aggregation when optional sections are unavailable +### `workflow +stale` + +- Scans issue and pull request queues for stale activity without remote writes +- Buckets results into `watch`, `stale`, and `zombie` severity levels +- Produces queue summaries, per-item recommendations, and fallback notes when PR activity requires journal probing + ## Safety - Remote mode is read-only @@ -87,11 +94,11 @@ Coverage includes: - `README.md` - `docs/workflow-agent-design.md` - `docs/workflow-agent-test-report.md` -- `skills/gitlink-workflow/SKILL.md` +- `doc/changes/workflow-stale.md` ## Known Limitations -- `workflow +stale` is not implemented. +- `workflow +release-notes` is not implemented. - Real GitLink API shapes may require follow-up normalization. ## Examples @@ -101,5 +108,5 @@ gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json - gitlink-cli workflow +health --from shortcuts/workflow/testdata/health_good.json --format markdown gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format markdown gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown -gitlink-cli workflow +release-notes --from shortcuts/workflow/testdata/release_notes.json --format markdown +gitlink-cli workflow +stale --from shortcuts/workflow/testdata/stale_input.json --format markdown ``` diff --git a/shortcuts/workflow/api_types.go b/shortcuts/workflow/api_types.go index ecae6ca..d023741 100644 --- a/shortcuts/workflow/api_types.go +++ b/shortcuts/workflow/api_types.go @@ -104,7 +104,7 @@ func apiList(data interface{}) []interface{} { case []interface{}: return v case map[string]interface{}: - for _, key := range []string{"issues", "pulls", "pull_requests", "files", "commits", "reviews", "issue_tags", "labels", "releases", "builds", "items", "records", "data"} { + for _, key := range []string{"issues", "pulls", "pull_requests", "files", "commits", "releases", "builds", "items", "records", "data", "journals", "issue_journals"} { if raw, ok := v[key]; ok { if items := apiList(raw); len(items) > 0 { return items @@ -259,6 +259,7 @@ func parseAPIStringTime(value string) time.Time { time.RFC3339, time.RFC3339Nano, "2006-01-02 15:04:05", + "2006-01-02 15:04", "2006-01-02T15:04:05", "2006-01-02", } diff --git a/shortcuts/workflow/render.go b/shortcuts/workflow/render.go index 06b127c..b25d3e1 100644 --- a/shortcuts/workflow/render.go +++ b/shortcuts/workflow/render.go @@ -8,6 +8,7 @@ import ( "sort" "strings" "text/tabwriter" + "time" ) func renderTriageReport(w io.Writer, report TriageReport, format string) error { @@ -36,6 +37,19 @@ func renderHealthResult(w io.Writer, result HealthResult, format string) error { } } +func renderStaleReport(w io.Writer, report StaleReport, format string, lang string) error { + switch normalizeFormat(format) { + case "json": + return writeJSON(w, report) + case "markdown": + return writeStaleMarkdown(w, report, lang) + case "table": + return writeStaleTable(w, report) + default: + return fmt.Errorf("unsupported workflow output format %q", format) + } +} + func RenderPRSummary(result PRSummaryResult, format string, lang string) (string, error) { var buf bytes.Buffer switch normalizeFormat(format) { @@ -78,27 +92,6 @@ func RenderRepoReport(result RepoReportResult, format string, lang string) (stri return buf.String(), nil } -func RenderReleaseNotes(result ReleaseNotesResult, format string, lang string) (string, error) { - var buf bytes.Buffer - switch normalizeFormat(format) { - case "json": - if err := writeJSON(&buf, result); err != nil { - return "", err - } - case "markdown": - if err := writeReleaseNotesMarkdown(&buf, result, lang); err != nil { - return "", err - } - case "table": - if err := writeReleaseNotesTable(&buf, result, lang); err != nil { - return "", err - } - default: - return "", fmt.Errorf("unsupported workflow output format %q", format) - } - return buf.String(), nil -} - func normalizeFormat(format string) string { format = strings.ToLower(strings.TrimSpace(format)) if format == "" { @@ -204,13 +197,22 @@ func writeRepoReportTable(w io.Writer, result RepoReportResult, lang string) err return tw.Flush() } -func writeReleaseNotesTable(w io.Writer, result ReleaseNotesResult, lang string) error { +func writeStaleTable(w io.Writer, report StaleReport) error { tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) - if _, err := fmt.Fprintln(tw, "SECTION\tCOUNT"); err != nil { + if _, err := fmt.Fprintln(tw, "TYPE\tNUMBER\tBUCKET\tAGE_DAYS\tLAST_ACTIVITY\tCOMMENTS\tACTION\tTITLE"); err != nil { return err } - for _, section := range result.Sections { - if _, err := fmt.Fprintf(tw, "%s\t%d\n", releaseNotesSectionTitle(lang, section.Key), len(section.Items)); err != nil { + for _, item := range report.Items { + if _, err := fmt.Fprintf(tw, "%s\t#%d\t%s\t%d\t%s\t%d\t%s\t%s\n", + item.Kind, + item.Number, + item.Bucket, + item.AgeDays, + formatStaleActivity(item.LastActivityAt), + item.CommentsCount, + truncateTableText(item.SuggestedAction, 52), + truncateTableText(item.Title, 64), + ); err != nil { return err } } @@ -320,108 +322,85 @@ func writeRepoReportMarkdown(w io.Writer, result RepoReportResult, lang string) return writeRepoReportMarkdownList(w, repoReportText(lang, "reasoning"), result.Reasoning, repoReportText(lang, "not_available")) } -func writeReleaseNotesMarkdown(w io.Writer, result ReleaseNotesResult, lang string) error { +func writeStaleMarkdown(w io.Writer, report StaleReport, lang string) error { lang = normalizeLang(lang) - if _, err := fmt.Fprintf(w, "# %s: %s\n\n", releaseNotesRenderText(lang, "title"), result.Version); err != nil { + if _, err := fmt.Fprintf(w, "# %s\n\n", staleText(lang, "title")); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "## %s\n\n", staleText(lang, "overview")); err != nil { return err } lines := []string{ - fmt.Sprintf("- Repository: `%s`", result.Repository), - fmt.Sprintf("- Range: `%s...%s`", result.FromRef, result.ToRef), - fmt.Sprintf("- Commits: `%d`", result.CommitsCount), - fmt.Sprintf("- Pull requests: `%d`", result.PullRequestsCount), - fmt.Sprintf("- Source: `%s`", result.Source), + fmt.Sprintf("- Repository: `%s`", report.Repository), + fmt.Sprintf("- Source: `%s`", report.Source), + fmt.Sprintf("- State: `%s`", report.State), + fmt.Sprintf("- Stale threshold: `%d` days", report.StaleDays), + fmt.Sprintf("- Scanned: `%d`", report.ScannedTotal), + fmt.Sprintf("- Flagged: `%d`", report.FlaggedTotal), + fmt.Sprintf("- Shown: `%d`", report.ShownTotal), + fmt.Sprintf("- Omitted: `%d`", report.OmittedTotal), + fmt.Sprintf("- Oldest age: `%d` days", report.OldestAgeDays), + fmt.Sprintf("- Buckets: `fresh=%d`, `watch=%d`, `stale=%d`, `zombie=%d`", report.ByBucket[staleBucketFresh], report.ByBucket[staleBucketWatch], report.ByBucket[staleBucketStale], report.ByBucket[staleBucketZombie]), } for _, line := range lines { if _, err := fmt.Fprintln(w, line); err != nil { return err } } - if _, err := fmt.Fprintf(w, "\n## %s\n\n- %s\n", releaseNotesRenderText(lang, "highlights"), result.Summary); err != nil { + + if _, err := fmt.Fprintf(w, "\n## %s\n\n", staleText(lang, "items")); err != nil { return err } - for _, section := range result.Sections { - if len(section.Items) == 0 { - continue - } - if _, err := fmt.Fprintf(w, "\n## %s\n\n", releaseNotesSectionTitle(lang, section.Key)); err != nil { + if len(report.Items) == 0 { + if _, err := fmt.Fprintf(w, "- %s\n", staleText(lang, "no_items")); err != nil { return err } - for _, item := range section.Items { - if _, err := fmt.Fprintf(w, "- %s\n", formatReleaseNotesMarkdownItem(item)); err != nil { + } else { + if _, err := fmt.Fprintln(w, "| Type | Number | Bucket | Age (days) | Last activity | Action | Title |"); err != nil { + return err + } + if _, err := fmt.Fprintln(w, "| --- | ---: | --- | ---: | --- | --- | --- |"); err != nil { + return err + } + for _, item := range report.Items { + if _, err := fmt.Fprintf(w, "| %s | #%d | %s | %d | %s | %s | %s |\n", + item.Kind, + item.Number, + item.Bucket, + item.AgeDays, + formatStaleActivity(item.LastActivityAt), + item.SuggestedAction, + strings.ReplaceAll(item.Title, "|", "\\|"), + ); err != nil { return err } } } - if err := writeReleaseNotesMarkdownList(w, releaseNotesRenderText(lang, "contributors"), result.Contributors, releaseNotesRenderText(lang, "none")); err != nil { - return err - } - return writeReleaseNotesMarkdownList(w, releaseNotesRenderText(lang, "reasoning"), result.Reasoning, releaseNotesRenderText(lang, "none")) -} -func formatReleaseNotesMarkdownItem(item ReleaseNotesItem) string { - parts := []string{item.Title} - if item.PRNumber > 0 { - parts = append(parts, fmt.Sprintf("(#%d)", item.PRNumber)) + if _, err := fmt.Fprintf(w, "\n## %s\n\n", staleText(lang, "recommendations")); err != nil { + return err } - if item.SHA != "" { - parts = append(parts, fmt.Sprintf("(`%s`)", item.SHA)) + for _, recommendation := range report.Recommendations { + if _, err := fmt.Fprintf(w, "- %s\n", recommendation); err != nil { + return err + } } - if item.Author != "" { - parts = append(parts, "by @"+strings.TrimPrefix(item.Author, "@")) - } - return strings.Join(parts, " ") -} -func writeReleaseNotesMarkdownList(w io.Writer, title string, values []string, fallback string) error { - if _, err := fmt.Fprintf(w, "\n## %s\n\n", title); err != nil { + if _, err := fmt.Fprintf(w, "\n## %s\n\n", staleText(lang, "notes")); err != nil { return err } - if len(values) == 0 { - _, err := fmt.Fprintf(w, "- %s\n", fallback) + if len(report.Notes) == 0 { + _, err := fmt.Fprintf(w, "- %s\n", staleText(lang, "no_notes")) return err } - for _, value := range values { - if _, err := fmt.Fprintf(w, "- %s\n", value); err != nil { + for _, note := range report.Notes { + if _, err := fmt.Fprintf(w, "- %s\n", note); err != nil { return err } } return nil } -func releaseNotesRenderText(lang, key string) string { - zh := normalizeLang(lang) == langZH - switch key { - case "title": - if zh { - return "版本说明" - } - return "Release Notes" - case "highlights": - if zh { - return "亮点" - } - return "Highlights" - case "contributors": - if zh { - return "贡献者" - } - return "Contributors" - case "reasoning": - if zh { - return "判断依据" - } - return "Reasoning" - case "none": - if zh { - return "无" - } - return "None" - default: - return key - } -} - func writeCountMapMarkdown(w io.Writer, title string, values map[string]int) error { if len(values) == 0 { return nil @@ -556,3 +535,10 @@ func truncateTableText(value string, max int) string { } return string(runes[:max-3]) + "..." } + +func formatStaleActivity(value time.Time) string { + if value.IsZero() { + return "-" + } + return value.Format("2006-01-02") +} diff --git a/shortcuts/workflow/render_test.go b/shortcuts/workflow/render_test.go index 2725c19..826f0a7 100644 --- a/shortcuts/workflow/render_test.go +++ b/shortcuts/workflow/render_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "strings" "testing" + "time" ) func TestWriteTriageTable(t *testing.T) { @@ -607,46 +608,85 @@ func TestRenderHealthResultJSON(t *testing.T) { } } -func TestRenderReleaseNotesMarkdown(t *testing.T) { - result := AnalyzeReleaseNotes(sampleReleaseNotesInput(), "en") - rendered, err := RenderReleaseNotes(result, "markdown", "en") - if err != nil { - t.Fatalf("RenderReleaseNotes returned error: %v", err) +func TestRenderStaleReportMarkdown(t *testing.T) { + report := StaleReport{ + Repository: "owner/repo", + Source: "local-json", + State: "open", + StaleDays: 30, + ScannedTotal: 3, + FlaggedTotal: 1, + ShownTotal: 1, + ByBucket: map[string]int{ + staleBucketFresh: 2, + staleBucketWatch: 1, + staleBucketStale: 0, + staleBucketZombie: 0, + }, + Items: []StaleItem{{ + Kind: staleKindIssue, + Number: 3, + Title: "stale issue", + Bucket: staleBucketWatch, + AgeDays: 45, + LastActivityAt: time.Date(2026, 5, 1, 0, 0, 0, 0, time.UTC), + SuggestedAction: "Follow up", + }}, + Recommendations: []string{"Follow up"}, } - for _, want := range []string{"Release Notes", "Features", "Bug Fixes", "Contributors"} { - if !strings.Contains(rendered, want) { - t.Fatalf("markdown output missing %q:\n%s", want, rendered) - } + + var buf bytes.Buffer + if err := renderStaleReport(&buf, report, "markdown", "en"); err != nil { + t.Fatalf("renderStaleReport markdown error: %v", err) + } + out := buf.String() + if !strings.Contains(out, "Stale Queue Report") || !strings.Contains(out, "#3") { + t.Fatalf("unexpected markdown output: %s", out) } } -func TestRenderReleaseNotesJSON(t *testing.T) { - result := AnalyzeReleaseNotes(sampleReleaseNotesInput(), "en") - rendered, err := RenderReleaseNotes(result, "json", "en") - if err != nil { - t.Fatalf("RenderReleaseNotes returned error: %v", err) +func TestRenderStaleReportTable(t *testing.T) { + report := StaleReport{ + Items: []StaleItem{{ + Kind: staleKindPR, + Number: 9, + Title: "old pr", + Bucket: staleBucketZombie, + AgeDays: 120, + LastActivityAt: time.Date(2026, 3, 1, 0, 0, 0, 0, time.UTC), + SuggestedAction: "Close or refresh", + }}, } - var parsed ReleaseNotesResult - if err := json.Unmarshal([]byte(rendered), &parsed); err != nil { - t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, rendered) + + var buf bytes.Buffer + if err := renderStaleReport(&buf, report, "table", "en"); err != nil { + t.Fatalf("renderStaleReport table error: %v", err) + } + if !strings.Contains(buf.String(), "TYPE") || !strings.Contains(buf.String(), "#9") { + t.Fatalf("unexpected table output: %s", buf.String()) } } -func TestRenderReleaseNotesTable(t *testing.T) { - result := AnalyzeReleaseNotes(sampleReleaseNotesInput(), "en") - rendered, err := RenderReleaseNotes(result, "table", "en") - if err != nil { - t.Fatalf("RenderReleaseNotes returned error: %v", err) +func TestRenderStaleReportJSON(t *testing.T) { + report := StaleReport{ + Repository: "owner/repo", + Items: []StaleItem{{ + Kind: staleKindIssue, + Number: 1, + Title: "issue", + }}, } - if !strings.Contains(rendered, "SECTION") || !strings.Contains(rendered, "COUNT") { - t.Fatalf("table output = %q, want headers", rendered) - } -} -func TestRenderReleaseNotesUnknownFormat(t *testing.T) { - _, err := RenderReleaseNotes(AnalyzeReleaseNotes(sampleReleaseNotesInput(), "en"), "xml", "en") - if err == nil { - t.Fatal("RenderReleaseNotes returned nil error for unknown format") + var buf bytes.Buffer + if err := renderStaleReport(&buf, report, "json", "en"); err != nil { + t.Fatalf("renderStaleReport json error: %v", err) + } + var parsed StaleReport + if err := json.Unmarshal(buf.Bytes(), &parsed); err != nil { + t.Fatalf("json.Unmarshal error: %v", err) + } + if parsed.Repository != "owner/repo" || len(parsed.Items) != 1 { + t.Fatalf("parsed = %+v", parsed) } } diff --git a/shortcuts/workflow/stale.go b/shortcuts/workflow/stale.go new file mode 100644 index 0000000..0a4c90a --- /dev/null +++ b/shortcuts/workflow/stale.go @@ -0,0 +1,672 @@ +package workflow + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strings" + "time" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +const ( + staleKindIssue = "issue" + staleKindPR = "pull_request" +) + +const ( + staleBucketFresh = "fresh" + staleBucketWatch = "watch" + staleBucketStale = "stale" + staleBucketZombie = "zombie" +) + +type StaleInput struct { + Repository string `json:"repository"` + Source string `json:"source"` + Issues []IssueInput `json:"issues,omitempty"` + PullRequests []StalePullRequestInput `json:"pull_requests,omitempty"` +} + +type StalePullRequestInput struct { + Number int `json:"number"` + Title string `json:"title"` + Author string `json:"author"` + State string `json:"state"` + URL string `json:"url"` + BaseBranch string `json:"base_branch,omitempty"` + HeadBranch string `json:"head_branch,omitempty"` + CreatedAt time.Time `json:"created_at,omitempty"` + UpdatedAt time.Time `json:"updated_at,omitempty"` + LastActivityAt time.Time `json:"last_activity_at,omitempty"` + CommentsCount int `json:"comments_count"` + ActivitySource string `json:"activity_source,omitempty"` +} + +type StaleReport struct { + Repository string `json:"repository"` + Source string `json:"source"` + State string `json:"state"` + StaleDays int `json:"stale_days"` + Top int `json:"top"` + ScannedTotal int `json:"scanned_total"` + IssuesScanned int `json:"issues_scanned"` + PRsScanned int `json:"prs_scanned"` + FlaggedTotal int `json:"flagged_total"` + IssuesFlagged int `json:"issues_flagged"` + PRsFlagged int `json:"prs_flagged"` + ShownTotal int `json:"shown_total"` + OmittedTotal int `json:"omitted_total"` + OldestAgeDays int `json:"oldest_age_days"` + ByBucket map[string]int `json:"by_bucket"` + Items []StaleItem `json:"items"` + Recommendations []string `json:"recommendations"` + Notes []string `json:"notes,omitempty"` +} + +type StaleItem struct { + Kind string `json:"kind"` + Number int `json:"number"` + Title string `json:"title"` + State string `json:"state"` + Author string `json:"author,omitempty"` + URL string `json:"url,omitempty"` + Labels []string `json:"labels,omitempty"` + BaseBranch string `json:"base_branch,omitempty"` + HeadBranch string `json:"head_branch,omitempty"` + CommentsCount int `json:"comments_count"` + LastActivityAt time.Time `json:"last_activity_at,omitempty"` + ActivitySource string `json:"activity_source,omitempty"` + AgeDays int `json:"age_days"` + Bucket string `json:"bucket"` + SuggestedAction string `json:"suggested_action"` + SuggestedComment string `json:"suggested_comment"` + Reasoning []string `json:"reasoning,omitempty"` +} + +type staleScanOptions struct { + State string + StaleDays int + Top int + IncludeIssues bool + IncludePRs bool +} + +type staleCandidate struct { + Kind string + Number int + Title string + State string + Author string + URL string + Labels []string + BaseBranch string + HeadBranch string + CommentsCount int + LastActivityAt time.Time + ActivitySource string +} + +func newStaleShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "stale", + Description: "Scan stale issues and pull requests with read-only workflow rules", + Flags: []common.Flag{ + {Name: "from", Usage: "Read stale scan input from a JSON file"}, + {Name: "state", Short: "s", Usage: "Filter state for local or remote scan", Default: "open"}, + {Name: "stale-days", Usage: "Days before an item enters the watch bucket", Default: "30"}, + {Name: "issue-limit", Usage: "Maximum issues to fetch in remote mode", Default: "20"}, + {Name: "pr-limit", Usage: "Maximum pull requests to fetch in remote mode", Default: "20"}, + {Name: "top", Usage: "Maximum flagged items to show; 0 shows all", Default: "20"}, + {Name: "include-issues", Usage: "Include issues in the scan", Bool: true, Default: "true"}, + {Name: "include-prs", Usage: "Include pull requests in the scan", Bool: true, Default: "true"}, + {Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN}, + }, + Run: runStale, + } +} + +func runStale(ctx *common.RuntimeContext) error { + lang := normalizeLang(ctx.Arg("lang")) + input, notes, opts, err := collectStaleInput(ctx) + if err != nil { + return err + } + + report := AnalyzeStale(input, notes, opts, lang) + format := ctx.Format + if strings.TrimSpace(cmdutil.Format) == "" { + format = "markdown" + } + return renderStaleReport(os.Stdout, report, format, lang) +} + +func collectStaleInput(ctx *common.RuntimeContext) (StaleInput, []string, staleScanOptions, error) { + opts := staleScanOptions{ + State: strings.TrimSpace(ctx.Arg("state")), + StaleDays: 30, + Top: 20, + IncludeIssues: true, + IncludePRs: true, + } + if value := strings.TrimSpace(ctx.Arg("include-issues")); value != "" { + opts.IncludeIssues = parseBoolArg(value) + } + if value := strings.TrimSpace(ctx.Arg("include-prs")); value != "" { + opts.IncludePRs = parseBoolArg(value) + } + if opts.State == "" { + opts.State = "open" + } + if !opts.IncludeIssues && !opts.IncludePRs { + return StaleInput{}, nil, staleScanOptions{}, fmt.Errorf("workflow +stale requires at least one of --include-issues or --include-prs") + } + + staleDays, err := parseIntArg(ctx.Arg("stale-days"), 30, "stale-days") + if err != nil { + return StaleInput{}, nil, staleScanOptions{}, err + } + top, err := parseIntArg(ctx.Arg("top"), 20, "top") + if err != nil { + return StaleInput{}, nil, staleScanOptions{}, err + } + opts.StaleDays = staleDays + opts.Top = top + + if path := strings.TrimSpace(ctx.Arg("from")); path != "" { + input, err := readStaleInput(path) + if err != nil { + return StaleInput{}, nil, staleScanOptions{}, err + } + if strings.TrimSpace(input.Source) == "" { + input.Source = "local-json" + } + input.Repository = repositoryFromContext(ctx, input.Repository) + return input, nil, opts, nil + } + + issueLimit, err := parseIntArg(ctx.Arg("issue-limit"), 20, "issue-limit") + if err != nil { + return StaleInput{}, nil, staleScanOptions{}, err + } + prLimit, err := parseIntArg(ctx.Arg("pr-limit"), 20, "pr-limit") + if err != nil { + return StaleInput{}, nil, staleScanOptions{}, err + } + + input, notes, err := FetchStaleInput(ctx, StaleFetchOptions{ + State: opts.State, + StaleDays: opts.StaleDays, + IssueLimit: issueLimit, + PRLimit: prLimit, + IncludeIssues: opts.IncludeIssues, + IncludePRs: opts.IncludePRs, + }) + if err != nil { + return StaleInput{}, nil, staleScanOptions{}, err + } + return input, notes, opts, nil +} + +func readStaleInput(path string) (StaleInput, error) { + data, err := os.ReadFile(path) + if err != nil { + return StaleInput{}, fmt.Errorf("read stale input: %w", err) + } + + var input StaleInput + if err := json.Unmarshal(data, &input); err != nil { + return StaleInput{}, fmt.Errorf("parse stale input: %w", err) + } + if len(input.Issues) == 0 && len(input.PullRequests) == 0 { + return StaleInput{}, fmt.Errorf("parse stale input: expected issues or pull_requests in the root object") + } + return input, nil +} + +func AnalyzeStale(input StaleInput, notes []string, opts staleScanOptions, lang string) StaleReport { + lang = normalizeLang(lang) + report := StaleReport{ + Repository: input.Repository, + Source: firstNonEmpty(strings.TrimSpace(input.Source), "local"), + State: opts.State, + StaleDays: maxInt(opts.StaleDays, 1), + Top: opts.Top, + ByBucket: map[string]int{ + staleBucketFresh: 0, + staleBucketWatch: 0, + staleBucketStale: 0, + staleBucketZombie: 0, + }, + } + + candidates := make([]staleCandidate, 0, len(input.Issues)+len(input.PullRequests)) + if opts.IncludeIssues { + for _, issue := range input.Issues { + candidate, ok := staleCandidateFromIssue(issue) + if !ok || !matchesState(candidate.State, opts.State) { + continue + } + candidates = append(candidates, candidate) + report.IssuesScanned++ + } + } + if opts.IncludePRs { + for _, pr := range input.PullRequests { + candidate, ok := staleCandidateFromPR(pr) + if !ok || !matchesState(candidate.State, opts.State) { + continue + } + candidates = append(candidates, candidate) + report.PRsScanned++ + } + } + report.ScannedTotal = len(candidates) + + flagged := make([]StaleItem, 0, len(candidates)) + for _, candidate := range candidates { + ageDays := apiAgeInDays(candidate.LastActivityAt) + if ageDays > report.OldestAgeDays { + report.OldestAgeDays = ageDays + } + bucket := staleBucketForAge(ageDays, report.StaleDays) + report.ByBucket[bucket]++ + if bucket == staleBucketFresh { + continue + } + + item := buildStaleItem(candidate, bucket, ageDays, lang) + flagged = append(flagged, item) + report.FlaggedTotal++ + switch candidate.Kind { + case staleKindIssue: + report.IssuesFlagged++ + case staleKindPR: + report.PRsFlagged++ + } + } + + sort.Slice(flagged, func(i, j int) bool { + if staleBucketRank(flagged[i].Bucket) != staleBucketRank(flagged[j].Bucket) { + return staleBucketRank(flagged[i].Bucket) > staleBucketRank(flagged[j].Bucket) + } + if flagged[i].AgeDays != flagged[j].AgeDays { + return flagged[i].AgeDays > flagged[j].AgeDays + } + if staleKindRank(flagged[i].Kind) != staleKindRank(flagged[j].Kind) { + return staleKindRank(flagged[i].Kind) < staleKindRank(flagged[j].Kind) + } + return flagged[i].Number < flagged[j].Number + }) + + report.ShownTotal = len(flagged) + if report.Top > 0 && report.ShownTotal > report.Top { + report.OmittedTotal = report.ShownTotal - report.Top + report.ShownTotal = report.Top + report.Items = append([]StaleItem(nil), flagged[:report.Top]...) + } else { + report.Items = flagged + } + report.Recommendations = buildStaleRecommendations(report, lang) + report.Notes = uniqueStrings(notes) + if report.Repository == "" { + report.Repository = "local" + } + return report +} + +func staleCandidateFromIssue(issue IssueInput) (staleCandidate, bool) { + title := strings.TrimSpace(issue.Title) + if title == "" { + return staleCandidate{}, false + } + lastActivity := apiLatestTime(issue.UpdatedAt, issue.CreatedAt) + source := "" + if !issue.UpdatedAt.IsZero() { + source = "updated_at" + } else if !issue.CreatedAt.IsZero() { + source = "created_at" + } + return staleCandidate{ + Kind: staleKindIssue, + Number: issue.Number, + Title: title, + State: issue.State, + Author: issue.Author, + URL: issue.URL, + Labels: append([]string(nil), issue.Labels...), + CommentsCount: issue.CommentsCount, + LastActivityAt: lastActivity, + ActivitySource: source, + }, true +} + +func staleCandidateFromPR(pr StalePullRequestInput) (staleCandidate, bool) { + title := strings.TrimSpace(pr.Title) + if title == "" { + return staleCandidate{}, false + } + lastActivity := apiLatestTime(pr.LastActivityAt, pr.UpdatedAt, pr.CreatedAt) + source := strings.TrimSpace(pr.ActivitySource) + if source == "" { + switch { + case !pr.LastActivityAt.IsZero(): + source = "last_activity_at" + case !pr.UpdatedAt.IsZero(): + source = "updated_at" + case !pr.CreatedAt.IsZero(): + source = "created_at" + } + } + return staleCandidate{ + Kind: staleKindPR, + Number: pr.Number, + Title: title, + State: pr.State, + Author: pr.Author, + URL: pr.URL, + BaseBranch: pr.BaseBranch, + HeadBranch: pr.HeadBranch, + CommentsCount: pr.CommentsCount, + LastActivityAt: lastActivity, + ActivitySource: source, + }, true +} + +func buildStaleItem(candidate staleCandidate, bucket string, ageDays int, lang string) StaleItem { + return StaleItem{ + Kind: candidate.Kind, + Number: candidate.Number, + Title: candidate.Title, + State: firstNonEmpty(strings.TrimSpace(candidate.State), "open"), + Author: candidate.Author, + URL: candidate.URL, + Labels: append([]string(nil), candidate.Labels...), + BaseBranch: candidate.BaseBranch, + HeadBranch: candidate.HeadBranch, + CommentsCount: candidate.CommentsCount, + LastActivityAt: candidate.LastActivityAt, + ActivitySource: candidate.ActivitySource, + AgeDays: ageDays, + Bucket: bucket, + SuggestedAction: staleActionText(lang, candidate.Kind, bucket), + SuggestedComment: staleCommentText(lang, candidate.Kind, bucket), + Reasoning: buildStaleReasoning(candidate, bucket, ageDays, lang), + } +} + +func buildStaleReasoning(candidate staleCandidate, bucket string, ageDays int, lang string) []string { + reasons := []string{ + fmt.Sprintf(staleText(lang, "reason_age"), ageDays), + fmt.Sprintf(staleText(lang, "reason_bucket"), bucket), + } + if candidate.CommentsCount > 0 { + reasons = append(reasons, fmt.Sprintf(staleText(lang, "reason_comments"), candidate.CommentsCount)) + } + if strings.TrimSpace(candidate.ActivitySource) != "" { + reasons = append(reasons, fmt.Sprintf(staleText(lang, "reason_source"), candidate.ActivitySource)) + } + if candidate.Kind == staleKindPR && candidate.HeadBranch != "" { + reasons = append(reasons, fmt.Sprintf(staleText(lang, "reason_branches"), firstNonEmpty(candidate.BaseBranch, "?"), candidate.HeadBranch)) + } + if candidate.Kind == staleKindIssue && len(candidate.Labels) > 0 { + reasons = append(reasons, fmt.Sprintf(staleText(lang, "reason_labels"), strings.Join(candidate.Labels, ", "))) + } + return uniqueStrings(reasons) +} + +func buildStaleRecommendations(report StaleReport, lang string) []string { + recommendations := []string{} + if report.ByBucket[staleBucketZombie] > 0 { + recommendations = append(recommendations, staleText(lang, "rec_zombie")) + } + if report.PRsFlagged > 0 { + recommendations = append(recommendations, staleText(lang, "rec_prs")) + } + if report.IssuesFlagged > 0 { + recommendations = append(recommendations, staleText(lang, "rec_issues")) + } + if report.OmittedTotal > 0 { + recommendations = append(recommendations, fmt.Sprintf(staleText(lang, "rec_omitted"), report.OmittedTotal)) + } + if len(recommendations) == 0 { + recommendations = append(recommendations, staleText(lang, "rec_clean")) + } + return uniqueStrings(recommendations) +} + +func staleBucketForAge(ageDays, staleDays int) string { + if staleDays <= 0 { + staleDays = 30 + } + switch { + case ageDays < staleDays: + return staleBucketFresh + case ageDays < staleDays*2: + return staleBucketWatch + case ageDays < staleDays*4: + return staleBucketStale + default: + return staleBucketZombie + } +} + +func staleBucketRank(bucket string) int { + switch bucket { + case staleBucketZombie: + return 4 + case staleBucketStale: + return 3 + case staleBucketWatch: + return 2 + default: + return 1 + } +} + +func staleKindRank(kind string) int { + switch kind { + case staleKindPR: + return 0 + case staleKindIssue: + return 1 + default: + return 2 + } +} + +func matchesState(itemState, requested string) bool { + requested = strings.TrimSpace(requested) + if requested == "" || requested == "all" { + return true + } + itemState = strings.TrimSpace(itemState) + if itemState == "" { + return true + } + return strings.EqualFold(itemState, requested) +} + +func staleActionText(lang, kind, bucket string) string { + lang = normalizeLang(lang) + switch bucket { + case staleBucketWatch: + if kind == staleKindPR { + if lang == langZH { + return "确认贡献者是否仍在推进,并补齐待合并阻塞点。" + } + return "Confirm whether the contributor is still active and list the merge blockers." + } + if lang == langZH { + return "补一个维护者跟进,明确下一步处理时间点。" + } + return "Leave a maintainer follow-up and define the next handling checkpoint." + case staleBucketStale: + if kind == staleKindPR { + if lang == langZH { + return "要求同步最新基线、重新验证测试,并确认是否继续维护。" + } + return "Request a rebase, rerun validation, and confirm whether the PR is still maintained." + } + if lang == langZH { + return "要求补充进展或关闭条件,避免长期悬空。" + } + return "Ask for an update or a closing condition so the item does not stay open indefinitely." + default: + if kind == staleKindPR { + if lang == langZH { + return "优先清理长期无响应 PR,必要时建议关闭或拆分后重提。" + } + return "Prioritize long-idle PR cleanup and consider closing or asking for a smaller resubmission." + } + if lang == langZH { + return "优先处理长期无人推进的 Issue,关闭前给出最后一次确认。" + } + return "Prioritize long-idle issue cleanup and give one final confirmation request before closing." + } +} + +func staleCommentText(lang, kind, bucket string) string { + lang = normalizeLang(lang) + if lang == langZH { + switch bucket { + case staleBucketWatch: + if kind == staleKindPR { + return "这条 PR 已经一段时间没有新的推进了。请确认当前是否还会继续维护,并说明还缺哪些合并前条件。" + } + return "这个条目已经一段时间没有新的进展了。请补充当前状态或下一步计划,方便维护者继续跟进。" + case staleBucketStale: + if kind == staleKindPR { + return "这条 PR 已长期没有活动。请确认是否仍计划继续推进,并在回复中说明需要维护者协助的阻塞点。" + } + return "这个条目已长期没有活动。若问题仍然存在,请补充最新复现或处理进展;否则维护者可能会考虑关闭。" + default: + if kind == staleKindPR { + return "这条 PR 已非常久没有活动。若近期没有恢复推进计划,建议关闭后在准备充分时重新提交。" + } + return "这个条目已非常久没有活动。若近期没有新的信息或推进计划,维护者可考虑在说明原因后关闭。" + } + } + + switch bucket { + case staleBucketWatch: + if kind == staleKindPR { + return "This pull request has been idle for a while. Please confirm whether it is still active and list any remaining merge blockers." + } + return "This item has been quiet for a while. Please share the current status or next step so maintainers can continue triage." + case staleBucketStale: + if kind == staleKindPR { + return "This pull request has been inactive for a long time. Please confirm whether you still plan to continue it and mention any blocker that needs maintainer help." + } + return "This item has been inactive for a long time. If it still needs work, please add the latest reproduction or progress details; otherwise maintainers may consider closing it." + default: + if kind == staleKindPR { + return "This pull request has been inactive for a very long time. If there is no plan to continue it soon, please consider closing it and reopening with a smaller refreshed change later." + } + return "This item has been inactive for a very long time. If there is no new information or plan to continue it soon, maintainers may consider closing it with a short explanation." + } +} + +func staleText(lang, key string) string { + lang = normalizeLang(lang) + if lang == langZH { + switch key { + case "title": + return "陈旧队列报告" + case "overview": + return "概览" + case "items": + return "待处理条目" + case "recommendations": + return "建议动作" + case "notes": + return "备注" + case "no_items": + return "没有达到陈旧阈值的条目。" + case "no_notes": + return "无额外备注。" + case "reason_age": + return "距离上次活动约 %d 天" + case "reason_bucket": + return "分桶:%s" + case "reason_comments": + return "评论/审查记录:%d" + case "reason_source": + return "活动时间来源:%s" + case "reason_branches": + return "目标分支:%s,来源分支:%s" + case "reason_labels": + return "标签:%s" + case "rec_zombie": + return "优先清理 zombie 桶中的条目,避免社区队列持续积压。" + case "rec_prs": + return "对陈旧 PR 优先给出继续推进或关闭建议,减少贡献者等待时间。" + case "rec_issues": + return "对陈旧 Issue 明确下一步动作、补充条件或关闭条件。" + case "rec_omitted": + return "当前结果省略了 %d 条已命中的陈旧条目,必要时可增大 --top。" + case "rec_clean": + return "当前扫描范围内没有命中陈旧阈值的条目,可维持现有跟进节奏。" + } + } + + switch key { + case "title": + return "Stale Queue Report" + case "overview": + return "Overview" + case "items": + return "Flagged Items" + case "recommendations": + return "Recommendations" + case "notes": + return "Notes" + case "no_items": + return "No items crossed the stale threshold." + case "no_notes": + return "No extra notes." + case "reason_age": + return "about %d days since the last activity" + case "reason_bucket": + return "bucket: %s" + case "reason_comments": + return "comments/reviews: %d" + case "reason_source": + return "activity source: %s" + case "reason_branches": + return "base branch: %s, head branch: %s" + case "reason_labels": + return "labels: %s" + case "rec_zombie": + return "Prioritize zombie-bucket cleanup so the community queue does not keep growing." + case "rec_prs": + return "Give stale PRs a clear continue-or-close decision to reduce contributor wait time." + case "rec_issues": + return "Define the next action, missing evidence, or closing condition for stale issues." + case "rec_omitted": + return "The report omitted %d flagged items; increase --top when maintainers need the full queue." + case "rec_clean": + return "No items crossed the stale threshold in the current scan scope." + default: + return key + } +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func maxInt(value, minValue int) int { + if value < minValue { + return minValue + } + return value +} diff --git a/shortcuts/workflow/stale_fetch.go b/shortcuts/workflow/stale_fetch.go new file mode 100644 index 0000000..edbd192 --- /dev/null +++ b/shortcuts/workflow/stale_fetch.go @@ -0,0 +1,278 @@ +package workflow + +import ( + "fmt" + "net/url" + "strings" + "time" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type StaleFetchOptions struct { + Owner string + Repo string + State string + StaleDays int + IssueLimit int + PRLimit int + IncludeIssues bool + IncludePRs bool +} + +type stalePullRequestProbe struct { + Input StalePullRequestInput + IssueID int +} + +func FetchStaleInput(ctx *common.RuntimeContext, opts StaleFetchOptions) (StaleInput, []string, error) { + owner, repo, err := resolveFetchRepo(ctx, opts.Owner, opts.Repo) + if err != nil { + return StaleInput{}, nil, fmt.Errorf("workflow +stale remote mode requires --owner and --repo or a Git remote: %w", err) + } + if !opts.IncludeIssues && !opts.IncludePRs { + return StaleInput{}, nil, fmt.Errorf("workflow +stale requires at least one of --include-issues or --include-prs") + } + + state := strings.TrimSpace(opts.State) + if state == "" { + state = "open" + } + input := StaleInput{ + Repository: fmt.Sprintf("%s/%s", owner, repo), + Source: "remote-read-only-fetch", + } + notes := []string{} + successes := 0 + + if opts.IncludeIssues { + issues, err := fetchStaleIssueInputs(ctx, owner, repo, state, opts.IssueLimit) + if err != nil { + notes = append(notes, fmt.Sprintf("issue fetch failed: %v", err)) + } else { + input.Issues = issues + successes++ + } + } + + if opts.IncludePRs { + prs, prNotes, err := fetchStalePullRequestInputs(ctx, owner, repo, state, opts.PRLimit) + notes = append(notes, prNotes...) + if err != nil { + notes = append(notes, fmt.Sprintf("pull request fetch failed: %v", err)) + } else { + input.PullRequests = prs + successes++ + } + } + + if successes == 0 { + return StaleInput{}, uniqueStrings(notes), fmt.Errorf("fetch stale input: all enabled sections failed") + } + return input, uniqueStrings(notes), nil +} + +func fetchStaleIssueInputs(ctx *common.RuntimeContext, owner, repo, state string, limit int) ([]IssueInput, error) { + if limit <= 0 { + limit = 20 + } + + issues := make([]IssueInput, 0, limit) + pageSize := minInt(limit, 100) + for page := 1; len(issues) < limit; page++ { + query := url.Values{} + query.Set("state", state) + query.Set("page", fmt.Sprintf("%d", page)) + query.Set("limit", fmt.Sprintf("%d", pageSize)) + + env, err := ctx.CallAPIWithQuery("GET", workflowRepoPath(owner, repo)+"/issues", query) + if err != nil { + return nil, err + } + items := apiList(env.Data) + if len(items) == 0 { + break + } + + rawCount := len(items) + for _, raw := range items { + issue, ok := normalizeIssueItem(raw) + if !ok { + continue + } + issues = append(issues, issue) + if len(issues) >= limit { + break + } + } + if rawCount < pageSize { + break + } + } + return issues, nil +} + +func fetchStalePullRequestInputs(ctx *common.RuntimeContext, owner, repo, state string, limit int) ([]StalePullRequestInput, []string, error) { + if limit <= 0 { + limit = 20 + } + + probes := make([]stalePullRequestProbe, 0, limit) + pageSize := minInt(limit, 50) + for page := 1; len(probes) < limit; page++ { + query := url.Values{} + query.Set("state", state) + query.Set("page", fmt.Sprintf("%d", page)) + query.Set("limit", fmt.Sprintf("%d", pageSize)) + + env, err := ctx.CallAPIWithQuery("GET", workflowRepoPath(owner, repo)+"/pulls", query) + if err != nil { + return nil, nil, err + } + items := apiList(env.Data) + if len(items) == 0 { + break + } + + rawCount := len(items) + for _, raw := range items { + probe, ok := normalizeStalePullRequest(raw) + if !ok { + continue + } + probes = append(probes, probe) + if len(probes) >= limit { + break + } + } + if rawCount < pageSize { + break + } + } + + notes := []string{} + prs := make([]StalePullRequestInput, 0, len(probes)) + for _, probe := range probes { + pr := probe.Input + if probe.IssueID > 0 { + latestJournal, err := fetchIssueJournalActivity(ctx, probe.IssueID) + if err != nil { + notes = append(notes, fmt.Sprintf("PR #%d journal fallback failed: %v", pr.Number, err)) + } else if latestJournal.After(pr.LastActivityAt) { + pr.LastActivityAt = latestJournal + pr.ActivitySource = "issue_journal" + } + } else if pr.ActivitySource == "created_at" || pr.ActivitySource == "pr_created_unix" { + notes = append(notes, fmt.Sprintf("PR #%d has no issue journal id; stale age fell back to creation time", pr.Number)) + } + prs = append(prs, pr) + } + + return prs, uniqueStrings(notes), nil +} + +func normalizeStalePullRequest(raw interface{}) (stalePullRequestProbe, bool) { + item, ok := raw.(map[string]interface{}) + if !ok { + return stalePullRequestProbe{}, false + } + + number := firstPRInt(item, "number", "index", "iid", "pull_request_number") + title := firstPRString(item, "title", "subject") + if number == 0 && strings.TrimSpace(title) == "" { + return stalePullRequestProbe{}, false + } + + issueObject, _ := item["issue"].(map[string]interface{}) + author := firstPRAuthor(item) + if author == "" && issueObject != nil { + author = apiAuthor(issueObject["author"]) + } + comments := firstPRInt(item, "journals_count") + if comments == 0 && issueObject != nil { + comments = firstPRInt(issueObject, "journals_count", "comment_journals_count") + } + + updatedAt := firstPRTime(item, "updated_at", "updated", "last_updated_at") + createdAt := firstPRTime(item, "created_at", "pr_created_unix") + lastActivity := apiLatestTime(updatedAt, createdAt) + activitySource := "" + switch { + case !updatedAt.IsZero(): + activitySource = "updated_at" + case !createdAt.IsZero(): + if item["pr_created_unix"] != nil { + activitySource = "pr_created_unix" + } else { + activitySource = "created_at" + } + } + + return stalePullRequestProbe{ + Input: StalePullRequestInput{ + Number: number, + Title: title, + Author: author, + State: firstNonEmpty(firstPRString(item, "status", "state"), "open"), + URL: firstPRString(item, "html_url", "url", "web_url"), + BaseBranch: firstPRBranch(item, "base_branch", "target_branch", "base"), + HeadBranch: firstPRBranch(item, "head_branch", "source_branch", "head"), + CreatedAt: createdAt, + UpdatedAt: updatedAt, + LastActivityAt: lastActivity, + CommentsCount: comments, + ActivitySource: activitySource, + }, + IssueID: firstPRInt(issueObject, "id"), + }, true +} + +func fetchIssueJournalActivity(ctx *common.RuntimeContext, issueID int) (time.Time, error) { + if issueID <= 0 { + return time.Time{}, fmt.Errorf("issue id is required") + } + + latest := time.Time{} + for page := 1; ; page++ { + query := url.Values{} + query.Set("page", fmt.Sprintf("%d", page)) + query.Set("limit", "100") + + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/issues/%d/journals", issueID), query) + if err != nil { + return time.Time{}, err + } + + items := apiList(env.Data) + if len(items) == 0 { + break + } + + pageCount := 0 + for _, raw := range items { + item, ok := raw.(map[string]interface{}) + if !ok { + continue + } + pageCount++ + latest = apiLatestTime(latest, journalActivityTime(item)) + } + if pageCount < 100 { + break + } + } + return latest, nil +} + +func journalActivityTime(item map[string]interface{}) time.Time { + if item == nil { + return time.Time{} + } + return apiLatestTime( + apiTime(item["format_time"]), + apiTime(item["updated_at"]), + apiTime(item["created_at"]), + apiTime(item["created_on"]), + apiTime(item["updated_on"]), + ) +} diff --git a/shortcuts/workflow/stale_test.go b/shortcuts/workflow/stale_test.go new file mode 100644 index 0000000..38f040a --- /dev/null +++ b/shortcuts/workflow/stale_test.go @@ -0,0 +1,262 @@ +package workflow + +import ( + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestReadStaleInput(t *testing.T) { + path := filepath.Join(t.TempDir(), "stale.json") + writeJSONFixture(t, path, StaleInput{ + Repository: "owner/repo", + Issues: []IssueInput{{ + Number: 1, + Title: "stale issue", + State: "open", + UpdatedAt: time.Now().AddDate(0, 0, -45), + }}, + }) + + input, err := readStaleInput(path) + if err != nil { + t.Fatalf("readStaleInput returned error: %v", err) + } + if input.Repository != "owner/repo" { + t.Fatalf("Repository = %q", input.Repository) + } + if len(input.Issues) != 1 { + t.Fatalf("len(Issues) = %d, want 1", len(input.Issues)) + } +} + +func TestAnalyzeStaleBuildsBucketsAndTopSlice(t *testing.T) { + now := time.Now() + report := AnalyzeStale(StaleInput{ + Repository: "owner/repo", + Source: "local-json", + Issues: []IssueInput{ + {Number: 1, Title: "fresh issue", State: "open", UpdatedAt: now.AddDate(0, 0, -5)}, + {Number: 2, Title: "watch issue", State: "open", UpdatedAt: now.AddDate(0, 0, -35), Labels: []string{"bug"}}, + {Number: 3, Title: "zombie issue", State: "open", UpdatedAt: now.AddDate(0, 0, -140)}, + }, + PullRequests: []StalePullRequestInput{ + {Number: 7, Title: "stale pr", State: "open", LastActivityAt: now.AddDate(0, 0, -75), HeadBranch: "feat/stale", BaseBranch: "master"}, + }, + }, []string{"journal fallback failed"}, staleScanOptions{ + State: "open", + StaleDays: 30, + Top: 2, + IncludeIssues: true, + IncludePRs: true, + }, "en") + + if report.ScannedTotal != 4 { + t.Fatalf("ScannedTotal = %d, want 4", report.ScannedTotal) + } + if report.ByBucket[staleBucketFresh] != 1 || report.ByBucket[staleBucketWatch] != 1 || report.ByBucket[staleBucketStale] != 1 || report.ByBucket[staleBucketZombie] != 1 { + t.Fatalf("ByBucket = %+v", report.ByBucket) + } + if report.FlaggedTotal != 3 { + t.Fatalf("FlaggedTotal = %d, want 3", report.FlaggedTotal) + } + if report.ShownTotal != 2 || len(report.Items) != 2 { + t.Fatalf("ShownTotal = %d len(items) = %d, want 2", report.ShownTotal, len(report.Items)) + } + if report.OmittedTotal != 1 { + t.Fatalf("OmittedTotal = %d, want 1", report.OmittedTotal) + } + if report.Items[0].Bucket != staleBucketZombie { + t.Fatalf("first bucket = %q, want zombie", report.Items[0].Bucket) + } + if len(report.Recommendations) == 0 { + t.Fatal("expected recommendations") + } + if len(report.Notes) != 1 || report.Notes[0] != "journal fallback failed" { + t.Fatalf("Notes = %+v", report.Notes) + } +} + +func TestNormalizeIssueItemSupportsGitLinkFields(t *testing.T) { + issue, ok := normalizeIssueItem(map[string]interface{}{ + "project_issues_index": 11, + "subject": "subject title", + "description": "body", + "status_name": "新增", + "author": map[string]interface{}{"login": "alice"}, + "issue_tags": []map[string]interface{}{{"name": "bug"}}, + "comment_journals_count": 3, + "updated_at": "2026-06-01T10:00:00Z", + }) + if !ok { + t.Fatal("normalizeIssueItem returned ok=false") + } + if issue.Number != 11 { + t.Fatalf("Number = %d, want 11", issue.Number) + } + if issue.State != "新增" { + t.Fatalf("State = %q, want 新增", issue.State) + } + if issue.CommentsCount != 3 { + t.Fatalf("CommentsCount = %d, want 3", issue.CommentsCount) + } + if len(issue.Labels) != 1 || issue.Labels[0] != "bug" { + t.Fatalf("Labels = %+v", issue.Labels) + } +} + +func TestFetchStaleInputRemoteUsesIssueAndPRJournalData(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json": + writeWorkflowJSON(t, w, map[string]interface{}{ + "issues": []map[string]interface{}{ + { + "project_issues_index": 3, + "subject": "old issue", + "description": "body", + "status_name": "open", + "author": map[string]interface{}{"login": "alice"}, + "issue_tags": []map[string]interface{}{{"name": "bug"}}, + "comment_journals_count": 2, + "updated_at": "2026-05-01T00:00:00Z", + }, + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json": + writeWorkflowJSON(t, w, map[string]interface{}{ + "pulls": []map[string]interface{}{ + { + "index": 7, + "title": "feat: old pr", + "status": "open", + "base": "master", + "head": "feat/old-pr", + "pr_created_unix": 1714608000, + "issue": map[string]interface{}{ + "id": 42, + "author": map[string]interface{}{"login": "bob"}, + "journals_count": 4, + }, + }, + }, + }) + case r.Method == "GET" && r.URL.Path == "/issues/42/journals.json": + writeWorkflowJSON(t, w, map[string]interface{}{ + "issue_journals": []map[string]interface{}{ + {"format_time": "2026-06-01 10:00"}, + }, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + input, notes, err := FetchStaleInput(workflowTestContext(server), StaleFetchOptions{ + State: "open", + IssueLimit: 5, + PRLimit: 5, + IncludeIssues: true, + IncludePRs: true, + }) + if err != nil { + t.Fatalf("FetchStaleInput returned error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("notes = %+v, want empty", notes) + } + if len(input.Issues) != 1 || len(input.PullRequests) != 1 { + t.Fatalf("input = %+v", input) + } + if input.Issues[0].State != "open" || input.Issues[0].CommentsCount != 2 { + t.Fatalf("issue = %+v", input.Issues[0]) + } + pr := input.PullRequests[0] + if pr.Number != 7 || pr.Author != "bob" { + t.Fatalf("pr = %+v", pr) + } + if pr.ActivitySource != "issue_journal" { + t.Fatalf("ActivitySource = %q, want issue_journal", pr.ActivitySource) + } + if pr.LastActivityAt.IsZero() { + t.Fatal("expected LastActivityAt from journal fallback") + } +} + +func TestFetchStaleInputAddsJournalFallbackNote(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json": + writeWorkflowJSON(t, w, map[string]interface{}{ + "pulls": []map[string]interface{}{ + { + "index": 9, + "title": "feat: old pr", + "status": "open", + "pr_created_unix": 1714608000, + "issue": map[string]interface{}{ + "id": 51, + }, + }, + }, + }) + case r.Method == "GET" && r.URL.Path == "/issues/51/journals.json": + http.Error(w, "journals unavailable", http.StatusServiceUnavailable) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + input, notes, err := FetchStaleInput(workflowTestContext(server), StaleFetchOptions{ + State: "open", + IssueLimit: 5, + PRLimit: 5, + IncludeIssues: false, + IncludePRs: true, + }) + if err != nil { + t.Fatalf("FetchStaleInput returned error: %v", err) + } + if len(input.PullRequests) != 1 { + t.Fatalf("len(PullRequests) = %d, want 1", len(input.PullRequests)) + } + if len(notes) == 0 || !strings.Contains(notes[0], "PR #9 journal fallback failed") { + t.Fatalf("notes = %+v", notes) + } + if input.PullRequests[0].ActivitySource != "pr_created_unix" { + t.Fatalf("ActivitySource = %q, want pr_created_unix", input.PullRequests[0].ActivitySource) + } +} + +func TestRunStaleFromFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "stale.json") + writeJSONFixture(t, path, StaleInput{ + Repository: "owner/repo", + Issues: []IssueInput{{ + Number: 1, + Title: "stale issue", + State: "open", + UpdatedAt: time.Now().AddDate(0, 0, -45), + }}, + }) + + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: http.DefaultClient, BaseURL: "http://localhost"}, + Format: "markdown", + Args: map[string]string{ + "from": path, + "lang": "en", + }, + } + if err := runStale(ctx); err != nil { + t.Fatalf("runStale returned error: %v", err) + } +} diff --git a/shortcuts/workflow/testdata/stale_input.json b/shortcuts/workflow/testdata/stale_input.json new file mode 100644 index 0000000..5298d19 --- /dev/null +++ b/shortcuts/workflow/testdata/stale_input.json @@ -0,0 +1,27 @@ +{ + "repository": "Gitlink/gitlink-cli", + "source": "local-json", + "issues": [ + { + "number": 18, + "title": "Issue shortcut returns partial metadata for status_name fields", + "state": "open", + "author": "alice", + "labels": ["bug", "api"], + "updated_at": "2026-04-10T09:00:00Z", + "comments_count": 2 + } + ], + "pull_requests": [ + { + "number": 42, + "title": "feat: add stale workflow scanner", + "state": "open", + "author": "bob", + "base_branch": "master", + "head_branch": "mengz/workflow-stale", + "last_activity_at": "2026-03-01T08:00:00Z", + "comments_count": 1 + } + ] +} diff --git a/shortcuts/workflow/triage_fetch.go b/shortcuts/workflow/triage_fetch.go index b801307..9363a73 100644 --- a/shortcuts/workflow/triage_fetch.go +++ b/shortcuts/workflow/triage_fetch.go @@ -81,13 +81,13 @@ func normalizeIssueItem(raw interface{}) (IssueInput, bool) { if id == "" { id = fmt.Sprintf("%d", number) } - state := firstIssueString(item, "state", "status") + state := firstIssueState(item) author := firstIssueString(item, "author", "user", "creator") urlValue := firstIssueString(item, "html_url", "url", "web_url") - labels := firstIssueLabels(item["labels"]) + labels := firstIssueLabels(item["labels"], item["tags"], item["issue_tags"]) createdAt := firstIssueTime(item, "created_at", "created") updatedAt := firstIssueTime(item, "updated_at", "updated", "last_updated_at") - comments := firstIssueInt(item, "comments_count", "comments") + comments := firstIssueInt(item, "comments_count", "comments", "comment_journals_count", "journals_count") return IssueInput{ ID: id, @@ -150,21 +150,48 @@ func firstIssueTime(item map[string]interface{}, keys ...string) time.Time { return time.Time{} } -func firstIssueLabels(value interface{}) []string { - switch labels := value.(type) { - case []interface{}: - out := make([]string, 0, len(labels)) - for _, label := range labels { - if s := apiStringValue(label); s != "" { - out = append(out, s) +func firstIssueState(item map[string]interface{}) string { + if state := firstIssueString(item, "state", "status_name"); state != "" { + return state + } + if raw, ok := item["status"]; ok { + switch value := raw.(type) { + case map[string]interface{}: + for _, key := range []string{"name", "title", "label", "state"} { + if state := apiString(value[key]); state != "" { + return state + } + } + default: + if state := apiString(raw); state != "" { + return state } } - return out - case []string: - return append([]string(nil), labels...) - case string: - return apiStringSlice(labels) - default: - return nil } + return "" +} + +func firstIssueLabels(values ...interface{}) []string { + out := []string{} + for _, value := range values { + switch labels := value.(type) { + case []interface{}: + for _, label := range labels { + if s := apiStringValue(label); s != "" { + out = append(out, s) + } + } + case []map[string]interface{}: + for _, label := range labels { + if s := apiStringValue(label); s != "" { + out = append(out, s) + } + } + case []string: + out = append(out, labels...) + case string: + out = append(out, apiStringSlice(labels)...) + } + } + return uniqueStrings(out) } diff --git a/shortcuts/workflow/workflow.go b/shortcuts/workflow/workflow.go index dd8d1a3..0f0ff9d 100644 --- a/shortcuts/workflow/workflow.go +++ b/shortcuts/workflow/workflow.go @@ -25,7 +25,7 @@ func Shortcuts() []*common.Shortcut { newHealthShortcut(), newPRSummaryShortcut(), newRepoReportShortcut(), - newReleaseNotesShortcut(), + newStaleShortcut(), } } @@ -363,13 +363,6 @@ func parseBoolArg(value string) bool { return err == nil && parsed } -func parseBoolArgDefault(value string, defaultValue bool) bool { - if strings.TrimSpace(value) == "" { - return defaultValue - } - return parseBoolArg(value) -} - func parseIntArg(value string, defaultValue int, name string) (int, error) { value = strings.TrimSpace(value) if value == "" { diff --git a/shortcuts/workflow/workflow_test.go b/shortcuts/workflow/workflow_test.go index b6d66f9..3fa1a67 100644 --- a/shortcuts/workflow/workflow_test.go +++ b/shortcuts/workflow/workflow_test.go @@ -28,14 +28,11 @@ func TestShortcutsExposesWorkflowCommands(t *testing.T) { if !names["pr-summary"] { t.Fatal("Shortcuts missing pr-summary") } - if !names["review-context"] { - t.Fatal("Shortcuts missing review-context") - } if !names["repo-report"] { t.Fatal("Shortcuts missing repo-report") } - if !names["release-notes"] { - t.Fatal("Shortcuts missing release-notes") + if !names["stale"] { + t.Fatal("Shortcuts missing stale") } }