forked from Gitlink/gitlink-cli
367 lines
12 KiB
Go
367 lines
12 KiB
Go
package workflow
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"strconv"
|
|
"strings"
|
|
|
|
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
|
)
|
|
|
|
type TriageReport struct {
|
|
Repository string `json:"repository"`
|
|
State string `json:"state"`
|
|
Limit int `json:"limit"`
|
|
DryRun bool `json:"dry_run"`
|
|
Language string `json:"language"`
|
|
Results []TriageResult `json:"results"`
|
|
}
|
|
|
|
func Shortcuts() []*common.Shortcut {
|
|
return []*common.Shortcut{
|
|
newTriageShortcut(),
|
|
newHealthShortcut(),
|
|
newPRSummaryShortcut(),
|
|
newRepoReportShortcut(),
|
|
}
|
|
}
|
|
|
|
func newTriageShortcut() *common.Shortcut {
|
|
return &common.Shortcut{
|
|
Name: "triage",
|
|
Description: "Analyze issues with local workflow triage rules",
|
|
Flags: []common.Flag{
|
|
{Name: "from", Usage: "Read issue inputs from a JSON file. Supports a single issue, an array, or an object with an issues field"},
|
|
{Name: "title", Short: "t", Usage: "Issue title for single-issue local analysis"},
|
|
{Name: "body", Short: "b", Usage: "Issue body for single-issue local analysis"},
|
|
{Name: "number", Short: "n", Usage: "Issue number for single-issue local analysis"},
|
|
{Name: "author", Usage: "Issue author for single-issue local analysis"},
|
|
{Name: "url", Usage: "Issue URL for single-issue local analysis"},
|
|
{Name: "labels", Usage: "Comma-separated labels for single-issue local analysis"},
|
|
{Name: "state", Short: "s", Usage: "Filter or assign issue state", Default: "open"},
|
|
{Name: "page", Short: "p", Usage: "API page number for remote triage", Default: "1"},
|
|
{Name: "limit", Short: "l", Usage: "Maximum issues to analyze", Default: "30"},
|
|
{Name: "since", Usage: "Optional remote issue filter for updated time"},
|
|
{Name: "dry-run", Usage: "Preview workflow recommendations without remote writes", Bool: true, Default: "true"},
|
|
{Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN},
|
|
},
|
|
Run: runTriage,
|
|
}
|
|
}
|
|
|
|
func newHealthShortcut() *common.Shortcut {
|
|
return &common.Shortcut{
|
|
Name: "health",
|
|
Description: "Score repository health with local workflow rules",
|
|
Flags: []common.Flag{
|
|
{Name: "from", Usage: "Read health input from a JSON file"},
|
|
{Name: "repository", Usage: "Repository name, for example owner/repo"},
|
|
{Name: "open-issues", Usage: "Open issue count", Default: "0"},
|
|
{Name: "open-prs", Usage: "Open pull request count", Default: "0"},
|
|
{Name: "stale-issues", Usage: "Stale issue count", Default: "0"},
|
|
{Name: "stale-prs", Usage: "Stale pull request count", Default: "0"},
|
|
{Name: "recent-activity-known", Usage: "Whether recent activity is known", Bool: true, Default: "false"},
|
|
{Name: "recent-activity-days", Usage: "Days since recent activity", Default: "0"},
|
|
{Name: "release-known", Usage: "Whether release status is known", Bool: true, Default: "false"},
|
|
{Name: "has-recent-release", Usage: "Whether a recent release exists", Bool: true, Default: "false"},
|
|
{Name: "ci-known", Usage: "Whether CI status is known", Bool: true, Default: "false"},
|
|
{Name: "ci-passing", Usage: "Whether CI is passing", Bool: true, Default: "false"},
|
|
{Name: "has-readme", Usage: "Whether README exists", Bool: true, Default: "false"},
|
|
{Name: "has-license", Usage: "Whether LICENSE exists", Bool: true, Default: "false"},
|
|
{Name: "has-contributing", Usage: "Whether CONTRIBUTING exists", Bool: true, Default: "false"},
|
|
{Name: "agent-readiness-known", Usage: "Whether agent readiness score is known", Bool: true, Default: "false"},
|
|
{Name: "agent-readiness-score", Usage: "Agent readiness score from 0 to 10", Default: "0"},
|
|
{Name: "stale-days", Usage: "Days before an issue or PR is considered stale", Default: "30"},
|
|
{Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN},
|
|
},
|
|
Run: runHealth,
|
|
}
|
|
}
|
|
|
|
func runTriage(ctx *common.RuntimeContext) error {
|
|
lang := normalizeLang(ctx.Arg("lang"))
|
|
limit, err := parseIntArg(ctx.Arg("limit"), 30, "limit")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
state := ctx.Arg("state")
|
|
if state == "" {
|
|
state = "open"
|
|
}
|
|
|
|
if hasLocalTriageInput(ctx) {
|
|
issues, err := collectIssuesFromArgs(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
filtered := filterIssueInputs(issues, state, limit)
|
|
results := make([]TriageResult, 0, len(filtered))
|
|
for _, issue := range filtered {
|
|
results = append(results, AnalyzeIssue(issue, lang))
|
|
}
|
|
|
|
report := TriageReport{
|
|
Repository: repositoryFromContext(ctx, ""),
|
|
State: state,
|
|
Limit: limit,
|
|
DryRun: parseBoolArg(ctx.Arg("dry-run")),
|
|
Language: lang,
|
|
Results: results,
|
|
}
|
|
return renderTriageReport(os.Stdout, report, ctx.Format)
|
|
}
|
|
|
|
issues, err := FetchIssuesForTriage(ctx, TriageFetchOptions{
|
|
State: state,
|
|
Limit: limit,
|
|
Page: mustParseInt(ctx.Arg("page"), 1),
|
|
Labels: parseCSV(ctx.Arg("labels")),
|
|
Since: ctx.Arg("since"),
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
results := make([]TriageResult, 0, len(issues))
|
|
for _, issue := range issues {
|
|
results = append(results, AnalyzeIssue(issue, lang))
|
|
}
|
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
|
return fmt.Errorf("workflow +triage remote mode requires --owner and --repo or a Git remote: %w", err)
|
|
}
|
|
report := TriageReport{
|
|
Repository: repositoryFromContext(ctx, ""),
|
|
State: state,
|
|
Limit: limit,
|
|
DryRun: parseBoolArg(ctx.Arg("dry-run")),
|
|
Language: lang,
|
|
Results: results,
|
|
}
|
|
return renderTriageReport(os.Stdout, report, ctx.Format)
|
|
}
|
|
|
|
func runHealth(ctx *common.RuntimeContext) error {
|
|
lang := normalizeLang(ctx.Arg("lang"))
|
|
if hasLocalHealthInput(ctx) {
|
|
input, err := collectHealthFromArgs(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
input.Repository = repositoryFromContext(ctx, input.Repository)
|
|
|
|
result := ScoreHealth(input, lang)
|
|
return renderHealthResult(os.Stdout, result, ctx.Format)
|
|
}
|
|
|
|
if err := ctx.ResolveOwnerRepo(); err != nil {
|
|
return fmt.Errorf("workflow +health remote mode requires --owner and --repo or a Git remote: %w", err)
|
|
}
|
|
input, notes, err := FetchHealthInput(ctx, HealthFetchOptions{
|
|
StaleDays: mustParseInt(ctx.Arg("stale-days"), 30),
|
|
IncludeCI: true,
|
|
IncludeRelease: true,
|
|
IncludeDocs: true,
|
|
})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result := ScoreHealth(input, lang)
|
|
result.ScoringNotes = append(notes, result.ScoringNotes...)
|
|
return renderHealthResult(os.Stdout, result, ctx.Format)
|
|
}
|
|
|
|
func collectIssuesFromArgs(ctx *common.RuntimeContext) ([]IssueInput, error) {
|
|
if path := ctx.Arg("from"); path != "" {
|
|
return readIssueInputs(path)
|
|
}
|
|
if strings.TrimSpace(ctx.Arg("title")) == "" {
|
|
return nil, fmt.Errorf("workflow +triage currently requires --from issues.json or --title for local rule analysis")
|
|
}
|
|
number, err := parseIntArg(ctx.Arg("number"), 0, "number")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
state := ctx.Arg("state")
|
|
if state == "" {
|
|
state = "open"
|
|
}
|
|
return []IssueInput{{
|
|
Number: number,
|
|
Title: ctx.Arg("title"),
|
|
Body: ctx.Arg("body"),
|
|
State: state,
|
|
Author: ctx.Arg("author"),
|
|
URL: ctx.Arg("url"),
|
|
Labels: parseCSV(ctx.Arg("labels")),
|
|
}}, nil
|
|
}
|
|
|
|
func hasLocalTriageInput(ctx *common.RuntimeContext) bool {
|
|
return strings.TrimSpace(ctx.Arg("from")) != "" || strings.TrimSpace(ctx.Arg("title")) != ""
|
|
}
|
|
|
|
func hasLocalHealthInput(ctx *common.RuntimeContext) bool {
|
|
return strings.TrimSpace(ctx.Arg("from")) != "" || strings.TrimSpace(ctx.Arg("repository")) != ""
|
|
}
|
|
|
|
func collectHealthFromArgs(ctx *common.RuntimeContext) (HealthInput, error) {
|
|
if path := ctx.Arg("from"); path != "" {
|
|
input, err := readHealthInput(path)
|
|
if err != nil {
|
|
return HealthInput{}, err
|
|
}
|
|
return input, nil
|
|
}
|
|
|
|
openIssues, err := parseIntArg(ctx.Arg("open-issues"), 0, "open-issues")
|
|
if err != nil {
|
|
return HealthInput{}, err
|
|
}
|
|
openPRs, err := parseIntArg(ctx.Arg("open-prs"), 0, "open-prs")
|
|
if err != nil {
|
|
return HealthInput{}, err
|
|
}
|
|
staleIssues, err := parseIntArg(ctx.Arg("stale-issues"), 0, "stale-issues")
|
|
if err != nil {
|
|
return HealthInput{}, err
|
|
}
|
|
stalePRs, err := parseIntArg(ctx.Arg("stale-prs"), 0, "stale-prs")
|
|
if err != nil {
|
|
return HealthInput{}, err
|
|
}
|
|
recentActivityDays, err := parseIntArg(ctx.Arg("recent-activity-days"), 0, "recent-activity-days")
|
|
if err != nil {
|
|
return HealthInput{}, err
|
|
}
|
|
agentReadinessScore, err := parseIntArg(ctx.Arg("agent-readiness-score"), 0, "agent-readiness-score")
|
|
if err != nil {
|
|
return HealthInput{}, err
|
|
}
|
|
|
|
return HealthInput{
|
|
Repository: ctx.Arg("repository"),
|
|
OpenIssues: openIssues,
|
|
OpenPRs: openPRs,
|
|
StaleIssues: staleIssues,
|
|
StalePRs: stalePRs,
|
|
RecentActivityKnown: parseBoolArg(ctx.Arg("recent-activity-known")),
|
|
RecentActivityDays: recentActivityDays,
|
|
ReleaseKnown: parseBoolArg(ctx.Arg("release-known")),
|
|
HasRecentRelease: parseBoolArg(ctx.Arg("has-recent-release")),
|
|
CIKnown: parseBoolArg(ctx.Arg("ci-known")),
|
|
CIPassing: parseBoolArg(ctx.Arg("ci-passing")),
|
|
HasReadme: parseBoolArg(ctx.Arg("has-readme")),
|
|
HasLicense: parseBoolArg(ctx.Arg("has-license")),
|
|
HasContributing: parseBoolArg(ctx.Arg("has-contributing")),
|
|
AgentReadinessKnown: parseBoolArg(ctx.Arg("agent-readiness-known")),
|
|
AgentReadinessScore: agentReadinessScore,
|
|
}, nil
|
|
}
|
|
|
|
func readIssueInputs(path string) ([]IssueInput, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("read issue inputs: %w", err)
|
|
}
|
|
|
|
var issues []IssueInput
|
|
if err := json.Unmarshal(data, &issues); err == nil {
|
|
return issues, nil
|
|
}
|
|
|
|
var wrapper struct {
|
|
Issues []IssueInput `json:"issues"`
|
|
}
|
|
if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.Issues != nil {
|
|
return wrapper.Issues, nil
|
|
}
|
|
|
|
var issue IssueInput
|
|
if err := json.Unmarshal(data, &issue); err == nil && issue.Title != "" {
|
|
return []IssueInput{issue}, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("parse issue inputs: expected a single issue, an array, or an object with an issues field")
|
|
}
|
|
|
|
func readHealthInput(path string) (HealthInput, error) {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return HealthInput{}, fmt.Errorf("read health input: %w", err)
|
|
}
|
|
var input HealthInput
|
|
if err := json.Unmarshal(data, &input); err != nil {
|
|
return HealthInput{}, fmt.Errorf("parse health input: %w", err)
|
|
}
|
|
return input, nil
|
|
}
|
|
|
|
func filterIssueInputs(issues []IssueInput, state string, limit int) []IssueInput {
|
|
filtered := make([]IssueInput, 0, len(issues))
|
|
for _, issue := range issues {
|
|
if state != "" && state != "all" && issue.State != "" && !strings.EqualFold(issue.State, state) {
|
|
continue
|
|
}
|
|
filtered = append(filtered, issue)
|
|
if limit > 0 && len(filtered) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func repositoryFromContext(ctx *common.RuntimeContext, fallback string) string {
|
|
if ctx.Owner != "" && ctx.Repo != "" {
|
|
return ctx.Owner + "/" + ctx.Repo
|
|
}
|
|
if fallback != "" {
|
|
return fallback
|
|
}
|
|
return "local"
|
|
}
|
|
|
|
func parseCSV(value string) []string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return nil
|
|
}
|
|
parts := strings.Split(value, ",")
|
|
result := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
part = strings.TrimSpace(part)
|
|
if part != "" {
|
|
result = append(result, part)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func parseBoolArg(value string) bool {
|
|
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
|
return err == nil && parsed
|
|
}
|
|
|
|
func parseIntArg(value string, defaultValue int, name string) (int, error) {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return defaultValue, nil
|
|
}
|
|
parsed, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
return 0, fmt.Errorf("invalid --%s %q: must be an integer", name, value)
|
|
}
|
|
if parsed < 0 {
|
|
return 0, fmt.Errorf("invalid --%s %q: must be non-negative", name, value)
|
|
}
|
|
return parsed, nil
|
|
}
|
|
|
|
func mustParseInt(value string, defaultValue int) int {
|
|
parsed, err := parseIntArg(value, defaultValue, "value")
|
|
if err != nil {
|
|
return defaultValue
|
|
}
|
|
return parsed
|
|
}
|