feat(workflow): 新增陈旧队列扫描命令
This commit is contained in:
parent
71ca2bb683
commit
7b86b1affb
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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` 推断最近活动时间,失败时保留条目并在报告里说明是按创建时间估算。
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"]),
|
||||
)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
newHealthShortcut(),
|
||||
newPRSummaryShortcut(),
|
||||
newRepoReportShortcut(),
|
||||
newStaleShortcut(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in New Issue