feat(workflow): 新增陈旧队列扫描命令

This commit is contained in:
Mengz 2026-06-24 17:40:47 +08:00
parent 71ca2bb683
commit 7b86b1affb
13 changed files with 1544 additions and 21 deletions

View File

@ -559,9 +559,11 @@ gitlink-cli profile +contribution --user zhangsan --year 2025
- `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:
@ -634,6 +636,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:
@ -649,6 +657,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.
### Dataset

View File

@ -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` 推断最近活动时间,失败时保留条目并在报告里说明是按创建时间估算。

View File

@ -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,12 +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 +release-notes` is not implemented.
- `workflow +stale` is not implemented.
- Real GitLink API shapes may require follow-up normalization.
## Examples
@ -102,4 +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 +stale --from shortcuts/workflow/testdata/stale_input.json --format markdown
```

View File

@ -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", "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",
}

View File

@ -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) {
@ -183,6 +197,28 @@ func writeRepoReportTable(w io.Writer, result RepoReportResult, lang string) err
return tw.Flush()
}
func writeStaleTable(w io.Writer, report StaleReport) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(tw, "TYPE\tNUMBER\tBUCKET\tAGE_DAYS\tLAST_ACTIVITY\tCOMMENTS\tACTION\tTITLE"); err != nil {
return err
}
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
}
}
return tw.Flush()
}
func writeTriageMarkdown(w io.Writer, report TriageReport) error {
if _, err := fmt.Fprintf(w, "# Issue Triage Report\n\nRepository: `%s`\n\n", report.Repository); err != nil {
return err
@ -286,6 +322,85 @@ func writeRepoReportMarkdown(w io.Writer, result RepoReportResult, lang string)
return writeRepoReportMarkdownList(w, repoReportText(lang, "reasoning"), result.Reasoning, repoReportText(lang, "not_available"))
}
func writeStaleMarkdown(w io.Writer, report StaleReport, lang string) error {
lang = normalizeLang(lang)
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`", 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", staleText(lang, "items")); err != nil {
return err
}
if len(report.Items) == 0 {
if _, err := fmt.Fprintf(w, "- %s\n", staleText(lang, "no_items")); err != nil {
return err
}
} 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 := fmt.Fprintf(w, "\n## %s\n\n", staleText(lang, "recommendations")); err != nil {
return err
}
for _, recommendation := range report.Recommendations {
if _, err := fmt.Fprintf(w, "- %s\n", recommendation); err != nil {
return err
}
}
if _, err := fmt.Fprintf(w, "\n## %s\n\n", staleText(lang, "notes")); err != nil {
return err
}
if len(report.Notes) == 0 {
_, err := fmt.Fprintf(w, "- %s\n", staleText(lang, "no_notes"))
return err
}
for _, note := range report.Notes {
if _, err := fmt.Fprintf(w, "- %s\n", note); err != nil {
return err
}
}
return nil
}
func writeCountMapMarkdown(w io.Writer, title string, values map[string]int) error {
if len(values) == 0 {
return nil
@ -420,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")
}

View File

@ -5,6 +5,7 @@ import (
"encoding/json"
"strings"
"testing"
"time"
)
func TestWriteTriageTable(t *testing.T) {
@ -607,6 +608,88 @@ func TestRenderHealthResultJSON(t *testing.T) {
}
}
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"},
}
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 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 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 TestRenderStaleReportJSON(t *testing.T) {
report := StaleReport{
Repository: "owner/repo",
Items: []StaleItem{{
Kind: staleKindIssue,
Number: 1,
Title: "issue",
}},
}
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)
}
}
func samplePRSummaryResult() PRSummaryResult {
return PRSummaryResult{
Repository: "owner/repo",

672
shortcuts/workflow/stale.go Normal file
View File

@ -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
}

View File

@ -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"]),
)
}

View File

@ -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)
}
}

View File

@ -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
}
]
}

View File

@ -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)
}

View File

@ -25,6 +25,7 @@ func Shortcuts() []*common.Shortcut {
newHealthShortcut(),
newPRSummaryShortcut(),
newRepoReportShortcut(),
newStaleShortcut(),
}
}

View File

@ -31,6 +31,9 @@ func TestShortcutsExposesWorkflowCommands(t *testing.T) {
if !names["repo-report"] {
t.Fatal("Shortcuts missing repo-report")
}
if !names["stale"] {
t.Fatal("Shortcuts missing stale")
}
}
func TestRunTriageWithSingleIssueArgs(t *testing.T) {