diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b9c885 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ + +gitlink-cli.exe diff --git a/README.md b/README.md index 1f2b0cf..cf17489 100644 --- a/README.md +++ b/README.md @@ -313,6 +313,105 @@ gitlink-cli search +repos -k "machine learning" gitlink-cli search +users -k "zhangsan" ``` +### Workflow Agent Commands + +`workflow` provides rule-based repository analysis for maintainers and AI Agents. It currently supports: + +- `workflow +triage` +- `workflow +health` +- `workflow +pr-summary` +- `workflow +repo-report` + +`workflow +pr-summary` defaults to `table` when `--format` is omitted. +`workflow +repo-report` defaults to `markdown` when `--format` is omitted. + +Examples: + +```bash +# Triage with local parameters +gitlink-cli workflow +triage --title "Install failed on Windows" --body "go install failed with error" --format table + +# Triage with JSON output +gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --format json + +# Triage with Chinese markdown output +gitlink-cli workflow +triage \ + --title "安装失败,无法登录" \ + --body "运行命令时报错" \ + --lang zh-CN \ + --format markdown + +# Triage from a local JSON file +gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format json + +# Triage by read-only GitLink fetch +gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table + +# Health for a healthy repository +gitlink-cli workflow +health \ + --repository Gitlink/gitlink-cli \ + --open-issues 3 \ + --open-prs 1 \ + --has-readme \ + --has-license \ + --has-contributing \ + --agent-readiness-known \ + --agent-readiness-score 9 \ + --format table + +# Health for a risky repository +gitlink-cli workflow +health \ + --repository demo/repo \ + --open-issues 60 \ + --stale-issues 25 \ + --open-prs 12 \ + --stale-prs 6 \ + --recent-activity-known \ + --recent-activity-days 120 \ + --release-known=false \ + --format json + +# Health with Chinese markdown output +gitlink-cli workflow +health \ + --repository Gitlink/gitlink-cli \ + --open-issues 3 \ + --open-prs 1 \ + --has-readme \ + --has-license \ + --has-contributing \ + --lang zh-CN \ + --format markdown + +# Health by read-only GitLink fetch +gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --format table + +# PR review summary by read-only GitLink fetch +gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown + +# PR review summary from a local JSON file +gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json + +# Repository workflow report by read-only GitLink fetch +gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown + +# Repository workflow report from a local JSON file +gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json +``` + +Output formats: + +- `json` for scripts and AI Agents +- `table` for terminal review +- `markdown` for Issue comments, PR comments, release notes, and competition write-ups + +Safety: + +- Current workflow commands use local analysis by default and can also read GitLink data in read-only fetch mode. +- They do not modify remote GitLink data. +- 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. + ### Raw API For endpoints not covered by shortcuts, use the Raw API directly: @@ -334,7 +433,7 @@ gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5' |-----------|-------------|---------| | `--owner` | Repository owner | `--owner Gitlink` | | `--repo` | Repository name | `--repo forgeplus` | -| `--format` | Output format (json/table/yaml) | `--format json` | +| `--format` | Output format (json/table/yaml; workflow also supports markdown) | `--format json` | | `--debug` | Enable debug output | `--debug` | **Automatic context resolution:** When running inside a git repository, `--owner` and `--repo` are automatically resolved from `git remote origin`. @@ -485,7 +584,9 @@ Reinstall first: npm install -g @gitlink-ai/cli ``` -If the error persists, check whether the release page contains the asset for your platform, for example `gitlink-cli__windows_amd64.zip` on Windows x64. You can also download the binary manually from the release page or build from source with `go install .`. +If the error persists, check whether the release page contains the asset for your platform, +for example `gitlink-cli__windows_amd64.zip` on Windows x64. +You can also download the binary manually from the release page or build from source with `go install .`. ### Q: Where are credentials stored on Windows? diff --git a/cmd/api/api.go b/cmd/api/api.go index af0593d..d13cf52 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -3,7 +3,9 @@ package api import ( "encoding/json" "fmt" + "io" "net/url" + "os" "strings" "github.com/spf13/cobra" @@ -20,12 +22,15 @@ func NewAPICmd() *cobra.Command { Long: `Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.`, Example: ` gitlink-cli api GET /users/me gitlink-cli api GET /projects --query 'page=1&limit=10' - gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'`, + gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}' + gitlink-cli api POST /:owner/:repo/issues --body-file issue.json`, Args: cobra.ExactArgs(2), RunE: runAPI, } apiCmd.Flags().String("body", "", "Request body (JSON string)") + apiCmd.Flags().String("body-file", "", "Read request body JSON from a file") + apiCmd.Flags().Bool("body-stdin", false, "Read request body JSON from stdin") apiCmd.Flags().String("query", "", "Query parameters (key=val&key2=val2)") apiCmd.Flags().StringSlice("header", nil, "Additional headers (key:value)") @@ -46,12 +51,9 @@ func runAPI(c *cobra.Command, args []string) error { } cli.Debug = cmdutil.Debug - var body interface{} - bodyStr, _ := c.Flags().GetString("body") - if bodyStr != "" { - if err := json.Unmarshal([]byte(bodyStr), &body); err != nil { - return fmt.Errorf("invalid JSON body: %w", err) - } + body, err := readJSONBody(c) + if err != nil { + return err } var query url.Values @@ -76,6 +78,49 @@ func runAPI(c *cobra.Command, args []string) error { return output.Print(env, resolveFormat()) } +func readJSONBody(c *cobra.Command) (interface{}, error) { + bodyStr, _ := c.Flags().GetString("body") + bodyFile, _ := c.Flags().GetString("body-file") + bodyStdin, _ := c.Flags().GetBool("body-stdin") + + sources := 0 + if bodyStr != "" { + sources++ + } + if bodyFile != "" { + sources++ + } + if bodyStdin { + sources++ + } + if sources == 0 { + return nil, nil + } + if sources > 1 { + return nil, fmt.Errorf("use only one of --body, --body-file, or --body-stdin") + } + + var data []byte + var err error + switch { + case bodyStr != "": + data = []byte(bodyStr) + case bodyFile != "": + data, err = os.ReadFile(bodyFile) + case bodyStdin: + data, err = io.ReadAll(c.InOrStdin()) + } + if err != nil { + return nil, fmt.Errorf("read JSON body: %w", err) + } + + var body interface{} + if err := json.Unmarshal(data, &body); err != nil { + return nil, fmt.Errorf("invalid JSON body: %w", err) + } + return body, nil +} + func resolveFormat() string { f := cmdutil.Format if f == "" { diff --git a/cmd/api/api_test.go b/cmd/api/api_test.go new file mode 100644 index 0000000..df9a104 --- /dev/null +++ b/cmd/api/api_test.go @@ -0,0 +1,90 @@ +package api + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestReadJSONBodyFromInlineFlag(t *testing.T) { + cmd := NewAPICmd() + cmd.Flags().Set("body", `{"title":"hello","count":2}`) + + body, err := readJSONBody(cmd) + if err != nil { + t.Fatalf("readJSONBody returned error: %v", err) + } + values := body.(map[string]interface{}) + if values["title"] != "hello" { + t.Fatalf("title = %v, want hello", values["title"]) + } + if values["count"] != float64(2) { + t.Fatalf("count = %v, want 2", values["count"]) + } +} + +func TestReadJSONBodyFromFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "body.json") + if err := os.WriteFile(path, []byte(`{"description":"来自文件"}`), 0o600); err != nil { + t.Fatalf("write body file: %v", err) + } + + cmd := NewAPICmd() + cmd.Flags().Set("body-file", path) + + body, err := readJSONBody(cmd) + if err != nil { + t.Fatalf("readJSONBody returned error: %v", err) + } + values := body.(map[string]interface{}) + if values["description"] != "来自文件" { + t.Fatalf("description = %v, want 来自文件", values["description"]) + } +} + +func TestReadJSONBodyFromStdin(t *testing.T) { + cmd := NewAPICmd() + cmd.Flags().Set("body-stdin", "true") + cmd.SetIn(strings.NewReader(`{"notes":"from stdin"}`)) + + body, err := readJSONBody(cmd) + if err != nil { + t.Fatalf("readJSONBody returned error: %v", err) + } + values := body.(map[string]interface{}) + if values["notes"] != "from stdin" { + t.Fatalf("notes = %v, want from stdin", values["notes"]) + } +} + +func TestReadJSONBodyRejectsMultipleSources(t *testing.T) { + cmd := NewAPICmd() + cmd.Flags().Set("body", `{"title":"hello"}`) + cmd.Flags().Set("body-stdin", "true") + + if _, err := readJSONBody(cmd); err == nil { + t.Fatal("expected multiple body sources to return an error") + } +} + +func TestReadJSONBodyRejectsInvalidJSON(t *testing.T) { + cmd := NewAPICmd() + cmd.Flags().Set("body", `{"title":`) + + if _, err := readJSONBody(cmd); err == nil { + t.Fatal("expected invalid JSON to return an error") + } +} + +func TestReadJSONBodyWithoutSource(t *testing.T) { + cmd := NewAPICmd() + + body, err := readJSONBody(cmd) + if err != nil { + t.Fatalf("readJSONBody returned error: %v", err) + } + if body != nil { + t.Fatalf("body = %v, want nil", body) + } +} diff --git a/doc/changes/milestone-shortcut.md b/doc/changes/milestone-shortcut.md new file mode 100644 index 0000000..a0e569b --- /dev/null +++ b/doc/changes/milestone-shortcut.md @@ -0,0 +1,19 @@ +# Milestone shortcut + +新增 `milestone` Shortcut 组,补齐 GitLink 里程碑 OpenAPI 的常用操作封装: + +- `milestone +list` +- `milestone +create` +- `milestone +view` +- `milestone +update` +- `milestone +delete` +- `milestone +close` +- `milestone +reopen` + +实现要点: + +- 支持列表筛选、分页、排序,以及详情页关联 Issue 过滤参数。 +- 写入时将 CLI 参数 `--due-date` 映射为 API 字段 `effective_date`。 +- `+update` 在没有任何变更字段时直接报错,避免发送空更新。 +- `+close` 和 `+reopen` 使用 GitLink 的 milestone 状态更新接口。 +- 补充单元测试覆盖各命令的 HTTP 方法、路径、查询参数和 payload。 diff --git a/docs/pr-draft.md b/docs/pr-draft.md new file mode 100644 index 0000000..48bc8c6 --- /dev/null +++ b/docs/pr-draft.md @@ -0,0 +1,105 @@ +# feat(workflow): add agent workflow commands for repository maintenance + +## Summary + +This PR adds four read-only workflow commands for repository maintenance: + +- `workflow +triage` +- `workflow +health` +- `workflow +pr-summary` +- `workflow +repo-report` + +The commands provide rule-based, explainable analysis with stable `json`, concise `table`, +and copy-friendly `markdown` output. + +## Motivation + +Open-source maintainers often spend time on repetitive information organization before +making actual decisions: + +- Issue triage cost +- PR review cost +- repository health visibility +- Agent needs stable structured output + +This PR adds workflow-level analysis on top of the existing GitLink CLI shortcut architecture +without introducing LLM dependencies or remote write behavior. + +## Changes + +### `workflow +triage` + +- Classifies issues by type +- Scores priority and confidence +- Detects missing bug-report information +- Produces risk flags, recommended actions, suggested comments, and reasoning + +### `workflow +health` + +- Scores repository health +- Covers issue/PR backlog, activity, release, CI, docs, license, contributing, and Agent readiness signals +- Tolerates unknown metrics without failing the command + +### `workflow +pr-summary` + +- Summarizes PR metadata, changed files, and commits +- Produces change type, risk level, review focus, test suggestions, merge checklist, and reasoning +- Supports local JSON input and remote read-only PR fetch + +### `workflow +repo-report` + +- Aggregates health, issue triage, and PR summary signals +- Produces a repository workflow report with score, risk level, recommendations, and reasoning +- Supports partial read-only remote aggregation when optional sections are unavailable + +## Safety + +- Remote mode is read-only +- No LLM dependency +- No labels/comments/close operations +- No PR approve/reject/merge operations +- No `internal/output` change +- No new third-party dependency +- Test fixtures do not contain secrets or tokens + +## Tests + +```bash +gofmt -w shortcuts/workflow/*.go shortcuts/register.go +go test ./shortcuts/workflow +go test ./... +``` + +Coverage includes: + +- triage rules +- health scoring +- PR summary rules +- repo report aggregation +- fetch normalization +- partial failure handling +- `json` / `table` / `markdown` rendering +- local `--from` fixtures +- command wiring tests + +## Documentation + +- `README.md` +- `docs/workflow-agent-design.md` +- `docs/workflow-agent-test-report.md` +- `skills/gitlink-workflow/SKILL.md` + +## Known Limitations + +- `workflow +release-notes` is not implemented. +- `workflow +stale` is not implemented. +- Real GitLink API shapes may require follow-up normalization. + +## Examples + +```bash +gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format table +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 +``` diff --git a/docs/workflow-agent-design.md b/docs/workflow-agent-design.md new file mode 100644 index 0000000..59c63ee --- /dev/null +++ b/docs/workflow-agent-design.md @@ -0,0 +1,502 @@ +# GitLink CLI Workflow Agent Design + +## Background + +`gitlink-cli` already provides low-level and shortcut operations for GitLink repositories, +issues, pull requests, releases, CI, organizations, search, and users. +The repository also includes `skills/gitlink-workflow/SKILL.md`, which describes +AI workflow patterns such as Issue triage, PR review, and Release Notes generation. + +The current Go command tree did not include a `workflow` command group before this work. +The competition PR turns the documented workflow concept into concrete, +deterministic CLI commands that can be used by human maintainers and AI Agents +without calling an external LLM. + +## Goals + +First PR: +- Add `gitlink-cli workflow +triage`. +- Add `gitlink-cli workflow +health`. +- Keep write behavior dry-run by default. +- Produce stable JSON for Agents. +- Produce concise table output for terminal users. +- Produce markdown output for reports, PR comments, Issue comments, and competition materials. +- Support `--lang en` and `--lang zh-CN` with a lightweight message helper. + +Additional workflow commands: +- `workflow +pr-summary`: done +- `workflow +repo-report`: done +- `workflow +release-notes`: planned +- `workflow +stale`: planned + +Current implementation status: +- Rule engine: done +- Local command layer: done +- API fetch layer: done +- Boundary tests: expanded for empty responses, field normalization, + unknown tolerance, and read-only error handling +- PR summary command: done with local JSON input, read-only fetch, rules, renderers, and tests +- Repo report command: done with local JSON input, partial read-only fetch aggregation, + scoring, renderers, and tests + +## Current Repository Findings + +Command registration: +- `cmd/root.go` registers global flags and calls `shortcuts.RegisterAll(rootCmd)`. +- `shortcuts/register.go` maps command groups to shortcut slices. +- Each group exposes `Shortcuts() []*common.Shortcut`. +- `common.MountShortcut` maps a `Shortcut` into a Cobra command named `+`. + +Runtime and API calls: +- `common.NewRuntimeContext` creates `client.Client`, carries owner, repo, format, and command args. +- `ctx.ResolveOwnerRepo()` resolves `--owner` / `--repo` or Git remote context. +- `ctx.CallAPI` and `ctx.CallAPIWithQuery` call `internal/client`. +- `client.Do` appends `.json`, injects auth via transport, parses GitLink error-in-body responses, and returns `output.Envelope`. + +Output: +- `internal/output` currently supports `json`, `yaml`, and generic `table`. +- Workflow requires `markdown`; the minimal-risk approach is a workflow-local renderer that prints stable workflow DTOs. +- A later cleanup can promote markdown support into `internal/output` if multiple command groups need it. +- Current workflow commands also expose workflow-local `json`, `table`, and `markdown` rendering without changing the global formatter. + +Testing: +- Existing tests use pure unit tests plus `httptest.Server`. +- Shortcut tests instantiate `common.RuntimeContext` manually with a mocked `client.Client`. +- This pattern should be reused for workflow API tests. + +## Command Design + +### `workflow +triage` + +Examples: + +```bash +gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 30 --dry-run --format json +gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 30 --format table +gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 30 --lang zh-CN --format markdown +``` + +Flags: +- `--state`: default `open` +- `--limit`: default `30` +- `--page`: default `1` +- `--dry-run`: default `true` +- `--from`: optional local JSON input +- `--title`, `--body`, `--number`, `--author`, `--url`, `--labels`: optional local single-issue input +- `--lang`: default `en`, allowed `en`, `zh-CN` + +Stable JSON item fields: +- `issue_id` +- `number` +- `title` +- `url` +- `author` +- `state` +- `created_at` +- `updated_at` +- `detected_type` +- `priority` +- `confidence` +- `suggested_labels` +- `missing_information` +- `risk_flags` +- `recommended_action` +- `suggested_comment` +- `reasoning` + +Rule categories: +- `bug` +- `feature` +- `question` +- `docs` +- `ci` +- `security` +- `performance` +- `refactor` +- `unknown` + +Priority: +- `P0`: security incident, secret/token leak, auth bypass, repository unusable +- `P1`: core command unusable, install/login failure, CI/release blocker +- `P2`: normal bug, important feature, missing docs blocking usage +- `P3`: ordinary question, typo, minor improvement + +Missing information for bug-like issues: +- reproduction steps +- expected behavior +- actual behavior +- version +- OS / platform +- command output +- logs + +### `workflow +health` + +Examples: + +```bash +gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --format json +gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --format table +gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --lang zh-CN --format markdown +``` + +Flags: +- `--stale-days`: default `30` +- `--from`: optional local JSON input +- local metric flags such as `--repository`, `--open-issues`, `--open-prs`, `--has-readme`, `--has-license`, and `--agent-readiness-score` +- `--lang`: default `en` + +Stable JSON fields: +- `repository` +- `open_issues` +- `open_prs` +- `stale_issues` +- `stale_prs` +- `recent_activity` +- `release_status` +- `ci_status` +- `documentation_status` +- `license_status` +- `contribution_status` +- `agent_readiness_score` +- `health_score` +- `risk_level` +- `recommendations` +- `scoring_notes` + +Scoring: +- Issue backlog and response: 20 +- PR backlog and merge state: 20 +- Recent activity: 15 +- Release status: 15 +- Documentation completeness: 10 +- License and contribution readiness: 10 +- Agent readiness: 10 + +Unknown metric policy: +- Keep field present. +- Set status or score detail to `unknown`. +- Add one entry to `scoring_notes`. +- Either omit the metric from denominator or apply a conservative partial score; the first PR should prefer denominator adjustment to avoid fake precision. + +Risk levels: +- `low`: 80-100 +- `medium`: 60-79 +- `high`: 40-59 +- `critical`: 0-39 + +## Architecture + +Proposed files: + +```text +shortcuts/workflow/ + workflow.go # Shortcuts() and command wiring + types.go # Stable DTOs + triage_rules.go # pure classifier, scoring, missing info detection + triage_fetch.go # GitLink issue fetching and response normalization + triage_render.go # json/table/markdown workflow rendering if needed + health_score.go # pure health scoring + health_fetch.go # repo, issue, PR, release, CI/doc/license probes + health_render.go # markdown/table rendering + messages.go # en and zh-CN strings + *_test.go +``` + +Registration: +- Add `workflow` import in `shortcuts/register.go`. +- Add `"workflow": workflow.Shortcuts()` to `groups`. +- Add description `"AI agent workflow analysis"`. + +No new dependency is needed for this PR. + +## Data Normalization + +GitLink responses vary by endpoint. Workflow code should not depend on a single raw shape. Add small extraction helpers: + +- `stringField(map, keys...)` +- `numberField(map, keys...)` +- `timeField(map, keys...)` +- `sliceField(map, keys...)` +- `extractItems(env, candidateKeys...)` + +Candidate issue list keys: +- `issues` +- `data` +- direct array after future client improvements + +Candidate issue fields: +- ID: `id`, `issue_id` +- Number: `project_issues_index`, `number`, `index`, `id` +- Title: `subject`, `title` +- Body: `description`, `body` +- Author: `author.login`, `user.login`, `login` +- URL: `html_url`, `url`, `issue_url` + +Health activity fields currently tolerated: +- `updated_at` +- `updatedAt` +- `last_updated_at` +- `lastUpdatedAt` +- `last_activity_at` +- `lastActivityAt` +- `merged_at` +- `mergedAt` +- `closed_at` +- `closedAt` + +## Safety Strategy + +- `+triage` only reads by default. +- `--dry-run` defaults true. +- A future explicit write flag for posting comments must require `--dry-run=false` in a later PR. +- Generated comments are output as data, not posted remotely in the first PR. +- Health checks never mutate remote state. +- If an API probe fails, health continues with `unknown`. +- The implemented prototype is local-first and has no LLM dependency. +- Remote fetch mode remains read-only and does not post comments, labels, merges, or close actions. +- API failures should fall back to `unknown` metrics or a clear fetch error instead of fabricating healthy data. + +## Core Pseudocode + +### Triage + +```go +issues := fetchIssues(owner, repo, state, limit, page) +results := []TriageResult{} +for _, issue := range issues { + text := normalize(issue.Title + "\n" + issue.Body) + scores := scoreKeywords(text, keywordRules) + detectedType := maxScoreType(scores) + priority := scorePriority(text, detectedType) + missing := detectMissingInfo(issue, detectedType) + confidence := confidenceFromScores(scores, missing) + result := TriageResult{ + IssueID: issue.ID, + Number: issue.Number, + DetectedType: detectedType, + Priority: priority, + SuggestedLabels: labelsFor(detectedType, priority, riskFlags), + MissingInformation: missing, + RiskFlags: detectRiskFlags(text), + RecommendedAction: actionFor(detectedType, priority, missing, lang), + SuggestedComment: commentFor(missing, lang), + Reasoning: explainTopMatches(scores, priorityRules), + } + results = append(results, result) +} +render(results, format, lang) +``` + +### Health + +```go +signals := collectHealthSignals(owner, repo) +score := NewWeightedScore(100) +score.Add("issues", 20, scoreIssueBacklog(signals.OpenIssues, signals.StaleIssues)) +score.Add("prs", 20, scorePRBacklog(signals.OpenPRs, signals.StalePRs)) +score.Add("activity", 15, scoreRecentActivity(signals.RecentActivity)) +score.Add("release", 15, scoreReleaseStatus(signals.ReleaseStatus)) +score.Add("docs", 10, scoreDocStatus(signals.DocumentationStatus)) +score.Add("license", 10, scoreLicenseContribution(signals.LicenseStatus, signals.ContributionStatus)) +score.Add("agent", 10, scoreAgentReadiness(signals)) +result := HealthResult{ + HealthScore: score.Percent(), + RiskLevel: riskLevel(score.Percent()), + Recommendations: recommendations(signals, score), + ScoringNotes: score.Notes(), +} +render(result, format, lang) +``` + +## Output Protocol + +JSON: +- Use stable struct tags. +- Include empty arrays as `[]` where useful for Agent consumption. +- Avoid prose outside JSON. + +Table: +- Triage columns: `NUMBER`, `TYPE`, `PRIORITY`, `CONFIDENCE`, `MISSING`, `ACTION` +- Health rows: `METRIC`, `STATUS`, `SCORE`, `NOTE` + +Markdown: +- Triage: one summary table with type, priority, confidence, action, and missing information. +- Health: repository score, metric table, recommendations, and scoring notes. +- `zh-CN` changes rule messages and recommendation text, not JSON field names. + +## Test Plan + +Unit tests: +- Issue type classification. +- Priority scoring. +- Missing information detection. +- Risk flag detection. +- Suggested comment generation. +- Health weighted score and risk level. +- Unknown metric denominator adjustment. +- Markdown headings and required sections. + +Mock API tests: +- `workflow +triage` fetches issues and normalizes raw response. +- `workflow +health` tolerates failing CI/release/doc probes. + +Command tests: +- `--dry-run` defaults to true. +- `--lang zh-CN` accepted. +- invalid `--lang` falls back to `en`. +- `--format markdown` routes to markdown renderer. + +## Later Extensions + +### `workflow +pr-summary` + +Inputs: +- `--number` +- `--from` +- `--lang` +- `--format` +- optional `--include-files` +- optional `--include-commits` +- optional `--max-files` +- optional `--max-commits` + +Default format: +- `table` for human review when `--format` is omitted + +Data: +- PR details +- changed files +- commits + +Output: +- `change_type` +- `risk_level` +- `review_focus` +- `test_suggestions` +- `merge_checklist` +- `reasoning` + +Implementation status: +- read-only local JSON mode: done +- read-only GitLink fetch mode: done +- rules and renderers: done +- tests: rules, fetch boundary, render, and command wiring + +Safety: +- no comments +- no approve/reject +- no merge +- no remote write operation + +### `workflow +repo-report` + +Inputs: +- `--owner` +- `--repo` +- `--from` +- `--lang` +- `--format` +- optional `--issue-limit` +- optional `--pr-limit` +- optional `--stale-days` +- optional `--include-issues` +- optional `--include-prs` +- optional `--include-health` + +Default format: +- `markdown` for maintainer and competition reports when `--format` is omitted + +Data: +- repository health input and score +- issue triage results aggregated by type, priority, risk, and missing information +- PR summary results aggregated by type, risk, and review focus + +Output: +- `report_score` +- `risk_level` +- `health` +- `issue_summary` +- `pr_summary` +- `recommendations` +- `reasoning` + +Partial report strategy: +- health, issue, and PR sections are fetched independently +- if at least one enabled section succeeds, the command returns a partial report +- failed sections are recorded in scoring notes or reasoning +- PR remote aggregation currently uses PR list metadata only; + detailed changed files and commits remain available through `workflow +pr-summary --number` + +Safety: +- read-only aggregation only +- no comments, labels, closes, approve/reject, or merge operations +- no LLM dependency + +### `workflow +release-notes` + +Inputs: +- `--from` +- `--to` +- optional `--tag` +- optional `--lang` + +Data: +- PR titles +- commit messages + +Markdown categories: +- Features +- Bug Fixes +- Documentation +- Tests +- Refactoring +- Chores +- Breaking Changes + +### `workflow +stale` + +Inputs: +- `--stale-days` +- `--state` +- `--dry-run` + +Behavior: +- Identify stale issues and PRs. +- Generate suggested comments or labels. +- Do not mutate remote state by default. + +## API Fetch Layer + +The current fetch layer uses: + +- `triage_fetch.go` +- `health_fetch.go` +- `pr_fetch.go` +- `repo_report_fetch.go` + +Design goals already applied: + +- tolerate unknown or partial API fields +- map GitLink response shapes into stable workflow DTOs +- continue operating when optional signals fail +- keep remote-write actions disabled until explicitly enabled later + +Planned fetch-layer extension: + +- `triage_fetch.go` and `health_fetch.go` remain the normalization boundary for remote mode. +- `pr_fetch.go` now reuses the same stable DTO and message patterns for read-only PR metadata, changed files, and commits. +- `repo_report_fetch.go` composes the existing fetch helpers and records partial failures instead of failing the whole report. +- Future `release-notes` should reuse the same normalization and renderer patterns. +- Unknown or missing fields should stay explicit in JSON output so Agents can decide how to proceed. + +## Implementation Order + +1. Pure DTOs and rule engine. +2. Pure health scoring. +3. Workflow renderers. +4. Command registration. +5. API fetch and normalization. +6. Tests. +7. README updates. +8. Competition docs and test report. diff --git a/docs/workflow-agent-test-report.md b/docs/workflow-agent-test-report.md new file mode 100644 index 0000000..c166148 --- /dev/null +++ b/docs/workflow-agent-test-report.md @@ -0,0 +1,174 @@ +# Workflow Agent Test Report + +## Scope + +This phase covers: + +- Issue triage rules +- health scoring rules +- PR summary rules +- repository report aggregation rules +- local command execution +- API fetch boundary tests +- remote read-only manual verification +- `json` / `table` / `markdown` rendering +- language handling +- mock tests do not depend on the real remote API + +## Environment + +- OS: Windows +- Go version: `go1.26.1 windows/amd64` +- Go path: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\go.exe` +- gofmt path: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\gofmt.exe` + +## Test Commands + +Executed: + +```bash +gofmt -w shortcuts/workflow/*.go shortcuts/register.go +go test ./shortcuts/workflow +go test ./... +``` + +Results: + +- `go test ./shortcuts/workflow` passed. +- `go test ./...` passed. + +## Unit Tests + +- triage rules tests +- health score tests +- messages tests +- render tests +- command tests +- fetch boundary tests +- PR summary rules and fetch tests +- repo report aggregation, render, command, and partial fetch tests + +## API Fetch Boundary Tests + +- empty issue responses return a clear error instead of panicking +- missing issue titles still allow body-only issues to be normalized +- label normalization supports string arrays, object arrays, and title/name variants +- author normalization supports string, `user`, and `creator` shapes +- GitLink error-in-body responses return readable errors +- health activity timestamps accept `updated_at`, `updatedAt`, `last_activity_at`, `merged_at`, and `closed_at` +- release responses accept `releases`, `data`, and direct array shapes +- CI unavailability is recorded as `unknown` without failing the whole health run +- stale-days values `0` and negative values fall back to the default `30` +- PR summary fetch normalizes PR metadata, changed files, commits, authors, branches, and list limits +- PR summary tolerates partial files or commits fetch failures while keeping base PR metadata +- PR summary base PR error-in-body responses return readable errors +- repo report fetch composes health, issue, and PR sections +- repo report returns a partial report when at least one enabled section succeeds +- repo report returns an error when all enabled fetched sections fail +- repo report issue and PR limits are covered + +## Manual Command Examples + +```bash +gitlink-cli workflow +triage --title "Install failed on Windows" --body "go install failed with error" --format table +gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --format json +gitlink-cli workflow +triage \ + --title "安装失败,无法登录" \ + --body "运行命令时报错" \ + --lang zh-CN \ + --format markdown +gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format json +gitlink-cli workflow +health \ + --repository Gitlink/gitlink-cli \ + --open-issues 3 \ + --open-prs 1 \ + --has-readme \ + --has-license \ + --has-contributing \ + --agent-readiness-known \ + --agent-readiness-score 9 \ + --format table +gitlink-cli workflow +health \ + --repository demo/repo \ + --open-issues 60 \ + --stale-issues 25 \ + --open-prs 12 \ + --stale-prs 6 \ + --recent-activity-known \ + --recent-activity-days 120 \ + --release-known=false \ + --format json +gitlink-cli workflow +health \ + --repository Gitlink/gitlink-cli \ + --open-issues 3 \ + --open-prs 1 \ + --has-readme \ + --has-license \ + --has-contributing \ + --lang zh-CN \ + --format markdown +gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown +gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json +gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown +gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json +``` + +## Remote Manual Verification + +- Command: `gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table` +- Result: succeeded, returned five issues in table form. +- Command: `gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --lang zh-CN --format markdown` +- Result: succeeded, returned a markdown health report with score `58` and risk level `high`. +- Remote writes: `No` + +## Known Limitations + +- Current workflow commands support local analysis and read-only GitLink fetch mode. +- `workflow +triage` still supports local parameters or a local JSON file via `--from`. +- `workflow +health` still supports local parameters or a local JSON file via `--from`. +- `workflow +pr-summary` supports local JSON input and read-only GitLink fetch mode. +- `workflow +repo-report` supports local JSON input and partial read-only GitLink fetch aggregation. +- Remote `workflow +repo-report` PR aggregation currently uses PR list metadata only; + detailed file and commit analysis remains available through `workflow +pr-summary --number`. +- `json/table/markdown` are rendered inside the workflow package, not by the global formatter. +- Fetch-layer tests use `httptest` and do not depend on the real remote API. + +## Conclusion + +The rule-based Agent Workflow prototype, including the read-only fetch layer, is implemented, tested, and locally runnable. + +## Final Verification + +Final verification should be run before opening the official GitLink PR: + +```bash +gofmt -w shortcuts/workflow/*.go shortcuts/register.go +go test ./shortcuts/workflow +go test ./... +``` + +Expected result: + +- `go test ./shortcuts/workflow` passes. +- `go test ./...` passes. +- No remote write operation is performed by workflow commands. + +## Competition Demo Commands + +Prefer local fixtures for stable demos: + +```bash +gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format table +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 +``` + +Read-only remote smoke commands: + +```bash +gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table +gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --format table +gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown +gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown +``` diff --git a/examples/workflows/community-ops-automation/.gitignore b/examples/workflows/community-ops-automation/.gitignore new file mode 100644 index 0000000..7d2b3d8 --- /dev/null +++ b/examples/workflows/community-ops-automation/.gitignore @@ -0,0 +1,8 @@ +outputs/ +__pycache__/ +*.pyc +.pytest_cache/ +.mypy_cache/ +*.log +*.tmp +*.swp diff --git a/examples/workflows/community-ops-automation/LICENSE b/examples/workflows/community-ops-automation/LICENSE new file mode 100644 index 0000000..d1ce275 --- /dev/null +++ b/examples/workflows/community-ops-automation/LICENSE @@ -0,0 +1,17 @@ +Apache License +Version 2.0, January 2004 +http://www.apache.org/licenses/ + +Copyright 2026 GitLink Workflow Project + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/examples/workflows/community-ops-automation/README.md b/examples/workflows/community-ops-automation/README.md new file mode 100644 index 0000000..181e5cd --- /dev/null +++ b/examples/workflows/community-ops-automation/README.md @@ -0,0 +1,53 @@ +# GitLink 构建端到端自动化工作流 + +面向 GitLink 竞赛子赛题三的端到端自动化工作流项目。 + +本项目面向开源社区运营场景,使用 `gitlink-cli` 串联仓库信息、Issue、PR 和 Release 数据采集,自动生成社区周报、Release Notes 草稿和结构化摘要,并支持将摘要发布到指定 GitLink Issue。该流程覆盖“数据采集 -> 指标分析 -> 文档生成 -> 结果发布”的完整闭环。 + +## 交付物 + +- `scripts/gitlink_workflow.py`:主工作流入口 +- `scripts/run_demo.ps1`:一键复现脚本 +- `docs/architecture.md`:架构图与流程说明 +- `docs/quickstart.md`:最短复现路径 +- `docs/runbook.md`:运行手册 +- `docs/verification.md`:真实仓库验证记录 +- `docs/submission-checklist.md`:参赛提交核对清单 +- `docs/upload-to-gitlink.md`:仓库目录结构说明 +- `examples/sample_config.json`:参赛仓库配置 +- `examples/demo_active_config.json`:公开仓库验证配置 +- `examples/demo_outputs/`:真实运行示例产物 +- `tests/test_gitlink_workflow.py`:单测 +- `LICENSE`:Apache 2.0 + +## 运行方式 + +推荐直接运行一键脚本: + +```powershell +.\scripts\run_demo.ps1 +``` + +切换到参赛仓库配置: + +```powershell +.\scripts\run_demo.ps1 -Config examples\sample_config.json +``` + +## 输出 + +- `outputs/*_report.md` +- `outputs/*_release_notes.md` +- `outputs/*_summary.json` + +## 已验证仓库 + +- `puygob236/gitlink-cli`:完成仓库信息、Issue、PR、Release 采集,并完成 Issue 摘要回写验证 +- `Gitlink/gitlink-cli`:完成仓库信息、Issue、PR、Release 采集,并生成包含有效统计数据的周报、Release Notes 和结构化摘要 + +## 项目定位 + +- 满足子赛题三“端到端自动化工作流”的要求 +- 串联 4 个数据采集命令和 1 个结果发布命令 +- 支持在真实 GitLink 项目上复现 +- 提供运行脚本、验证记录、示例产物和单元测试 diff --git a/examples/workflows/community-ops-automation/docs/architecture.md b/examples/workflows/community-ops-automation/docs/architecture.md new file mode 100644 index 0000000..a6264ed --- /dev/null +++ b/examples/workflows/community-ops-automation/docs/architecture.md @@ -0,0 +1,26 @@ +# 架构说明 + +本项目采用“采集 -> 归一化 -> 分析 -> 生成 -> 发布”的五段式流程。 + +![GitLink 社区运营端到端自动化工作流架构](assets/architecture-workflow-v2.svg) + +## 设计目标 + +- 低门槛:只依赖 `gitlink-cli` 和 Python 标准库 +- 可复现:同一配置可重复跑出同类报告 +- 可维护:采集、归一化、分析、生成和发布步骤保持清晰边界 +- 可验证:报告文件、结构化摘要和 Issue 评论均可作为运行结果核验依据 + +## 为什么选这个链路 + +子赛题三要求使用现有命令或 Skill 组合形成完整解决方案。本方案覆盖: + +1. 仓库信息采集 +2. Issue 列表采集 +3. PR 列表采集 +4. Release 列表采集 +5. 报告生成 +6. Issue 摘要发布 + +该链路满足不少于 3 个 CLI 调用的要求,并形成从数据获取到结果发布的端到端闭环。 + diff --git a/examples/workflows/community-ops-automation/docs/assets/architecture-workflow-v2.svg b/examples/workflows/community-ops-automation/docs/assets/architecture-workflow-v2.svg new file mode 100644 index 0000000..8cf5f29 --- /dev/null +++ b/examples/workflows/community-ops-automation/docs/assets/architecture-workflow-v2.svg @@ -0,0 +1,5 @@ + + GitLink Community Operations Automation Architecture + Publication-style architecture figure for the GitLink community operations automation workflow. + + \ No newline at end of file diff --git a/examples/workflows/community-ops-automation/docs/demo-output.md b/examples/workflows/community-ops-automation/docs/demo-output.md new file mode 100644 index 0000000..c977a30 --- /dev/null +++ b/examples/workflows/community-ops-automation/docs/demo-output.md @@ -0,0 +1,32 @@ +# 示例输出摘要 + +## 验证目标 + +`Gitlink/gitlink-cli` + +## 运行命令 + +```powershell +.\scripts\run_demo.ps1 +``` + +## 关键结果 + +- Issues: 15 +- PR: 20 +- Release: 11 +- 输出文件: + - `outputs/Gitlink_gitlink-cli_20260520_140525_report.md` + - `outputs/Gitlink_gitlink-cli_20260520_140525_release_notes.md` + - `outputs/Gitlink_gitlink-cli_20260520_140525_summary.json` + +## 仓库内示例产物 + +- `examples/demo_outputs/Gitlink_gitlink-cli_report.md` +- `examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md` +- `examples/demo_outputs/puygob236_gitlink-cli_report.md` +- `examples/demo_outputs/puygob236_gitlink-cli_release_notes.md` + +## 额外验证 + +`puygob236/gitlink-cli` 已完成仓库信息、Issue、PR 和 Release 采集验证,并完成摘要回写到 Issue 的发布验证。 diff --git a/examples/workflows/community-ops-automation/docs/quickstart.md b/examples/workflows/community-ops-automation/docs/quickstart.md new file mode 100644 index 0000000..07d9a43 --- /dev/null +++ b/examples/workflows/community-ops-automation/docs/quickstart.md @@ -0,0 +1,34 @@ +# 快速开始 + +## 一键运行 + +直接运行一键脚本: + +```powershell +.\scripts\run_demo.ps1 +``` + +脚本会自动通过 `npm exec` 找到 `@gitlink-ai/cli`,把 `gitlink-cli` 放到临时 PATH 里,再执行: + +- 仓库信息采集 +- Issue 列表采集 +- PR 列表采集 +- Release 列表采集 +- 周报生成 +- Release Notes 草稿生成 + +## 配置切换 + +- `examples/demo_active_config.json`:公开仓库验证配置,默认指向 `Gitlink/gitlink-cli` +- `examples/sample_config.json`:参赛仓库验证配置,默认指向 `puygob236/gitlink-cli` + +## 输出 + +- `outputs/*_report.md` +- `outputs/*_release_notes.md` +- `outputs/*_summary.json` + +## 已验证事实 + +- `puygob236/gitlink-cli` 已完成采集、报告生成和 Issue 摘要回写验证 +- `Gitlink/gitlink-cli` 可生成带统计内容的周报和 Release Notes diff --git a/examples/workflows/community-ops-automation/docs/runbook.md b/examples/workflows/community-ops-automation/docs/runbook.md new file mode 100644 index 0000000..86087a1 --- /dev/null +++ b/examples/workflows/community-ops-automation/docs/runbook.md @@ -0,0 +1,54 @@ +# 运行手册 + +## 前置条件 + +- 已安装 `gitlink-cli` +- 已完成 `gitlink-cli auth login` +- 目标仓库有可读权限 + +官方快速开始里要求的验证命令是: + +```powershell +gitlink-cli user +me +``` + +## 运行方式 + +### 1. 只生成报告 + +```powershell +python .\scripts\gitlink_workflow.py --config .\examples\sample_config.json +``` + +### 2. 生成报告并发布摘要 + +```powershell +python .\scripts\gitlink_workflow.py --config .\examples\sample_config.json --publish-issue-id 123 +``` + +### 3. 一键复现 + +```powershell +.\scripts\run_demo.ps1 +``` + +## 输出文件 + +- `outputs/*_report.md`:完整周报 +- `outputs/*_release_notes.md`:Release Notes 草稿 +- `outputs/*_summary.json`:结构化摘要 + +## 验证清单 + +- `repo +info` 能返回仓库信息 +- `issue +list` 能返回 Issue 列表 +- `pr +list` 能返回 PR 列表 +- `release +list` 能返回 Release 列表 +- 报告文件能落盘 +- Release Notes 草稿能落盘 +- 发布模式能把摘要写回指定 Issue + +## 真实项目配置 + +- `examples/demo_active_config.json` 指向 `Gitlink/gitlink-cli`,用于验证活跃公开仓库的数据分析能力。 +- `examples/sample_config.json` 指向 `puygob236/gitlink-cli`,用于验证参赛仓库的采集和 Issue 回写能力。 diff --git a/examples/workflows/community-ops-automation/docs/submission-checklist.md b/examples/workflows/community-ops-automation/docs/submission-checklist.md new file mode 100644 index 0000000..9041b70 --- /dev/null +++ b/examples/workflows/community-ops-automation/docs/submission-checklist.md @@ -0,0 +1,28 @@ +# 提交核对清单 + +## 官方交付要求映射 + +| 要求 | 本项目对应内容 | +| --- | --- | +| 工作流串联不少于 3 个 CLI 命令或 Skill 调用 | `scripts/gitlink_workflow.py` 串联 `repo +info`、`issue +list`、`pr +list`、`release +list`,并支持 `issue +comment` 发布摘要 | +| 提供可复现执行脚本或 Agent 对话记录 | `scripts/run_demo.ps1` | +| 在至少一个真实 GitLink 项目上运行并展示效果 | `docs/verification.md`、`docs/demo-output.md`、`examples/demo_outputs/` | +| 提供工作流说明文档 | `README.md`、`docs/quickstart.md`、`docs/runbook.md` | +| 提供架构图 | `docs/architecture.md` 引用 `docs/assets/architecture-workflow-v2.svg` | +| 代码开源并托管到 GitLink | `https://gitlink.org.cn/puygob236/gitlink-cli` 的 `examples/workflows/community-ops-automation/` | +| 提供完整中文 README | `README.md` | +| 开源协议 | `LICENSE`,Apache 2.0 | + +## 验证状态 + +- `python -m py_compile .\scripts\gitlink_workflow.py .\tests\test_gitlink_workflow.py`:通过 +- `python -m unittest discover -s tests`:通过 +- `.\scripts\run_demo.ps1`:已在 `Gitlink/gitlink-cli` 上跑通 +- `.\scripts\run_demo.ps1 -Config examples\sample_config.json`:已在 `puygob236/gitlink-cli` 上跑通 +- `.\scripts\run_demo.ps1 -Config examples\sample_config.json -PublishIssueId 2`:已完成 Issue 摘要回写验证 + +## 交付内容 + +- `README.md`、`docs/`、`scripts/`、`examples/`、`tests/`、`LICENSE` 均位于 `examples/workflows/community-ops-automation/`。 +- `outputs/` 为运行时生成目录,评审可通过复现脚本重新生成。 +- `examples/demo_outputs/` 提供固定示例产物,便于快速查看报告格式和输出内容。 diff --git a/examples/workflows/community-ops-automation/docs/upload-to-gitlink.md b/examples/workflows/community-ops-automation/docs/upload-to-gitlink.md new file mode 100644 index 0000000..9be839b --- /dev/null +++ b/examples/workflows/community-ops-automation/docs/upload-to-gitlink.md @@ -0,0 +1,30 @@ +# GitLink 仓库目录结构 + +本作品以 `gitlink-cli` 工作流示例的形式托管在 GitLink 仓库中,目录与主项目源码保持隔离,避免改变主仓库既有命令、Skill 和设计文档结构。 + +## 作品路径 + +```text +examples/workflows/community-ops-automation/ +``` + +## 目录内容 + +- `README.md`:项目说明与复现入口 +- `LICENSE`:Apache 2.0 开源协议 +- `.gitignore`:运行时产物忽略规则 +- `docs/`:架构、运行、验证和交付说明 +- `examples/`:配置文件和示例输出 +- `scripts/`:工作流执行脚本 +- `tests/`:单元测试 + +## 仓库内验证 + +进入作品目录后运行: + +```powershell +python -m unittest discover -s tests +.\scripts\run_demo.ps1 +``` + +生成的 `outputs/` 是运行时目录;固定示例产物位于 `examples/demo_outputs/`。 diff --git a/examples/workflows/community-ops-automation/docs/verification.md b/examples/workflows/community-ops-automation/docs/verification.md new file mode 100644 index 0000000..38613c2 --- /dev/null +++ b/examples/workflows/community-ops-automation/docs/verification.md @@ -0,0 +1,67 @@ +# 验证记录 + +## 环境 + +- Windows PowerShell +- Python 3 +- `@gitlink-ai/cli` 0.1.13 + +## 已验证的真实仓库 + +### `puygob236/gitlink-cli` + +- `repo +info` 可访问 +- `issue +list` 可访问 +- `pr +list` 可访问 +- `release +list` 可访问 +- 已完成 Issue 摘要回写验证 + +### `Gitlink/gitlink-cli` + +- `repo +info` 可访问 +- `issue +list` 可访问 +- `pr +list` 可访问 +- `release +list` 可访问 +- 当前可提取到的统计结果: + - Issues: 15 + - PR: 20 + - Release: 11 + +## 本地输出 + +已生成的文件: + +- `outputs/Gitlink_gitlink-cli_20260515_040153_report.md` +- `outputs/Gitlink_gitlink-cli_20260515_040153_summary.json` +- `outputs/Gitlink_gitlink-cli_20260515_121523_report.md` +- `outputs/Gitlink_gitlink-cli_20260515_121523_release_notes.md` +- `outputs/Gitlink_gitlink-cli_20260515_121523_summary.json` +- `outputs/puygob236_gitlink-cli_20260515_121544_report.md` +- `outputs/puygob236_gitlink-cli_20260515_121544_release_notes.md` +- `outputs/puygob236_gitlink-cli_20260515_121544_summary.json` +- `outputs/puygob236_gitlink-cli_20260515_121845_report.md` +- `outputs/puygob236_gitlink-cli_20260515_121845_release_notes.md` +- `outputs/puygob236_gitlink-cli_20260515_121845_summary.json` +- `outputs/Gitlink_gitlink-cli_20260520_140525_report.md` +- `outputs/Gitlink_gitlink-cli_20260520_140525_release_notes.md` +- `outputs/Gitlink_gitlink-cli_20260520_140525_summary.json` +- `outputs/puygob236_gitlink-cli_20260520_143224_report.md` +- `outputs/puygob236_gitlink-cli_20260520_143224_release_notes.md` +- `outputs/puygob236_gitlink-cli_20260520_143224_summary.json` + +其中 `20260520_140525` 对应公开仓库数据分析验证,`20260520_143224` 对应参赛仓库采集与 Issue 回写验证。 + +## 示例产物 + +`outputs/` 是运行时目录,仓库交付中同时提供了轻量示例: + +- `examples/demo_outputs/Gitlink_gitlink-cli_report.md` +- `examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md` +- `examples/demo_outputs/puygob236_gitlink-cli_report.md` +- `examples/demo_outputs/puygob236_gitlink-cli_release_notes.md` + +## 复现方式 + +```powershell +.\scripts\run_demo.ps1 +``` diff --git a/examples/workflows/community-ops-automation/examples/demo_active_config.json b/examples/workflows/community-ops-automation/examples/demo_active_config.json new file mode 100644 index 0000000..83074b7 --- /dev/null +++ b/examples/workflows/community-ops-automation/examples/demo_active_config.json @@ -0,0 +1,6 @@ +{ + "owner": "Gitlink", + "repo": "gitlink-cli", + "window_days": 7, + "output_dir": "outputs" +} diff --git a/examples/workflows/community-ops-automation/examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md b/examples/workflows/community-ops-automation/examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md new file mode 100644 index 0000000..25e0f17 --- /dev/null +++ b/examples/workflows/community-ops-automation/examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md @@ -0,0 +1,18 @@ +# gitlink-cli Release Notes 草稿 + +- 统计窗口:近 7 天 +- 生成时间:2026-05-20 14:05:25 UTC + +## 变更概览 +- 已合并 PR:8 个 +- 最近窗口内合并 PR:2 个 + +## 变更分类 +### feature +- feat(pr): add pr +comment shortcut (2026-05-14) + +### fix +- fix(npm): improve missing binary diagnostics (2026-05-19) + +## 发布说明 +- 存在 1 个超过 7 天未更新的开放 Issue,建议优先清理。 diff --git a/examples/workflows/community-ops-automation/examples/demo_outputs/Gitlink_gitlink-cli_report.md b/examples/workflows/community-ops-automation/examples/demo_outputs/Gitlink_gitlink-cli_report.md new file mode 100644 index 0000000..a357731 --- /dev/null +++ b/examples/workflows/community-ops-automation/examples/demo_outputs/Gitlink_gitlink-cli_report.md @@ -0,0 +1,32 @@ +# gitlink-cli 自动化周报 + +- 统计窗口:近 7 天 +- 生成时间:2026-05-20 14:05:25 UTC + +## 核心指标 + +| 指标 | 数值 | +| --- | ---: | +| Issues 总数 | 15 | +| 打开 Issues | 5 | +| 超窗 Issue | 1 | +| PR 总数 | 20 | +| 打开 PR | 5 | +| 已合并 PR | 8 | +| Release 数 | 11 | + +## 热点标签 +- 无 + +## 最近合并 PR +### fix +- fix(npm): improve missing binary diagnostics (2026-05-19) +### feature +- feat(pr): add pr +comment shortcut (2026-05-14) + +## 风险提示 +### 超窗 Issue +- 2 gitlink-cli 使用讨论与反馈收集 (open) 2026-04-18 + +### 建议动作 +- 存在 1 个超过 7 天未更新的开放 Issue,建议优先清理。 diff --git a/examples/workflows/community-ops-automation/examples/demo_outputs/README.md b/examples/workflows/community-ops-automation/examples/demo_outputs/README.md new file mode 100644 index 0000000..27884af --- /dev/null +++ b/examples/workflows/community-ops-automation/examples/demo_outputs/README.md @@ -0,0 +1,10 @@ +# 示例输出说明 + +本目录保存一次真实 GitLink 项目的演示输出,便于评审在不重新运行脚本时快速查看效果。 + +- `Gitlink_gitlink-cli_report.md`:活跃官方仓库周报示例 +- `Gitlink_gitlink-cli_release_notes.md`:活跃官方仓库 Release Notes 草稿示例 +- `puygob236_gitlink-cli_report.md`:参赛 fork 连通性周报示例 +- `puygob236_gitlink-cli_release_notes.md`:参赛 fork Release Notes 草稿示例 + +完整结构化摘要会在运行脚本后生成到 `outputs/*_summary.json`。 diff --git a/examples/workflows/community-ops-automation/examples/demo_outputs/puygob236_gitlink-cli_release_notes.md b/examples/workflows/community-ops-automation/examples/demo_outputs/puygob236_gitlink-cli_release_notes.md new file mode 100644 index 0000000..d10b9af --- /dev/null +++ b/examples/workflows/community-ops-automation/examples/demo_outputs/puygob236_gitlink-cli_release_notes.md @@ -0,0 +1,14 @@ +# gitlink-cli Release Notes 草稿 + +- 统计窗口:近 7 天 +- 生成时间:2026-05-20 14:32:24 UTC + +## 变更概览 +- 已合并 PR:0 个 +- 最近窗口内合并 PR:0 个 + +## 变更分类 +- 无 + +## 发布说明 +- 当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。 diff --git a/examples/workflows/community-ops-automation/examples/demo_outputs/puygob236_gitlink-cli_report.md b/examples/workflows/community-ops-automation/examples/demo_outputs/puygob236_gitlink-cli_report.md new file mode 100644 index 0000000..3dbbce4 --- /dev/null +++ b/examples/workflows/community-ops-automation/examples/demo_outputs/puygob236_gitlink-cli_report.md @@ -0,0 +1,26 @@ +# gitlink-cli 自动化周报 + +- 统计窗口:近 7 天 +- 生成时间:2026-05-20 14:32:24 UTC + +## 核心指标 + +| 指标 | 数值 | +| --- | ---: | +| Issues 总数 | 2 | +| 打开 Issues | 2 | +| 超窗 Issue | 0 | +| PR 总数 | 0 | +| 打开 PR | 0 | +| 已合并 PR | 0 | +| Release 数 | 0 | + +## 热点标签 +- 无 + +## 最近合并 PR +- 无 + +## 风险提示 +### 建议动作 +- 当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。 diff --git a/examples/workflows/community-ops-automation/examples/sample_config.json b/examples/workflows/community-ops-automation/examples/sample_config.json new file mode 100644 index 0000000..eaf94d4 --- /dev/null +++ b/examples/workflows/community-ops-automation/examples/sample_config.json @@ -0,0 +1,6 @@ +{ + "owner": "puygob236", + "repo": "gitlink-cli", + "window_days": 7, + "output_dir": "outputs" +} diff --git a/examples/workflows/community-ops-automation/scripts/gitlink_workflow.py b/examples/workflows/community-ops-automation/scripts/gitlink_workflow.py new file mode 100644 index 0000000..d27be60 --- /dev/null +++ b/examples/workflows/community-ops-automation/scripts/gitlink_workflow.py @@ -0,0 +1,814 @@ +from __future__ import annotations + +import argparse +import json +import os +import subprocess +from collections import Counter, defaultdict +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Iterable + + +class WorkflowError(RuntimeError): + pass + + +CLI_PAGE_SIZE = 100 + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="GitLink 社区运营自动化工作流:周报 + Release Notes + 风险提示" + ) + parser.add_argument( + "--config", + type=Path, + default=Path("examples/sample_config.json"), + help="配置文件路径", + ) + parser.add_argument("--owner", help="覆盖配置中的仓库所有者") + parser.add_argument("--repo", help="覆盖配置中的仓库名称") + parser.add_argument( + "--window-days", + type=int, + help="统计窗口,默认从配置文件读取或使用 7 天", + ) + parser.add_argument( + "--output-dir", + type=Path, + help="输出目录,默认从配置文件读取或使用 outputs", + ) + parser.add_argument( + "--publish-issue-id", + type=int, + help="发布摘要到指定 Issue 评论,未提供则只生成本地报告", + ) + parser.add_argument( + "--now", + help="固定当前时间,便于测试,格式为 ISO8601", + ) + parser.add_argument( + "--skip-releases", + action="store_true", + help="跳过 release 列表采集", + ) + parser.add_argument( + "--cli-bin", + help="gitlink-cli 可执行文件路径;可配合 GITLINK_CLI_BIN 使用", + ) + return parser.parse_args(argv) + + +def load_json_file(path: Path) -> dict[str, Any]: + if not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + +def sanitize_repo_name(value: str) -> str: + return value.replace("/", "_").replace("\\", "_") + + +def parse_datetime(value: Any) -> datetime | None: + if value in (None, "", []): + return None + if isinstance(value, datetime): + dt = value + else: + text = str(value).strip() + if not text: + return None + text = text.replace("Z", "+00:00") + try: + dt = datetime.fromisoformat(text) + except ValueError: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def parse_iso_now(value: str | None) -> datetime: + if not value: + return datetime.now(timezone.utc) + dt = parse_datetime(value) + if dt is None: + raise WorkflowError(f"无法解析 --now 的值: {value}") + return dt + + +def first_value(item: dict[str, Any], keys: Iterable[str], default: Any = None) -> Any: + for key in keys: + if key in item: + value = item[key] + if value not in (None, "", []): + return value + return default + + +def normalize_labels(value: Any) -> list[str]: + labels: list[str] = [] + if isinstance(value, list): + for item in value: + if isinstance(item, dict): + name = first_value(item, ("name", "title", "label_name")) + if name: + labels.append(str(name)) + elif item not in (None, ""): + labels.append(str(item)) + elif isinstance(value, str) and value: + labels.append(value) + return labels + + +def extract_first_list(payload: Any, keys: Iterable[str]) -> list[Any]: + if isinstance(payload, list): + return payload + if isinstance(payload, dict): + for key in keys: + value = payload.get(key) + if isinstance(value, list): + return value + for value in payload.values(): + found = extract_first_list(value, keys) + if found: + return found + return [] + + +def extract_first_dict(payload: Any, keys: Iterable[str]) -> dict[str, Any]: + if isinstance(payload, dict): + for key in keys: + value = payload.get(key) + if isinstance(value, dict): + return value + for value in payload.values(): + found = extract_first_dict(value, keys) + if found: + return found + if isinstance(payload, list): + for item in payload: + found = extract_first_dict(item, keys) + if found: + return found + return {} + + +def run_gitlink_cli(command: list[str], owner: str, repo: str, cwd: Path | None = None) -> Any: + if shutil_which("gitlink-cli") is None: + raise WorkflowError("未找到 gitlink-cli,请先安装并确保它在 PATH 中") + + cli_path = shutil_which("gitlink-cli") or "gitlink-cli" + if cli_path.lower().endswith((".cmd", ".bat")): + cmd = [ + "cmd", + "/c", + cli_path, + *command, + "--owner", + owner, + "--repo", + repo, + "--format", + "json", + ] + else: + cmd = [ + cli_path, + *command, + "--owner", + owner, + "--repo", + repo, + "--format", + "json", + ] + proc = subprocess.run( + cmd, + cwd=str(cwd) if cwd else None, + capture_output=True, + text=True, + encoding="utf-8", + ) + if proc.returncode != 0: + stderr = proc.stderr.strip() or proc.stdout.strip() or "未知错误" + raise WorkflowError(f"{' '.join(cmd)} 失败: {stderr}") + return parse_json_output(proc.stdout) + + +def parse_json_output(text: str) -> Any: + stripped = text.strip() + if not stripped: + raise WorkflowError("CLI 返回空结果") + try: + return json.loads(stripped) + except json.JSONDecodeError: + first_json = min( + [idx for idx in (stripped.find("{"), stripped.find("[")) if idx != -1], + default=-1, + ) + if first_json > 0: + return json.loads(stripped[first_json:]) + raise WorkflowError(f"无法解析 CLI JSON 输出: {stripped[:120]}") + + +def normalize_repo_info(payload: Any) -> dict[str, Any]: + repo = extract_first_dict(payload, ("project", "repo", "repository", "data")) + if not repo and isinstance(payload, dict): + repo = payload + return { + "name": first_value(repo, ("name", "repo_name", "project_name", "identifier"), ""), + "description": first_value(repo, ("description", "desc", "summary"), ""), + "default_branch": first_value(repo, ("default_branch", "defaultBranch"), ""), + "language": first_value(repo, ("language",), ""), + "raw": repo, + } + + +def normalize_issue_state(item: dict[str, Any], query_state: str | None = None) -> str: + raw_status = first_value(item, ("status_id", "status", "state_id"), None) + raw_name = str( + first_value(item, ("issue_status", "status_name", "state", "status_name_cn"), "") + ).strip().lower() + if raw_status is not None: + try: + raw_status = int(raw_status) + except (TypeError, ValueError): + raw_status = str(raw_status).strip().lower() + if raw_status in {5, "5", "closed", "close"} or "关" in raw_name or "closed" in raw_name: + return "closed" + if raw_status in {1, "1", 2, "2", 3, "3", "open", "opened"} or "开" in raw_name or "新" in raw_name: + return "open" + if query_state: + return query_state + return "open" + + +def normalize_issue(item: dict[str, Any], query_state: str | None = None) -> dict[str, Any]: + return { + "id": str(first_value(item, ("project_issues_index", "iid", "issue_id", "id", "number"), "")), + "title": str(first_value(item, ("subject", "title", "name"), "(untitled)")), + "state": normalize_issue_state(item, query_state=query_state), + "created_at": parse_datetime( + first_value(item, ("created_at", "createdAt", "created_time", "created", "format_time")) + ), + "updated_at": parse_datetime( + first_value(item, ("updated_at", "updatedAt", "updated_time", "updated", "format_time")) + ), + "labels": normalize_labels(first_value(item, ("labels", "label_list", "label"), [])), + "raw": item, + } + + +def normalize_issues(payload: Any, query_state: str | None = None) -> list[dict[str, Any]]: + items = extract_first_list(payload, ("issues", "issue_list", "items", "list")) + normalized: list[dict[str, Any]] = [] + for item in items: + if not isinstance(item, dict): + continue + normalized.append(normalize_issue(item, query_state=query_state)) + return normalized + + +def normalize_pr_state(item: dict[str, Any], query_state: str | None = None) -> str: + raw_status = first_value(item, ("pull_request_status", "pull_request_staus", "status_id", "state_id"), None) + if raw_status is not None: + try: + raw_status = int(raw_status) + except (TypeError, ValueError): + raw_status = str(raw_status).strip().lower() + if raw_status in {1, "1", "merged"}: + return "merged" + if raw_status in {2, "2", "closed", "close"}: + return "closed" + if raw_status in {0, "0", "open", "opened"}: + return "open" + if query_state: + return query_state + return "open" + + +def normalize_pr(item: dict[str, Any], query_state: str | None = None) -> dict[str, Any]: + state = normalize_pr_state(item, query_state=query_state) + merged_at = parse_datetime(first_value(item, ("merged_at", "mergedAt", "merged_time"))) + merged_flag = state == "merged" or merged_at is not None + return { + "id": str( + first_value(item, ("pull_request_number", "iid", "pr_id", "merge_request_iid", "id", "number"), "") + ), + "title": str(first_value(item, ("title", "subject", "name"), "(untitled)")), + "state": state, + "created_at": parse_datetime( + first_value(item, ("created_at", "createdAt", "created_time", "created", "pr_full_time")) + ), + "updated_at": parse_datetime( + first_value(item, ("updated_at", "updatedAt", "updated_time", "updated", "pr_full_time")) + ), + "merged_at": merged_at + or (parse_datetime(first_value(item, ("pr_full_time",))) if state == "merged" else None), + "merged": merged_flag, + "labels": normalize_labels(first_value(item, ("labels", "label_list", "label"), [])), + "raw": item, + } + + +def normalize_prs(payload: Any, query_state: str | None = None) -> list[dict[str, Any]]: + items = extract_first_list(payload, ("pull_requests", "merge_requests", "prs", "items", "list")) + normalized: list[dict[str, Any]] = [] + for item in items: + if not isinstance(item, dict): + continue + normalized.append(normalize_pr(item, query_state=query_state)) + return normalized + + +def normalize_releases(payload: Any) -> list[dict[str, Any]]: + items = extract_first_list(payload, ("releases", "items", "list")) + normalized: list[dict[str, Any]] = [] + for item in items: + if not isinstance(item, dict): + continue + normalized.append( + { + "id": str(first_value(item, ("version_id", "id", "release_id", "iid"), "")), + "title": str(first_value(item, ("name", "title", "tag_name"), "(untitled)")), + "created_at": parse_datetime( + first_value(item, ("created_at", "createdAt", "released_at", "releasedAt")) + ), + "raw": item, + } + ) + return normalized + + +def is_open(state: str) -> bool: + return state == "open" + + +def is_closed(state: str) -> bool: + return state in {"closed", "close", "done", "resolved"} + + +def classify_title(title: str) -> str: + lowered = title.strip().lower() + prefix = lowered.split(":", 1)[0] + prefix = prefix.split("(", 1)[0].strip() + mapping = { + "feat": "feature", + "feature": "feature", + "fix": "fix", + "bugfix": "fix", + "docs": "docs", + "doc": "docs", + "refactor": "refactor", + "test": "test", + "chore": "chore", + "ci": "ci", + } + return mapping.get(prefix, "other") + + +def within_window(dt: datetime | None, cutoff: datetime) -> bool: + return dt is not None and dt >= cutoff + + +def dedupe_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]: + seen: set[str] = set() + result: list[dict[str, Any]] = [] + for item in records: + key = str(item.get("id", "")).strip() + if not key or key in seen: + continue + seen.add(key) + result.append(item) + return result + + +def fetch_paginated_payload( + command: list[str], + owner: str, + repo: str, + item_keys: tuple[str, ...], + page_size: int = CLI_PAGE_SIZE, +) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + page = 1 + max_pages = 50 + while True: + if page > max_pages: + break + payload = run_gitlink_cli( + [*command, "--page", str(page), "--limit", str(page_size)], + owner, + repo, + ) + page_items = extract_first_list(payload, item_keys) + page_items = [item for item in page_items if isinstance(item, dict)] + if not page_items: + break + items.extend(page_items) + if len(page_items) < page_size: + break + page += 1 + return items + + +def fetch_issues(owner: str, repo: str) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for state in ("open", "closed"): + payloads = fetch_paginated_payload( + ["issue", "+list", "--state", state], + owner, + repo, + ("issues", "issue_list", "items", "list"), + ) + records.extend(normalize_issues({"issues": payloads}, query_state=state)) + return dedupe_records(records) + + +def fetch_prs(owner: str, repo: str) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + for state in ("open", "merged", "closed"): + payloads = fetch_paginated_payload( + ["pr", "+list", "--state", state], + owner, + repo, + ("pull_requests", "merge_requests", "prs", "items", "list"), + ) + records.extend(normalize_prs({"pull_requests": payloads}, query_state=state)) + return dedupe_records(records) + + +def fetch_releases(owner: str, repo: str) -> list[dict[str, Any]]: + payloads = fetch_paginated_payload( + ["release", "+list"], + owner, + repo, + ("releases", "items", "list"), + ) + return dedupe_records(normalize_releases({"releases": payloads})) + + +def summarize_workflow( + repo_info: dict[str, Any], + issues: list[dict[str, Any]], + prs: list[dict[str, Any]], + releases: list[dict[str, Any]], + now: datetime, + window_days: int, +) -> dict[str, Any]: + cutoff = now - timedelta(days=window_days) + + open_issues = [item for item in issues if is_open(item["state"])] + closed_issues = [item for item in issues if is_closed(item["state"])] + stale_issues = [ + item + for item in open_issues + if item["updated_at"] is None or item["updated_at"] < cutoff + ] + + merged_prs = [item for item in prs if item["merged"] or item["state"] == "merged"] + open_prs = [item for item in prs if is_open(item["state"]) or (not item["merged"] and not is_closed(item["state"]))] + stale_prs = [ + item + for item in open_prs + if item["updated_at"] is None or item["updated_at"] < cutoff + ] + recent_merged_prs = [ + item + for item in merged_prs + if within_window(item["merged_at"] or item["updated_at"] or item["created_at"], cutoff) + ] + + issue_label_counter: Counter[str] = Counter() + for item in issues: + issue_label_counter.update(item["labels"]) + + pr_buckets: dict[str, list[dict[str, Any]]] = defaultdict(list) + for item in recent_merged_prs: + pr_buckets[classify_title(item["title"])].append(item) + + actions: list[str] = [] + if stale_issues: + actions.append( + f"存在 {len(stale_issues)} 个超过 {window_days} 天未更新的开放 Issue,建议优先清理。" + ) + if stale_prs: + actions.append( + f"存在 {len(stale_prs)} 个超过 {window_days} 天未更新的开放 PR,建议安排 review 或重新拆解。" + ) + if not releases: + actions.append("当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。") + + return { + "repo": repo_info, + "window_days": window_days, + "now": now, + "cutoff": cutoff, + "counts": { + "issues_total": len(issues), + "issues_open": len(open_issues), + "issues_closed": len(closed_issues), + "issues_stale": len(stale_issues), + "prs_total": len(prs), + "prs_open": len(open_prs), + "prs_merged": len(merged_prs), + "prs_stale": len(stale_prs), + "releases_total": len(releases), + }, + "labels": issue_label_counter.most_common(8), + "stale_issues": stale_issues, + "stale_prs": stale_prs, + "recent_merged_prs": recent_merged_prs, + "pr_buckets": {key: value for key, value in pr_buckets.items()}, + "actions": actions, + } + + +def render_list_block(items: list[dict[str, Any]], title_key: str = "title") -> str: + if not items: + return "- 无" + lines = [] + for item in items[:10]: + parts = [f"- {item.get('id', '')} {item.get(title_key, '')}".strip()] + state = item.get("state") + if state: + parts.append(f"({state})") + dt = item.get("updated_at") or item.get("merged_at") or item.get("created_at") + if isinstance(dt, datetime): + parts.append(dt.strftime("%Y-%m-%d")) + lines.append(" ".join(parts)) + return "\n".join(lines) + + +def render_markdown_report(summary: dict[str, Any]) -> str: + repo = summary["repo"] + counts = summary["counts"] + lines: list[str] = [] + title = repo["name"] or "GitLink 仓库" + lines.append(f"# {title} 自动化周报") + if repo.get("description"): + lines.append("") + lines.append(repo["description"]) + lines.append("") + lines.append(f"- 统计窗口:近 {summary['window_days']} 天") + lines.append(f"- 生成时间:{summary['now'].strftime('%Y-%m-%d %H:%M:%S UTC')}") + lines.append("") + lines.append("## 核心指标") + lines.append("") + lines.append("| 指标 | 数值 |") + lines.append("| --- | ---: |") + lines.append(f"| Issues 总数 | {counts['issues_total']} |") + lines.append(f"| 打开 Issues | {counts['issues_open']} |") + lines.append(f"| 超窗 Issue | {counts['issues_stale']} |") + lines.append(f"| PR 总数 | {counts['prs_total']} |") + lines.append(f"| 打开 PR | {counts['prs_open']} |") + lines.append(f"| 已合并 PR | {counts['prs_merged']} |") + lines.append(f"| Release 数 | {counts['releases_total']} |") + lines.append("") + + lines.append("## 热点标签") + if summary["labels"]: + for label, count in summary["labels"]: + lines.append(f"- {label}: {count}") + else: + lines.append("- 无") + lines.append("") + + lines.append("## 最近合并 PR") + recent_groups = summary["pr_buckets"] + if recent_groups: + for bucket, items in recent_groups.items(): + lines.append(f"### {bucket}") + for item in items[:8]: + merged_at = item.get("merged_at") or item.get("updated_at") or item.get("created_at") + suffix = f" ({merged_at.strftime('%Y-%m-%d')})" if isinstance(merged_at, datetime) else "" + lines.append(f"- {item['title']}{suffix}") + else: + lines.append("- 无") + lines.append("") + + lines.append("## 风险提示") + if summary["stale_issues"]: + lines.append("### 超窗 Issue") + lines.append(render_list_block(summary["stale_issues"])) + lines.append("") + if summary["stale_prs"]: + lines.append("### 超窗 PR") + lines.append(render_list_block(summary["stale_prs"])) + lines.append("") + if summary["actions"]: + lines.append("### 建议动作") + for action in summary["actions"]: + lines.append(f"- {action}") + else: + lines.append("- 当前未发现明显风险。") + return "\n".join(lines).rstrip() + "\n" + + +def render_release_notes(summary: dict[str, Any]) -> str: + repo = summary["repo"] + lines: list[str] = [] + title = repo["name"] or "GitLink 仓库" + lines.append(f"# {title} Release Notes 草稿") + lines.append("") + lines.append(f"- 统计窗口:近 {summary['window_days']} 天") + lines.append(f"- 生成时间:{summary['now'].strftime('%Y-%m-%d %H:%M:%S UTC')}") + lines.append("") + lines.append("## 变更概览") + lines.append(f"- 已合并 PR:{summary['counts']['prs_merged']} 个") + lines.append(f"- 最近窗口内合并 PR:{len(summary['recent_merged_prs'])} 个") + lines.append("") + lines.append("## 变更分类") + groups = summary["pr_buckets"] + if groups: + for bucket in ("feature", "fix", "docs", "refactor", "test", "chore", "ci", "other"): + items = groups.get(bucket, []) + if not items: + continue + lines.append(f"### {bucket}") + for item in items[:10]: + merged_at = item.get("merged_at") or item.get("updated_at") or item.get("created_at") + suffix = f" ({merged_at.strftime('%Y-%m-%d')})" if isinstance(merged_at, datetime) else "" + lines.append(f"- {item['title']}{suffix}") + lines.append("") + else: + lines.append("- 无") + lines.append("") + lines.append("## 发布说明") + if summary["actions"]: + for action in summary["actions"]: + lines.append(f"- {action}") + else: + lines.append("- 当前未发现明显风险。") + return "\n".join(lines).rstrip() + "\n" + + +def render_publish_comment( + summary: dict[str, Any], + report_path: Path, + release_notes_path: Path | None = None, +) -> str: + repo = summary["repo"] + counts = summary["counts"] + lines = [ + f"## {repo['name'] or 'GitLink 仓库'} 自动化周报摘要", + "", + f"- 时间窗:近 {summary['window_days']} 天", + f"- Issues:{counts['issues_open']} 个打开,{counts['issues_stale']} 个超窗", + f"- PR:{counts['prs_open']} 个打开,{counts['prs_merged']} 个已合并", + f"- Release:{counts['releases_total']} 条", + "", + f"完整报告已生成:`{report_path.as_posix()}`", + ] + if release_notes_path is not None: + lines.append(f"Release Notes 草稿:`{release_notes_path.as_posix()}`") + if summary["actions"]: + lines.append("") + lines.append("### 建议动作") + for action in summary["actions"][:3]: + lines.append(f"- {action}") + return "\n".join(lines).rstrip() + + +def build_issue_comment_command(issue_number: int, comment: str) -> list[str]: + return ["issue", "+comment", "--number", str(issue_number), "--body", comment] + + +def safe_fetch( + label: str, + func, + warnings: list[str], + default: Any, +) -> Any: + try: + return func() + except Exception as exc: # noqa: BLE001 + warnings.append(f"{label} 失败:{exc}") + return default + + +def shutil_which(name: str) -> str | None: + from shutil import which + + return which(name) + + +def build_artifacts( + owner: str, + repo: str, + window_days: int, + output_dir: Path, + now: datetime, + publish_issue_id: int | None, + skip_releases: bool, +) -> tuple[dict[str, Any], Path, Path, Path, list[str]]: + warnings: list[str] = [] + repo_info = safe_fetch( + "repo +info", + lambda: normalize_repo_info(run_gitlink_cli(["repo", "+info"], owner, repo)), + warnings, + {"name": repo, "description": "", "default_branch": "", "language": "", "raw": {}}, + ) + issues = safe_fetch("issue +list", lambda: fetch_issues(owner, repo), warnings, []) + prs = safe_fetch("pr +list", lambda: fetch_prs(owner, repo), warnings, []) + releases = [] if skip_releases else safe_fetch( + "release +list", + lambda: fetch_releases(owner, repo), + warnings, + [], + ) + + summary = summarize_workflow(repo_info, issues, prs, releases, now, window_days) + summary["warnings"] = warnings + summary["owner"] = owner + summary["repo_name"] = repo + summary["publish_issue_id"] = publish_issue_id + + output_dir.mkdir(parents=True, exist_ok=True) + stamp = now.strftime("%Y%m%d_%H%M%S") + repo_slug = sanitize_repo_name(repo) + base_name = f"{owner}_{repo_slug}_{stamp}" + report_path = output_dir / f"{base_name}_report.md" + summary_path = output_dir / f"{base_name}_summary.json" + release_notes_path = output_dir / f"{base_name}_release_notes.md" + + report_text = render_markdown_report(summary) + release_notes_text = render_release_notes(summary) + report_path.write_text(report_text, encoding="utf-8") + release_notes_path.write_text(release_notes_text, encoding="utf-8") + summary_path.write_text( + json.dumps( + { + **summary, + "now": summary["now"].isoformat(), + "cutoff": summary["cutoff"].isoformat(), + "artifacts": { + "report": report_path.as_posix(), + "summary": summary_path.as_posix(), + "release_notes": release_notes_path.as_posix(), + }, + }, + ensure_ascii=False, + indent=2, + default=str, + ), + encoding="utf-8", + ) + + if publish_issue_id is not None: + comment = render_publish_comment(summary, report_path, release_notes_path) + try: + run_gitlink_cli( + build_issue_comment_command(publish_issue_id, comment), + owner, + repo, + ) + except Exception as exc: # noqa: BLE001 + warnings.append(f"issue +comment 失败:{exc}") + + return summary, report_path, summary_path, release_notes_path, warnings + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + config = load_json_file(args.config) + + owner = args.owner or config.get("owner") + repo = args.repo or config.get("repo") + if not owner or not repo: + raise WorkflowError("请在配置文件或命令行中提供 owner 和 repo") + + window_days = args.window_days or int(config.get("window_days", 7)) + output_dir = args.output_dir or Path(config.get("output_dir", "outputs")) + now = parse_iso_now(args.now) + + summary, report_path, summary_path, release_notes_path, warnings = build_artifacts( + owner=owner, + repo=repo, + window_days=window_days, + output_dir=output_dir, + now=now, + publish_issue_id=args.publish_issue_id, + skip_releases=args.skip_releases, + ) + + print(f"已生成报告: {report_path}") + print(f"已生成摘要: {summary_path}") + print(f"已生成 Release Notes: {release_notes_path}") + if warnings: + print("警告:") + for warning in warnings: + print(f"- {warning}") + print( + "指标概览: " + f"Issues={summary['counts']['issues_total']}, " + f"PR={summary['counts']['prs_total']}, " + f"Release={summary['counts']['releases_total']}" + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/workflows/community-ops-automation/scripts/run_demo.ps1 b/examples/workflows/community-ops-automation/scripts/run_demo.ps1 new file mode 100644 index 0000000..5ad872b --- /dev/null +++ b/examples/workflows/community-ops-automation/scripts/run_demo.ps1 @@ -0,0 +1,45 @@ +param( + [string]$Config = "examples/demo_active_config.json", + [string]$Owner = "", + [string]$Repo = "", + [int]$WindowDays = 7, + [string]$OutputDir = "outputs", + [int]$PublishIssueId = 0, + [switch]$SkipReleases +) + +$ErrorActionPreference = "Stop" + +$cliCandidates = npm.cmd exec --yes --package=@gitlink-ai/cli -- cmd /c where gitlink-cli 2>$null +$cliPath = $cliCandidates | Where-Object { $_ -match 'gitlink-cli\.cmd$' } | Select-Object -First 1 +if (-not $cliPath) { + $cliPath = $cliCandidates | Select-Object -First 1 +} +if (-not $cliPath) { + throw "未能通过 npm exec 找到 gitlink-cli" +} + +$cliDir = Split-Path -Parent $cliPath +$env:PATH = "$cliDir;$env:PATH" + +$args = @( + "scripts\gitlink_workflow.py", + "--config", $Config, + "--window-days", "$WindowDays", + "--output-dir", $OutputDir +) + +if ($Owner) { + $args += @("--owner", $Owner) +} +if ($Repo) { + $args += @("--repo", $Repo) +} +if ($PublishIssueId -gt 0) { + $args += @("--publish-issue-id", "$PublishIssueId") +} +if ($SkipReleases.IsPresent) { + $args += "--skip-releases" +} + +python @args diff --git a/examples/workflows/community-ops-automation/tests/test_gitlink_workflow.py b/examples/workflows/community-ops-automation/tests/test_gitlink_workflow.py new file mode 100644 index 0000000..8025bf8 --- /dev/null +++ b/examples/workflows/community-ops-automation/tests/test_gitlink_workflow.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import unittest +from datetime import datetime, timezone + +from scripts.gitlink_workflow import ( + build_issue_comment_command, + normalize_issues, + normalize_prs, + normalize_releases, + render_markdown_report, + render_release_notes, + summarize_workflow, +) + + +class WorkflowTests(unittest.TestCase): + def setUp(self) -> None: + self.now = datetime(2026, 5, 15, 12, 0, tzinfo=timezone.utc) + self.repo_info = { + "name": "forgeplus", + "description": "demo repo", + "default_branch": "master", + } + + def test_normalize_issue_payload(self) -> None: + payload = { + "data": { + "issues": [ + { + "project_issues_index": 1, + "subject": "feat: add report", + "status_id": 1, + "status_name": "新增", + "updated_at": "2026-05-10T10:00:00Z", + "labels": [{"name": "enhancement"}], + } + ] + } + } + issues = normalize_issues(payload) + self.assertEqual(len(issues), 1) + self.assertEqual(issues[0]["title"], "feat: add report") + self.assertEqual(issues[0]["labels"], ["enhancement"]) + self.assertEqual(issues[0]["state"], "open") + + def test_normalize_pr_payload(self) -> None: + payload = { + "data": { + "merge_requests": [ + { + "pull_request_number": 10, + "title": "fix: bug", + "pull_request_status": 1, + "merged_at": "2026-05-14T10:00:00Z", + } + ] + } + } + prs = normalize_prs(payload) + self.assertEqual(len(prs), 1) + self.assertTrue(prs[0]["merged"]) + self.assertEqual(prs[0]["state"], "merged") + + def test_normalize_release_payload(self) -> None: + payload = {"data": {"releases": [{"id": 5, "name": "v1.0.0"}]}} + releases = normalize_releases(payload) + self.assertEqual(len(releases), 1) + self.assertEqual(releases[0]["title"], "v1.0.0") + + def test_summary_and_report(self) -> None: + issues = [ + { + "id": "1", + "title": "feat: add report", + "state": "open", + "created_at": datetime(2026, 5, 5, 12, 0, tzinfo=timezone.utc), + "updated_at": datetime(2026, 5, 10, 12, 0, tzinfo=timezone.utc), + "labels": ["enhancement"], + }, + { + "id": "2", + "title": "fix: stale issue", + "state": "open", + "created_at": datetime(2026, 4, 20, 12, 0, tzinfo=timezone.utc), + "updated_at": datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc), + "labels": ["bug"], + }, + ] + prs = [ + { + "id": "10", + "title": "feat: workflow", + "state": "merged", + "created_at": datetime(2026, 5, 12, 12, 0, tzinfo=timezone.utc), + "updated_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc), + "merged_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc), + "merged": True, + "labels": [], + }, + { + "id": "11", + "title": "chore: cleanup", + "state": "open", + "created_at": datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc), + "updated_at": datetime(2026, 5, 2, 12, 0, tzinfo=timezone.utc), + "merged_at": None, + "merged": False, + "labels": [], + }, + ] + releases = [{"id": "1", "title": "v1.0.0", "created_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc)}] + summary = summarize_workflow(self.repo_info, issues, prs, releases, self.now, 7) + report = render_markdown_report(summary) + self.assertIn("# forgeplus 自动化周报", report) + self.assertIn("Issues 总数", report) + self.assertIn("超窗 Issue", report) + self.assertIn("feature", report) + release_notes = render_release_notes(summary) + self.assertIn("Release Notes", release_notes) + self.assertIn("变更分类", release_notes) + self.assertEqual(summary["counts"]["issues_stale"], 1) + self.assertEqual(summary["counts"]["prs_merged"], 1) + self.assertIn("feature", summary["pr_buckets"]) + + def test_issue_comment_command_uses_number_flag(self) -> None: + command = build_issue_comment_command(2, "demo") + self.assertEqual(command, ["issue", "+comment", "--number", "2", "--body", "demo"]) + self.assertNotIn("-i", command) + + +if __name__ == "__main__": + unittest.main() diff --git a/internal/client/client.go b/internal/client/client.go index d147d96..831caa7 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -42,6 +42,8 @@ func New() (*Client, error) { } func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) { + path = normalizeAPIPath(c.BaseURL, path) + // Append .json suffix if not already present (GitLink API convention) // Handle paths that may already contain query strings (e.g., /path?key=val) if idx := strings.Index(path, "?"); idx != -1 { @@ -158,6 +160,18 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o return output.SuccessEnvelope(raw, meta), nil } +func normalizeAPIPath(baseURL, path string) string { + if strings.HasSuffix(strings.TrimRight(baseURL, "/"), "/api") { + switch { + case path == "/api": + return "" + case strings.HasPrefix(path, "/api/"): + return strings.TrimPrefix(path, "/api") + } + } + return path +} + func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) { return c.Do("GET", path, nil, query) } diff --git a/internal/client/client_test.go b/internal/client/client_test.go new file mode 100644 index 0000000..87fb91d --- /dev/null +++ b/internal/client/client_test.go @@ -0,0 +1,27 @@ +package client + +import "testing" + +func TestNormalizeAPIPathStripsDuplicateAPIPrefix(t *testing.T) { + got := normalizeAPIPath("https://www.gitlink.org.cn/api", "/api/v1/repos/Gitlink/gitlink-cli/contents/README.md") + want := "/v1/repos/Gitlink/gitlink-cli/contents/README.md" + if got != want { + t.Fatalf("normalizeAPIPath() = %q, want %q", got, want) + } +} + +func TestNormalizeAPIPathKeepsRegularPath(t *testing.T) { + got := normalizeAPIPath("https://www.gitlink.org.cn/api", "/projects") + want := "/projects" + if got != want { + t.Fatalf("normalizeAPIPath() = %q, want %q", got, want) + } +} + +func TestNormalizeAPIPathKeepsAPIPrefixForNonAPIBaseURL(t *testing.T) { + got := normalizeAPIPath("https://www.gitlink.org.cn", "/api/v1/repos/Gitlink/gitlink-cli") + want := "/api/v1/repos/Gitlink/gitlink-cli" + if got != want { + t.Fatalf("normalizeAPIPath() = %q, want %q", got, want) + } +} diff --git a/pr-test-file.txt b/pr-test-file.txt deleted file mode 100644 index d848ff9..0000000 --- a/pr-test-file.txt +++ /dev/null @@ -1 +0,0 @@ -PR Test 2026年 4月 7日 星期二 11时45分56秒 CST diff --git a/shortcuts/compare/compare.go b/shortcuts/compare/compare.go new file mode 100644 index 0000000..b39937a --- /dev/null +++ b/shortcuts/compare/compare.go @@ -0,0 +1,83 @@ +package compare + +import ( + "encoding/base64" + "fmt" + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "view", + Description: "Compare two branches, tags, or commits", + Flags: []common.Flag{ + {Name: "head", Usage: "Source branch, tag, or commit", Required: true}, + {Name: "base", Usage: "Target branch, tag, or commit", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + head, err := ctx.RequireArg("head") + if err != nil { + return err + } + base, err := ctx.RequireArg("base") + if err != nil { + return err + } + env, err := ctx.CallAPI("GET", comparePath(ctx, head, base), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "files", + Description: "List changed files between two refs", + Flags: []common.Flag{ + {Name: "head", Usage: "Source branch, tag, or commit", Required: true}, + {Name: "base", Usage: "Target branch, tag, or commit", Required: true}, + {Name: "file", Short: "f", Usage: "Filter by file path"}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + head, err := ctx.RequireArg("head") + if err != nil { + return err + } + base, err := ctx.RequireArg("base") + if err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + if file := ctx.Arg("file"); file != "" { + q.Set("filepath", file) + } + env, err := ctx.CallAPIWithQuery("GET", "/v1"+comparePath(ctx, head, base)+"/files", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} + +func comparePath(ctx *common.RuntimeContext, head, base string) string { + return fmt.Sprintf("%s/compare/%s...%s", ctx.RepoPath(), encodeRef(head), encodeRef(base)) +} + +func encodeRef(ref string) string { + return base64.RawURLEncoding.EncodeToString([]byte(ref)) +} diff --git a/shortcuts/compare/compare_test.go b/shortcuts/compare/compare_test.go new file mode 100644 index 0000000..d10b9f5 --- /dev/null +++ b/shortcuts/compare/compare_test.go @@ -0,0 +1,107 @@ +package compare + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestCompareViewEncodesRefs(t *testing.T) { + var calledPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calledPath = r.URL.Path + if r.Method != "GET" || r.URL.Path != "/owner/repo/compare/ZmVhdHVyZS9hcGk...bWFzdGVy.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeJSON(t, w, map[string]interface{}{"commits_count": 1}) + })) + defer server.Close() + + err := runCompareShortcut(t, server, "view", map[string]string{ + "head": "feature/api", + "base": "master", + }) + if err != nil { + t.Fatalf("view shortcut failed: %v", err) + } + if calledPath == "" { + t.Fatal("server was not called") + } +} + +func TestCompareFilesUsesV1EndpointWithFilters(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/compare/YnVnZml4...cmVsZWFzZS92MQ/files.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("filepath"); got != "cmd/api/api.go" { + t.Fatalf("filepath query = %q, want cmd/api/api.go", got) + } + if got := r.URL.Query().Get("page"); got != "2" { + t.Fatalf("page query = %q, want 2", got) + } + if got := r.URL.Query().Get("limit"); got != "50" { + t.Fatalf("limit query = %q, want 50", got) + } + writeJSON(t, w, map[string]interface{}{"files": []interface{}{}}) + })) + defer server.Close() + + err := runCompareShortcut(t, server, "files", map[string]string{ + "head": "bugfix", + "base": "release/v1", + "file": "cmd/api/api.go", + "page": "2", + "limit": "50", + }) + if err != nil { + t.Fatalf("files shortcut failed: %v", err) + } +} + +func TestEncodeRefUsesRawURLBase64(t *testing.T) { + got := encodeRef("feature/api") + want := "ZmVhdHVyZS9hcGk" + if got != want { + t.Fatalf("encodeRef() = %q, want %q", got, want) + } +} + +func runCompareShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findCompareShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + return shortcut.Run(ctx) +} + +func findCompareShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} diff --git a/shortcuts/milestone/milestone.go b/shortcuts/milestone/milestone.go new file mode 100644 index 0000000..1ec2b30 --- /dev/null +++ b/shortcuts/milestone/milestone.go @@ -0,0 +1,239 @@ +package milestone + +import ( + "fmt" + "net/url" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List milestones", + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "Search keyword"}, + {Name: "category", Short: "c", Usage: "Filter by category: opening, closed"}, + {Name: "only-name", Usage: "Return only milestone id and name: true or false"}, + {Name: "sort-by", Usage: "Sort field: created_on, updated_on, effective_date, issues_count, percent"}, + {Name: "sort-direction", Usage: "Sort direction: asc or desc"}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + setQueryIfPresent(q, "keyword", ctx.Arg("keyword")) + setQueryIfPresent(q, "category", ctx.Arg("category")) + setQueryIfPresent(q, "only_name", ctx.Arg("only-name")) + setQueryIfPresent(q, "sort_by", ctx.Arg("sort-by")) + setQueryIfPresent(q, "sort_direction", ctx.Arg("sort-direction")) + env, err := ctx.CallAPIWithQuery("GET", milestonePath(ctx), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create", + Description: "Create a milestone", + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: "Milestone name", Required: true}, + {Name: "description", Short: "d", Usage: "Milestone description", Required: true}, + {Name: "due-date", Usage: "Due date in YYYY-MM-DD format", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + payload, err := milestonePayload(ctx, true) + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", milestonePath(ctx), payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "view", + Description: "View milestone details and linked issues", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Milestone ID", Required: true}, + {Name: "category", Short: "c", Usage: "Filter issues by category: all, opened, closed"}, + {Name: "author-id", Usage: "Filter issues by author ID"}, + {Name: "assigner-id", Usage: "Filter issues by assignee ID"}, + {Name: "issue-tag-ids", Usage: "Comma-separated issue tag IDs"}, + {Name: "sort-by", Usage: "Sort field: issues.created_on, issues.updated_on, issue_priorities.position"}, + {Name: "sort-direction", Usage: "Sort direction: asc or desc"}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + setQueryIfPresent(q, "category", ctx.Arg("category")) + setQueryIfPresent(q, "author_id", ctx.Arg("author-id")) + setQueryIfPresent(q, "assigner_id", ctx.Arg("assigner-id")) + setQueryIfPresent(q, "issue_tag_ids", normalizeCSV(ctx.Arg("issue-tag-ids"))) + setQueryIfPresent(q, "sort_by", ctx.Arg("sort-by")) + setQueryIfPresent(q, "sort_direction", ctx.Arg("sort-direction")) + env, err := ctx.CallAPIWithQuery("GET", milestoneItemPath(ctx, id), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "update", + Description: "Update a milestone", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Milestone ID", Required: true}, + {Name: "name", Short: "n", Usage: "Milestone name"}, + {Name: "description", Short: "d", Usage: "Milestone description"}, + {Name: "due-date", Usage: "Due date in YYYY-MM-DD format"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + payload, err := milestonePayload(ctx, false) + if err != nil { + return err + } + env, err := ctx.CallAPI("PATCH", milestoneItemPath(ctx, id), payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "delete", + Description: "Delete a milestone", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Milestone ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("DELETE", milestoneItemPath(ctx, id), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + newStatusShortcut("close", "Close a milestone", "closed"), + newStatusShortcut("reopen", "Reopen a milestone", "open"), + } +} + +func milestonePath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/v1/%s/%s/milestones", ctx.Owner, ctx.Repo) +} + +func milestoneItemPath(ctx *common.RuntimeContext, id string) string { + return fmt.Sprintf("%s/%s", milestonePath(ctx), url.PathEscape(id)) +} + +func milestoneStatusPath(ctx *common.RuntimeContext, id string) string { + return fmt.Sprintf("%s/milestones/%s/update_status", ctx.RepoPath(), url.PathEscape(id)) +} + +func milestonePayload(ctx *common.RuntimeContext, requireAll bool) (map[string]interface{}, error) { + payload := map[string]interface{}{} + if name := ctx.Arg("name"); name != "" { + payload["name"] = name + } + if description := ctx.Arg("description"); description != "" { + payload["description"] = description + } + if dueDate := ctx.Arg("due-date"); dueDate != "" { + payload["effective_date"] = dueDate + } + + if requireAll { + for _, name := range []string{"name", "description", "due-date"} { + if _, err := ctx.RequireArg(name); err != nil { + return nil, err + } + } + return payload, nil + } + if len(payload) == 0 { + return nil, fmt.Errorf("at least one of --name, --description, or --due-date is required") + } + return payload, nil +} + +func newStatusShortcut(name, description, status string) *common.Shortcut { + return &common.Shortcut{ + Name: name, + Description: description, + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Milestone ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", milestoneStatusPath(ctx, id), map[string]interface{}{ + "status": status, + }) + if err != nil { + return err + } + return ctx.Output(env) + }, + } +} + +func setQueryIfPresent(q url.Values, name, value string) { + if value != "" { + q.Set(name, value) + } +} + +func normalizeCSV(value string) string { + 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 strings.Join(result, ",") +} diff --git a/shortcuts/milestone/milestone_test.go b/shortcuts/milestone/milestone_test.go new file mode 100644 index 0000000..bf76193 --- /dev/null +++ b/shortcuts/milestone/milestone_test.go @@ -0,0 +1,207 @@ +package milestone + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestMilestoneList(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/milestones.json") + assertEqual(t, r.URL.Query().Get("category"), "opening") + assertEqual(t, r.URL.Query().Get("keyword"), "v1") + assertEqual(t, r.URL.Query().Get("page"), "2") + assertEqual(t, r.URL.Query().Get("limit"), "50") + writeJSON(t, w, map[string]interface{}{"total_count": 0, "milestones": []interface{}{}}) + })) + defer server.Close() + + err := runMilestoneShortcut(t, server, "list", map[string]string{ + "category": "opening", + "keyword": "v1", + "page": "2", + "limit": "50", + }) + if err != nil { + t.Fatalf("list shortcut failed: %v", err) + } +} + +func TestMilestoneCreatePayload(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/v1/owner/repo/milestones.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + err := runMilestoneShortcut(t, server, "create", map[string]string{ + "name": "v1.0", + "description": "first release", + "due-date": "2026-07-01", + }) + if err != nil { + t.Fatalf("create shortcut failed: %v", err) + } + + assertEqual(t, payload["name"], "v1.0") + assertEqual(t, payload["description"], "first release") + assertEqual(t, payload["effective_date"], "2026-07-01") +} + +func TestMilestoneViewWithIssueFilters(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "GET", "/v1/owner/repo/milestones/7.json") + assertEqual(t, r.URL.Query().Get("category"), "opened") + assertEqual(t, r.URL.Query().Get("author_id"), "11") + assertEqual(t, r.URL.Query().Get("assigner_id"), "22") + assertEqual(t, r.URL.Query().Get("issue_tag_ids"), "1,2,3") + writeJSON(t, w, map[string]interface{}{"milestone": map[string]interface{}{"id": 7}}) + })) + defer server.Close() + + err := runMilestoneShortcut(t, server, "view", map[string]string{ + "id": "7", + "category": "opened", + "author-id": "11", + "assigner-id": "22", + "issue-tag-ids": "1, 2,3", + }) + if err != nil { + t.Fatalf("view shortcut failed: %v", err) + } +} + +func TestMilestoneUpdatePayload(t *testing.T) { + var payload map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "PATCH", "/v1/owner/repo/milestones/7.json") + payload = decodeJSON(t, r) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + err := runMilestoneShortcut(t, server, "update", map[string]string{ + "id": "7", + "due-date": "2026-08-01", + }) + if err != nil { + t.Fatalf("update shortcut failed: %v", err) + } + + if _, ok := payload["name"]; ok { + t.Fatal("update payload should omit empty name") + } + assertEqual(t, payload["effective_date"], "2026-08-01") +} + +func TestMilestoneUpdateRequiresChange(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("server should not be called when update payload is empty: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runMilestoneShortcut(t, server, "update", map[string]string{"id": "7"}) + if err == nil { + t.Fatal("expected update without fields to return an error") + } +} + +func TestMilestoneDelete(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "DELETE", "/v1/owner/repo/milestones/7.json") + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + if err := runMilestoneShortcut(t, server, "delete", map[string]string{"id": "7"}); err != nil { + t.Fatalf("delete shortcut failed: %v", err) + } +} + +func TestMilestoneCloseAndReopen(t *testing.T) { + gotStatuses := []string{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertRequest(t, r, "POST", "/owner/repo/milestones/7/update_status.json") + payload := decodeJSON(t, r) + gotStatuses = append(gotStatuses, payload["status"].(string)) + writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"}) + })) + defer server.Close() + + if err := runMilestoneShortcut(t, server, "close", map[string]string{"id": "7"}); err != nil { + t.Fatalf("close shortcut failed: %v", err) + } + if err := runMilestoneShortcut(t, server, "reopen", map[string]string{"id": "7"}); err != nil { + t.Fatalf("reopen shortcut failed: %v", err) + } + assertEqual(t, gotStatuses[0], "closed") + assertEqual(t, gotStatuses[1], "open") +} + +func runMilestoneShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { + t.Helper() + shortcut := findMilestoneShortcut(t, name) + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + if ctx.Args == nil { + ctx.Args = map[string]string{} + } + return shortcut.Run(ctx) +} + +func findMilestoneShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func assertRequest(t *testing.T, r *http.Request, method, path string) { + t.Helper() + if r.Method != method || r.URL.Path != path { + t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path) + } +} + +func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("failed to decode request body: %v", err) + } + return payload +} + +func writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} + +func assertEqual(t *testing.T, got interface{}, want interface{}) { + t.Helper() + if got != want { + t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want) + } +} diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 7f9984c..ce7453a 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -131,6 +131,27 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "reopen", + Description: "Reopen a closed pull request", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "PR number", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, err := ctx.RequireArg("id") + if err != nil { + return err + } + env, err := ctx.CallAPI("POST", prV1Path(ctx, id)+"/reopen", nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "files", Description: "List changed files in a pull request", diff --git a/shortcuts/pr/pr_test.go b/shortcuts/pr/pr_test.go index db58cb6..551bc8d 100644 --- a/shortcuts/pr/pr_test.go +++ b/shortcuts/pr/pr_test.go @@ -263,6 +263,29 @@ func TestPRReviewRejectsInvalidStatus(t *testing.T) { } } +func TestPRReopenUsesV1Endpoint(t *testing.T) { + var calledPath string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/pulls/13/reopen.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + calledPath = r.URL.Path + writeJSON(t, w, map[string]interface{}{ + "status": 0, + "message": "success", + }) + })) + defer server.Close() + + err := runPRShortcut(t, server, "reopen", map[string]string{ + "id": "13", + }) + if err != nil { + t.Fatalf("reopen shortcut failed: %v", err) + } + assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/reopen.json") +} + func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error { t.Helper() shortcut := findPRShortcut(t, name) diff --git a/shortcuts/register.go b/shortcuts/register.go index 05a685f..49b20ed 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -6,8 +6,10 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/branch" "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" + "github.com/gitlink-org/gitlink-cli/shortcuts/compare" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" "github.com/gitlink-org/gitlink-cli/shortcuts/member" + "github.com/gitlink-org/gitlink-cli/shortcuts/milestone" "github.com/gitlink-org/gitlink-cli/shortcuts/org" "github.com/gitlink-org/gitlink-cli/shortcuts/pr" "github.com/gitlink-org/gitlink-cli/shortcuts/release" @@ -15,36 +17,43 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/search" "github.com/gitlink-org/gitlink-cli/shortcuts/user" "github.com/gitlink-org/gitlink-cli/shortcuts/webhook" + "github.com/gitlink-org/gitlink-cli/shortcuts/workflow" ) // RegisterAll mounts all shortcut groups onto the root command. func RegisterAll(root *cobra.Command) { groups := map[string][]*common.Shortcut{ - "repo": repo.Shortcuts(), - "issue": issue.Shortcuts(), - "member": member.Shortcuts(), - "pr": pr.Shortcuts(), - "release": release.Shortcuts(), - "branch": branch.Shortcuts(), - "org": org.Shortcuts(), - "user": user.Shortcuts(), - "search": search.Shortcuts(), - "ci": ci.Shortcuts(), - "webhook": webhook.Shortcuts(), + "repo": repo.Shortcuts(), + "issue": issue.Shortcuts(), + "member": member.Shortcuts(), + "milestone": milestone.Shortcuts(), + "pr": pr.Shortcuts(), + "release": release.Shortcuts(), + "branch": branch.Shortcuts(), + "org": org.Shortcuts(), + "user": user.Shortcuts(), + "search": search.Shortcuts(), + "ci": ci.Shortcuts(), + "compare": compare.Shortcuts(), + "webhook": webhook.Shortcuts(), + "workflow": workflow.Shortcuts(), } descriptions := map[string]string{ - "repo": "Repository operations", - "issue": "Issue operations", - "member": "Repository member operations", - "pr": "Pull request operations", - "release": "Release operations", - "branch": "Branch operations", - "org": "Organization operations", - "user": "User operations", - "search": "Search operations", - "ci": "CI/CD operations", - "webhook": "Webhook operations", + "repo": "Repository operations", + "issue": "Issue operations", + "member": "Repository member operations", + "milestone": "Milestone operations", + "pr": "Pull request operations", + "release": "Release operations", + "branch": "Branch operations", + "org": "Organization operations", + "user": "User operations", + "search": "Search operations", + "ci": "CI/CD operations", + "compare": "Compare branches, tags, or commits", + "webhook": "Webhook operations", + "workflow": "AI agent workflow analysis", } for name, shortcuts := range groups { diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 75091a4..4d621ad 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -52,6 +52,31 @@ func Shortcuts() []*common.Shortcut { return ctx.Output(env) }, }, + { + Name: "readme", + Description: "Show repository README content", + Flags: []common.Flag{ + {Name: "ref", Usage: "Branch, tag, or commit SHA"}, + {Name: "path", Usage: "README directory path"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + if ref := ctx.Arg("ref"); ref != "" { + q.Set("ref", ref) + } + if path := ctx.Arg("path"); path != "" { + q.Set("filepath", path) + } + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/readme", q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, { Name: "create", Description: "Create a new repository", diff --git a/shortcuts/repo/repo_test.go b/shortcuts/repo/repo_test.go new file mode 100644 index 0000000..41305c4 --- /dev/null +++ b/shortcuts/repo/repo_test.go @@ -0,0 +1,63 @@ +package repo + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestRepoReadmeUsesRepositoryReadmeEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/owner/repo/readme.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("ref"); got != "main" { + t.Fatalf("ref query = %q, want main", got) + } + if got := r.URL.Query().Get("filepath"); got != "docs" { + t.Fatalf("filepath query = %q, want docs", got) + } + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(map[string]interface{}{ + "type": "file", + "name": "README.md", + "content": "# docs\n", + }); err != nil { + t.Fatalf("write response: %v", err) + } + })) + defer server.Close() + + err := runRepoShortcut(server, "readme", map[string]string{ + "ref": "main", + "path": "docs", + }) + if err != nil { + t.Fatalf("readme shortcut failed: %v", err) + } +} + +func runRepoShortcut(server *httptest.Server, name string, args map[string]string) error { + for _, shortcut := range Shortcuts() { + if shortcut.Name != name { + continue + } + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + return shortcut.Run(ctx) + } + return fmt.Errorf("shortcut %q not found", name) +} diff --git a/shortcuts/workflow/api_types.go b/shortcuts/workflow/api_types.go new file mode 100644 index 0000000..3a0bf3e --- /dev/null +++ b/shortcuts/workflow/api_types.go @@ -0,0 +1,363 @@ +package workflow + +import ( + "encoding/json" + "fmt" + "strconv" + "strings" + "time" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type TriageFetchOptions struct { + Owner string + Repo string + State string + Limit int + Page int + Labels []string + Since string +} + +type HealthFetchOptions struct { + Owner string + Repo string + StaleDays int + IncludeCI bool + IncludeRelease bool + IncludeDocs bool +} + +func workflowRepoPath(owner, repo string) string { + return fmt.Sprintf("/v1/%s/%s", strings.TrimSpace(owner), strings.TrimSpace(repo)) +} + +func resolveFetchRepo(ctx *common.RuntimeContext, owner, repo string) (string, string, error) { + if strings.TrimSpace(owner) != "" && strings.TrimSpace(repo) != "" { + return strings.TrimSpace(owner), strings.TrimSpace(repo), nil + } + if strings.TrimSpace(ctx.Owner) != "" && strings.TrimSpace(ctx.Repo) != "" { + return strings.TrimSpace(ctx.Owner), strings.TrimSpace(ctx.Repo), nil + } + if err := ctx.ResolveOwnerRepo(); err != nil { + return "", "", err + } + if strings.TrimSpace(ctx.Owner) == "" || strings.TrimSpace(ctx.Repo) == "" { + return "", "", fmt.Errorf("repository owner/repo is required; use --owner and --repo or run inside a GitLink repository") + } + return strings.TrimSpace(ctx.Owner), strings.TrimSpace(ctx.Repo), nil +} + +func normalizeAPIData(data interface{}) (interface{}, error) { + switch v := data.(type) { + case nil: + return nil, nil + case string: + trimmed := strings.TrimSpace(v) + if trimmed == "" { + return nil, nil + } + var decoded interface{} + if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil { + return v, nil + } + return decoded, nil + case json.RawMessage: + if len(v) == 0 { + return nil, nil + } + var decoded interface{} + if err := json.Unmarshal(v, &decoded); err != nil { + return nil, err + } + return decoded, nil + default: + return v, nil + } +} + +func apiObject(data interface{}) map[string]interface{} { + normalized, err := normalizeAPIData(data) + if err != nil { + return nil + } + switch v := normalized.(type) { + case map[string]interface{}: + return v + case []interface{}: + if len(v) == 1 { + if item, ok := v[0].(map[string]interface{}); ok { + return item + } + } + } + return nil +} + +func apiList(data interface{}) []interface{} { + normalized, err := normalizeAPIData(data) + if err != nil { + return nil + } + switch v := normalized.(type) { + case []interface{}: + return v + case map[string]interface{}: + for _, key := range []string{"issues", "pulls", "pull_requests", "files", "commits", "releases", "builds", "items", "records", "data"} { + if raw, ok := v[key]; ok { + if items := apiList(raw); len(items) > 0 { + return items + } + } + } + if looksLikeIssueOrRepoItem(v) { + return []interface{}{v} + } + } + return nil +} + +func looksLikeIssueOrRepoItem(v map[string]interface{}) bool { + _, hasTitle := v["title"] + _, hasSubject := v["subject"] + _, hasNumber := v["number"] + _, hasID := v["id"] + _, hasIID := v["iid"] + _, hasIssueNumber := v["issue_number"] + _, hasProjectIndex := v["project_issues_index"] + return hasTitle || hasSubject || hasNumber || hasID || hasIID || hasIssueNumber || hasProjectIndex +} + +func apiString(v interface{}) string { + switch value := v.(type) { + case string: + return value + case fmt.Stringer: + return value.String() + case float64: + return trimTrailingZero(fmt.Sprintf("%f", value)) + case float32: + return trimTrailingZero(fmt.Sprintf("%f", value)) + case int: + return strconv.Itoa(value) + case int64: + return strconv.FormatInt(value, 10) + case int32: + return strconv.FormatInt(int64(value), 10) + case uint64: + return strconv.FormatUint(value, 10) + case uint32: + return strconv.FormatUint(uint64(value), 10) + case json.Number: + return value.String() + case bool: + return strconv.FormatBool(value) + default: + return "" + } +} + +func trimTrailingZero(value string) string { + value = strings.TrimSuffix(value, "000000") + value = strings.TrimSuffix(value, ".000000") + value = strings.TrimSuffix(value, ".0") + value = strings.TrimSuffix(value, ".") + return value +} + +func apiInt(v interface{}) int { + switch value := v.(type) { + case int: + return value + case int8: + return int(value) + case int16: + return int(value) + case int32: + return int(value) + case int64: + return int(value) + case uint: + return int(value) + case uint8: + return int(value) + case uint16: + return int(value) + case uint32: + return int(value) + case uint64: + return int(value) + case float32: + return int(value) + case float64: + return int(value) + case json.Number: + n, _ := value.Int64() + return int(n) + case string: + if value == "" { + return 0 + } + if n, err := strconv.Atoi(value); err == nil { + return n + } + if n, err := strconv.ParseFloat(value, 64); err == nil { + return int(n) + } + } + return 0 +} + +func apiBool(v interface{}) bool { + switch value := v.(type) { + case bool: + return value + case string: + parsed, err := strconv.ParseBool(strings.TrimSpace(value)) + return err == nil && parsed + case float64: + return value != 0 + case int: + return value != 0 + case json.Number: + n, err := value.Int64() + return err == nil && n != 0 + default: + return false + } +} + +func apiTime(v interface{}) time.Time { + switch value := v.(type) { + case time.Time: + return value + case string: + return parseAPIStringTime(value) + case float64: + return parseAPINumericTime(int64(value)) + case float32: + return parseAPINumericTime(int64(value)) + case int: + return parseAPINumericTime(int64(value)) + case int64: + return parseAPINumericTime(value) + case json.Number: + if n, err := value.Int64(); err == nil { + return parseAPINumericTime(n) + } + } + return time.Time{} +} + +func parseAPIStringTime(value string) time.Time { + trimmed := strings.TrimSpace(value) + if trimmed == "" { + return time.Time{} + } + layouts := []string{ + time.RFC3339, + time.RFC3339Nano, + "2006-01-02 15:04:05", + "2006-01-02T15:04:05", + "2006-01-02", + } + for _, layout := range layouts { + if parsed, err := time.Parse(layout, trimmed); err == nil { + return parsed + } + } + if n, err := strconv.ParseInt(trimmed, 10, 64); err == nil { + return parseAPINumericTime(n) + } + return time.Time{} +} + +func parseAPINumericTime(n int64) time.Time { + if n <= 0 { + return time.Time{} + } + if n > 1_000_000_000_000 { + return time.Unix(0, n*int64(time.Millisecond)) + } + return time.Unix(n, 0) +} + +func apiStringSlice(v interface{}) []string { + switch value := v.(type) { + case nil: + return nil + case []string: + return append([]string(nil), value...) + case []interface{}: + out := make([]string, 0, len(value)) + for _, item := range value { + if s := apiStringValue(item); s != "" { + out = append(out, s) + } + } + return out + case string: + if strings.TrimSpace(value) == "" { + return nil + } + parts := strings.Split(value, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out + } + return nil +} + +func apiStringValue(v interface{}) string { + switch value := v.(type) { + case map[string]interface{}: + for _, key := range []string{"name", "title", "login", "label", "text"} { + if s := apiString(value[key]); s != "" { + return s + } + } + return "" + default: + return apiString(v) + } +} + +func apiAuthor(v interface{}) string { + switch value := v.(type) { + case map[string]interface{}: + for _, key := range []string{"login", "name", "username", "full_name", "display_name"} { + if s := apiString(value[key]); s != "" { + return s + } + } + return "" + default: + return apiString(v) + } +} + +func apiLatestTime(values ...time.Time) time.Time { + var latest time.Time + for _, value := range values { + if value.IsZero() { + continue + } + if latest.IsZero() || value.After(latest) { + latest = value + } + } + return latest +} + +func apiAgeInDays(value time.Time) int { + if value.IsZero() { + return -1 + } + return int(time.Since(value).Hours() / 24) +} diff --git a/shortcuts/workflow/health_fetch.go b/shortcuts/workflow/health_fetch.go new file mode 100644 index 0000000..0efbe75 --- /dev/null +++ b/shortcuts/workflow/health_fetch.go @@ -0,0 +1,301 @@ +package workflow + +import ( + "fmt" + "net/url" + "strings" + "time" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func FetchHealthInput(ctx *common.RuntimeContext, opts HealthFetchOptions) (HealthInput, []ScoringNote, error) { + owner, repo, err := resolveFetchRepo(ctx, opts.Owner, opts.Repo) + if err != nil { + return HealthInput{}, nil, err + } + + input := HealthInput{ + Repository: fmt.Sprintf("%s/%s", owner, repo), + } + notes := []ScoringNote{} + staleDays := opts.StaleDays + if staleDays <= 0 { + staleDays = 30 + } + + repoInfo, repoErr := fetchRepoInfo(ctx, owner, repo) + if repoErr != nil { + notes = append(notes, ScoringNote{Metric: "repository", Note: repoErr.Error()}) + } else { + applyRepoSignals(&input, repoInfo) + } + + if issues, err := fetchAllListItems(ctx, workflowRepoPath(owner, repo)+"/issues", issueListQuery("open"), 100); err != nil { + notes = append(notes, ScoringNote{Metric: "open_issues", Note: fmt.Sprintf("issue probe failed: %v", err)}) + } else { + input.OpenIssues = len(issues) + input.StaleIssues = countStaleItems(issues, staleDays) + input.RecentActivityKnown, input.RecentActivityDays, input = updateRecentActivity(input, latestTimeFromItems(issues, input.RecentActivityDays)) + } + + if prs, err := fetchAllListItems(ctx, workflowRepoPath(owner, repo)+"/pulls", issueListQuery("open"), 100); err != nil { + notes = append(notes, ScoringNote{Metric: "open_prs", Note: fmt.Sprintf("pull request probe failed: %v", err)}) + } else { + input.OpenPRs = len(prs) + input.StalePRs = countStaleItems(prs, staleDays) + input.RecentActivityKnown, input.RecentActivityDays, input = updateRecentActivity(input, latestTimeFromItems(prs, input.RecentActivityDays)) + } + + if opts.IncludeRelease { + if releases, err := fetchAllListItems(ctx, ctx.RepoPath()+"/releases", nil, 100); err != nil { + input.ReleaseKnown = false + notes = append(notes, ScoringNote{Metric: "release_status", Note: fmt.Sprintf("release probe failed: %v", err)}) + } else { + input.ReleaseKnown = true + input.HasRecentRelease = len(releases) > 0 + input.RecentActivityKnown, input.RecentActivityDays, input = updateRecentActivity(input, latestTimeFromItems(releases, input.RecentActivityDays)) + } + } + + if opts.IncludeCI { + if builds, err := fetchAllListItems(ctx, ctx.RepoPath()+"/builds", queryWithPageLimit(nil, 1, 20), 20); err != nil { + input.CIKnown = false + notes = append(notes, ScoringNote{Metric: "ci_status", Note: fmt.Sprintf("ci probe failed: %v", err)}) + } else { + input.CIKnown = true + input.CIPassing = len(builds) > 0 && buildPassing(builds[0]) + } + } + + if opts.IncludeDocs { + applyDocSignals(&input, repoInfo, ¬es) + } + + applyAgentReadinessEstimate(&input) + + if !input.RecentActivityKnown { + notes = append(notes, ScoringNote{Metric: "recent_activity", Note: "recent activity unavailable; scored conservatively"}) + } + if !input.ReleaseKnown { + notes = append(notes, ScoringNote{Metric: "release_status", Note: "release status unavailable; scored conservatively"}) + } + if !input.CIKnown { + notes = append(notes, ScoringNote{Metric: "ci_status", Note: "ci status unavailable; scored conservatively"}) + } + + return input, uniqueScoringNotes(notes), nil +} + +func fetchRepoInfo(ctx *common.RuntimeContext, owner, repo string) (map[string]interface{}, error) { + env, err := ctx.CallAPI("GET", workflowRepoPath(owner, repo), nil) + if err != nil { + return nil, err + } + info := apiObject(env.Data) + if info == nil { + return nil, fmt.Errorf("repository response did not contain an object") + } + return info, nil +} + +func applyRepoSignals(input *HealthInput, repoInfo map[string]interface{}) { + if repoInfo == nil { + return + } + if t := apiTime(repoInfo["updated_at"]); !t.IsZero() { + input.RecentActivityKnown = true + input.RecentActivityDays = apiAgeInDays(t) + } + applyDocSignals(input, repoInfo, nil) +} + +func applyDocSignals(input *HealthInput, repoInfo map[string]interface{}, notes *[]ScoringNote) { + if repoInfo == nil { + return + } + hasReadme, readmeOK := repoInfo["has_readme"] + hasLicense, licenseOK := repoInfo["has_license"] + hasContributing, contribOK := repoInfo["has_contributing"] + if readmeOK { + input.HasReadme = apiBool(hasReadme) + } else if notes != nil { + *notes = append(*notes, ScoringNote{Metric: "documentation", Note: "README probe unavailable; scored conservatively"}) + } + if licenseOK { + input.HasLicense = apiBool(hasLicense) + } else if notes != nil { + *notes = append(*notes, ScoringNote{Metric: "license_status", Note: "license probe unavailable; scored conservatively"}) + } + if contribOK { + input.HasContributing = apiBool(hasContributing) + } else if notes != nil { + *notes = append(*notes, ScoringNote{Metric: "contribution_status", Note: "contributing probe unavailable; scored conservatively"}) + } +} + +func applyAgentReadinessEstimate(input *HealthInput) { + score := 4 + if input.HasReadme { + score += 2 + } + if input.HasLicense { + score += 2 + } + if input.HasContributing { + score += 2 + } + if input.RecentActivityKnown { + score++ + } + if input.ReleaseKnown { + score++ + } + input.AgentReadinessKnown = true + input.AgentReadinessScore = clampInt(score, 0, 10) +} + +func countStaleItems(items []map[string]interface{}, staleDays int) int { + if staleDays <= 0 { + staleDays = 30 + } + count := 0 + for _, item := range items { + if apiAgeInDays(itemActivityTime(item)) >= staleDays { + count++ + } + } + return count +} + +func itemActivityTime(item map[string]interface{}) time.Time { + if item == nil { + return time.Time{} + } + return apiLatestTime( + apiTime(item["updated_at"]), + apiTime(item["updatedAt"]), + apiTime(item["created_at"]), + apiTime(item["last_updated_at"]), + apiTime(item["lastUpdatedAt"]), + apiTime(item["last_activity_at"]), + apiTime(item["lastActivityAt"]), + apiTime(item["merged_at"]), + apiTime(item["mergedAt"]), + apiTime(item["closed_at"]), + apiTime(item["closedAt"]), + ) +} + +func latestTimeFromItems(items []map[string]interface{}, currentDays int) time.Time { + latest := time.Time{} + for _, item := range items { + latest = apiLatestTime(latest, itemActivityTime(item)) + } + return latest +} + +func updateRecentActivity(input HealthInput, latest time.Time) (bool, int, HealthInput) { + if latest.IsZero() { + return input.RecentActivityKnown, input.RecentActivityDays, input + } + days := apiAgeInDays(latest) + if !input.RecentActivityKnown || days < input.RecentActivityDays || input.RecentActivityDays == 0 { + input.RecentActivityKnown = true + input.RecentActivityDays = days + } + return input.RecentActivityKnown, input.RecentActivityDays, input +} + +func queryWithPageLimit(base url.Values, page, limit int) url.Values { + if base == nil { + base = url.Values{} + } + if page > 0 { + base.Set("page", fmt.Sprintf("%d", page)) + } + if limit > 0 { + base.Set("limit", fmt.Sprintf("%d", limit)) + } + return base +} + +func issueListQuery(state string) url.Values { + q := url.Values{} + q.Set("state", state) + return q +} + +func fetchAllListItems(ctx *common.RuntimeContext, path string, baseQuery url.Values, pageSize int) ([]map[string]interface{}, error) { + if pageSize <= 0 { + pageSize = 100 + } + all := []map[string]interface{}{} + for page := 1; ; page++ { + query := cloneValues(baseQuery) + query.Set("page", fmt.Sprintf("%d", page)) + query.Set("limit", fmt.Sprintf("%d", pageSize)) + + env, err := ctx.CallAPIWithQuery("GET", path, query) + if err != nil { + return nil, err + } + items := apiList(env.Data) + pageItems := make([]map[string]interface{}, 0, len(items)) + for _, raw := range items { + if item, ok := raw.(map[string]interface{}); ok { + pageItems = append(pageItems, item) + } + } + if len(pageItems) == 0 { + break + } + all = append(all, pageItems...) + if len(pageItems) < pageSize { + break + } + } + return all, nil +} + +func cloneValues(values url.Values) url.Values { + if values == nil { + return url.Values{} + } + out := url.Values{} + for key, list := range values { + out[key] = append([]string(nil), list...) + } + return out +} + +func buildPassing(item map[string]interface{}) bool { + for _, key := range []string{"status", "state", "result", "conclusion", "status_text"} { + if value := strings.ToLower(strings.TrimSpace(apiString(item[key]))); value != "" { + switch value { + case "success", "passed", "pass", "ok", "done", "succeeded", "build passed": + return true + case "failed", "failure", "error", "canceled", "cancelled", "running", "pending": + return false + } + } + } + if apiBool(item["success"]) { + return true + } + return false +} + +func uniqueScoringNotes(notes []ScoringNote) []ScoringNote { + seen := map[string]struct{}{} + out := make([]ScoringNote, 0, len(notes)) + for _, note := range notes { + key := note.Metric + "|" + note.Note + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, note) + } + return out +} diff --git a/shortcuts/workflow/health_fetch_test.go b/shortcuts/workflow/health_fetch_test.go new file mode 100644 index 0000000..c9accd3 --- /dev/null +++ b/shortcuts/workflow/health_fetch_test.go @@ -0,0 +1,321 @@ +package workflow + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestFetchHealthInputCollectsSignals(t *testing.T) { + now := time.Now().UTC() + old := now.AddDate(0, 0, -45) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo.json": + writeWorkflowJSON(t, w, map[string]interface{}{ + "name": "repo", + "updated_at": now.AddDate(0, 0, -2).Format(time.RFC3339), + "has_readme": true, + "has_license": true, + "has_contributing": true, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json": + writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{ + {"id": 1, "subject": "fresh issue", "updated_at": now.AddDate(0, 0, -1).Format(time.RFC3339)}, + {"id": 2, "subject": "stale issue", "updated_at": old.Format(time.RFC3339)}, + }}) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json": + writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{ + {"id": 3, "title": "stale pr", "updated_at": old.Format(time.RFC3339)}, + }}) + case r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json": + writeWorkflowJSON(t, w, map[string]interface{}{"releases": []map[string]interface{}{ + {"id": 4, "name": "v1.0.0", "created_at": now.AddDate(0, 0, -3).Format(time.RFC3339)}, + }}) + case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json": + writeWorkflowJSON(t, w, map[string]interface{}{"builds": []map[string]interface{}{ + {"id": 5, "status": "success"}, + }}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + input, notes, err := FetchHealthInput(workflowTestContext(server), HealthFetchOptions{ + StaleDays: 30, + IncludeCI: true, + IncludeRelease: true, + IncludeDocs: true, + }) + if err != nil { + t.Fatalf("FetchHealthInput returned error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("notes = %v, want empty", notes) + } + if input.Repository != "owner/repo" { + t.Fatalf("Repository = %q, want owner/repo", input.Repository) + } + if input.OpenIssues != 2 || input.StaleIssues != 1 { + t.Fatalf("issues = open %d stale %d, want open 2 stale 1", input.OpenIssues, input.StaleIssues) + } + if input.OpenPRs != 1 || input.StalePRs != 1 { + t.Fatalf("prs = open %d stale %d, want open 1 stale 1", input.OpenPRs, input.StalePRs) + } + if !input.ReleaseKnown || !input.HasRecentRelease { + t.Fatalf("release signals = known %v recent %v, want true true", input.ReleaseKnown, input.HasRecentRelease) + } + if !input.CIKnown || !input.CIPassing { + t.Fatalf("ci signals = known %v passing %v, want true true", input.CIKnown, input.CIPassing) + } + if !input.HasReadme || !input.HasLicense || !input.HasContributing { + t.Fatalf("doc signals = readme %v license %v contributing %v, want all true", input.HasReadme, input.HasLicense, input.HasContributing) + } + if !input.RecentActivityKnown || input.RecentActivityDays > 3 { + t.Fatalf("recent activity = known %v days %d, want known and <= 3", input.RecentActivityKnown, input.RecentActivityDays) + } +} + +func TestFetchHealthInputToleratesOptionalProbeFailures(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.json": + writeWorkflowJSON(t, w, map[string]interface{}{ + "name": "repo", + "has_readme": true, + "has_license": true, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json": + writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{}}) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json": + writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{}}) + case r.Method == "GET" && (r.URL.Path == "/owner/repo/releases.json" || r.URL.Path == "/owner/repo/builds.json"): + http.Error(w, "temporary failure", http.StatusInternalServerError) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + input, notes, err := FetchHealthInput(workflowTestContext(server), HealthFetchOptions{ + StaleDays: 30, + IncludeCI: true, + IncludeRelease: true, + IncludeDocs: true, + }) + if err != nil { + t.Fatalf("FetchHealthInput returned error: %v", err) + } + if input.Repository != "owner/repo" { + t.Fatalf("Repository = %q, want owner/repo", input.Repository) + } + if input.ReleaseKnown { + t.Fatal("ReleaseKnown = true, want false after release probe failure") + } + if input.CIKnown { + t.Fatal("CIKnown = true, want false after CI probe failure") + } + if len(notes) == 0 { + t.Fatal("notes is empty, want scoring notes for failed optional probes") + } +} + +func TestFetchHealthInputHandlesMissingRepoActivityAndDocGaps(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.json": + writeWorkflowJSON(t, w, map[string]interface{}{ + "name": "repo", + "has_readme": true, + "has_license": false, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json": + writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{}}) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json": + writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{}}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + input, notes, err := FetchHealthInput(workflowTestContext(server), HealthFetchOptions{ + StaleDays: 30, + IncludeCI: false, + IncludeDocs: true, + }) + if err != nil { + t.Fatalf("FetchHealthInput returned error: %v", err) + } + if input.RecentActivityKnown { + t.Fatal("RecentActivityKnown = true, want false when updated_at is missing and no activity lists carry timestamps") + } + if len(notes) == 0 { + t.Fatal("notes is empty, want scoring notes for missing repo signals") + } +} + +func TestFetchHealthInputUsesAlternativeActivityFieldsAndDefaultStaleDays(t *testing.T) { + now := time.Now().UTC() + fresh := now.AddDate(0, 0, -2) + stale := now.AddDate(0, 0, -40) + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo.json": + writeWorkflowJSON(t, w, map[string]interface{}{"name": "repo"}) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json": + writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{ + {"id": 1, "updatedAt": fresh.Format(time.RFC3339)}, + {"id": 2, "last_activity_at": stale.Format(time.RFC3339)}, + }}) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json": + writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{ + {"id": 3, "merged_at": fresh.Format(time.RFC3339)}, + {"id": 4, "closed_at": stale.Format(time.RFC3339)}, + }}) + case r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json": + writeWorkflowJSON(t, w, map[string]interface{}{"releases": []map[string]interface{}{}}) + case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json": + writeWorkflowJSON(t, w, map[string]interface{}{"builds": []map[string]interface{}{ + {"id": 5, "status": "success"}, + }}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + for _, staleDays := range []int{0, -5} { + t.Run("stale-days", func(t *testing.T) { + input, notes, err := FetchHealthInput(workflowTestContext(server), HealthFetchOptions{ + StaleDays: staleDays, + IncludeRelease: true, + IncludeCI: true, + }) + if err != nil { + t.Fatalf("FetchHealthInput returned error: %v", err) + } + if input.StaleIssues != 1 { + t.Fatalf("StaleIssues = %d, want 1", input.StaleIssues) + } + if input.StalePRs != 1 { + t.Fatalf("StalePRs = %d, want 1", input.StalePRs) + } + if !input.RecentActivityKnown { + t.Fatal("RecentActivityKnown = false, want true") + } + if input.RecentActivityDays > 3 { + t.Fatalf("RecentActivityDays = %d, want <= 3", input.RecentActivityDays) + } + if len(notes) != 0 { + t.Fatalf("notes = %v, want empty when release and CI probes succeed", notes) + } + }) + } +} + +func TestFetchHealthInputSupportsReleaseShapeVariants(t *testing.T) { + now := time.Now().UTC() + releasePayloads := []map[string]interface{}{ + {"releases": []map[string]interface{}{{"id": 1, "name": "v1.0.0", "created_at": now.AddDate(0, 0, -1).Format(time.RFC3339)}}}, + {"data": []map[string]interface{}{{"id": 2, "name": "v1.0.1", "updated_at": now.AddDate(0, 0, -1).Format(time.RFC3339)}}}, + } + + for i, payload := range releasePayloads { + t.Run("shape", func(t *testing.T) { + payload := payload + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo.json": + writeWorkflowJSON(t, w, map[string]interface{}{ + "name": "repo", + "updated_at": now.Format(time.RFC3339), + "has_readme": true, + "has_license": true, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json": + writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{}}) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json": + writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{}}) + case r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json": + writeWorkflowJSON(t, w, payload) + case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json": + writeWorkflowJSON(t, w, map[string]interface{}{"builds": []map[string]interface{}{ + {"id": 3, "status": "success"}, + }}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + input, notes, err := FetchHealthInput(workflowTestContext(server), HealthFetchOptions{ + StaleDays: 30, + IncludeRelease: true, + IncludeCI: true, + IncludeDocs: false, + }) + if err != nil { + t.Fatalf("FetchHealthInput returned error: %v", err) + } + if !input.ReleaseKnown || !input.HasRecentRelease { + t.Fatalf("release signals = known %v recent %v, want true true", input.ReleaseKnown, input.HasRecentRelease) + } + if input.RecentActivityDays > 1 { + t.Fatalf("RecentActivityDays = %d, want <= 1", input.RecentActivityDays) + } + if len(notes) != 0 { + t.Fatalf("notes = %v, want empty for supported release shape %d", notes, i) + } + }) + } +} + +func TestFetchHealthInputReportsCIUnavailable(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.json": + writeWorkflowJSON(t, w, map[string]interface{}{ + "name": "repo", + "has_readme": true, + "has_license": true, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json": + writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{}}) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json": + writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{}}) + case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json": + http.Error(w, "not found", http.StatusNotFound) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + input, notes, err := FetchHealthInput(workflowTestContext(server), HealthFetchOptions{ + StaleDays: 30, + IncludeCI: true, + IncludeRelease: false, + IncludeDocs: false, + }) + if err != nil { + t.Fatalf("FetchHealthInput returned error: %v", err) + } + if input.CIKnown { + t.Fatal("CIKnown = true, want false for CI probe failure") + } + if len(notes) == 0 { + t.Fatal("notes is empty, want note for unavailable CI") + } + joined := "" + for _, note := range notes { + joined += note.Metric + " " + note.Note + "\n" + } + if !strings.Contains(joined, "ci_status") { + t.Fatalf("notes = %v, want ci_status note", notes) + } +} diff --git a/shortcuts/workflow/health_score.go b/shortcuts/workflow/health_score.go new file mode 100644 index 0000000..771d6dd --- /dev/null +++ b/shortcuts/workflow/health_score.go @@ -0,0 +1,246 @@ +package workflow + +func ScoreHealth(input HealthInput, lang string) HealthResult { + lang = normalizeLang(lang) + + metrics := []HealthMetric{} + notes := []ScoringNote{} + recommendations := []string{} + + issueMetric := scoreIssueBacklog(input, lang) + metrics = append(metrics, issueMetric) + if issueMetric.Score < issueMetric.MaxScore { + recommendations = append(recommendations, message(lang, "rec_reduce_issues")) + } + + prMetric := scorePRBacklog(input, lang) + metrics = append(metrics, prMetric) + if prMetric.Score < prMetric.MaxScore { + recommendations = append(recommendations, message(lang, "rec_reduce_prs")) + } + + recentMetric, recentNote := scoreRecentActivity(input, lang) + metrics = append(metrics, recentMetric) + if recentNote.Note != "" { + notes = append(notes, recentNote) + } + if recentMetric.Score < recentMetric.MaxScore/2 { + recommendations = append(recommendations, message(lang, "rec_restore_activity")) + } + + releaseMetric, releaseNote := scoreReleaseStatus(input, lang) + metrics = append(metrics, releaseMetric) + if releaseNote.Note != "" { + notes = append(notes, releaseNote) + } + if releaseMetric.Score < releaseMetric.MaxScore/2 { + recommendations = append(recommendations, message(lang, "rec_release")) + } + + docMetric := scoreDocumentation(input, lang) + metrics = append(metrics, docMetric) + if docMetric.Score < docMetric.MaxScore { + recommendations = append(recommendations, message(lang, "rec_docs")) + } + + licenseMetric := scoreLicenseAndContributing(input, lang) + metrics = append(metrics, licenseMetric) + if licenseMetric.Score < licenseMetric.MaxScore { + recommendations = append(recommendations, message(lang, "rec_license")) + } + + agentMetric, agentNote := scoreAgentReadiness(input, lang) + metrics = append(metrics, agentMetric) + if agentNote.Note != "" { + notes = append(notes, agentNote) + } + if agentMetric.Score < agentMetric.MaxScore/2 { + recommendations = append(recommendations, message(lang, "rec_agent")) + } + + ciMetric, ciNote := scoreCIStatus(input, lang) + metrics = append(metrics, ciMetric) + if ciNote.Note != "" { + notes = append(notes, ciNote) + } + + total := 0 + maxTotal := 0 + for _, metric := range metrics { + total += metric.Score + maxTotal += metric.MaxScore + } + + healthScore := 0 + if maxTotal > 0 { + healthScore = clampInt(total*100/maxTotal, 0, 100) + } + if len(recommendations) == 0 { + recommendations = append(recommendations, message(lang, "rec_maintain")) + } + + return HealthResult{ + Repository: input.Repository, + HealthScore: healthScore, + RiskLevel: riskLevel(healthScore), + Metrics: metrics, + Recommendations: uniqueStrings(recommendations), + ScoringNotes: notes, + } +} + +func scoreIssueBacklog(input HealthInput, lang string) HealthMetric { + score := 20 + score -= minInt(input.StaleIssues*3, 14) + if input.OpenIssues > 50 { + score -= 8 + } else if input.OpenIssues > 20 { + score -= 5 + } else if input.OpenIssues > 10 { + score -= 2 + } + score = clampInt(score, 0, 20) + + status := "good" + reason := message(lang, "health_issue_backlog_good") + if score < 14 { + status = "attention" + reason = message(lang, "health_issue_backlog_attention") + } + return HealthMetric{Name: "issue_backlog_and_response", Status: status, Score: score, MaxScore: 20, Reason: reason} +} + +func scorePRBacklog(input HealthInput, lang string) HealthMetric { + score := 20 + score -= minInt(input.StalePRs*5, 15) + if input.OpenPRs > 20 { + score -= 8 + } else if input.OpenPRs > 10 { + score -= 5 + } else if input.OpenPRs > 5 { + score -= 2 + } + score = clampInt(score, 0, 20) + + status := "good" + reason := message(lang, "health_pr_backlog_good") + if score < 14 { + status = "attention" + reason = message(lang, "health_pr_backlog_attention") + } + return HealthMetric{Name: "pr_backlog_and_merge_state", Status: status, Score: score, MaxScore: 20, Reason: reason} +} + +func scoreRecentActivity(input HealthInput, lang string) (HealthMetric, ScoringNote) { + if !input.RecentActivityKnown { + return HealthMetric{Name: "recent_activity", Status: "unknown", Score: 8, MaxScore: 15, Reason: message(lang, "health_recent_unknown")}, ScoringNote{Metric: "recent_activity", Note: message(lang, "health_recent_unknown")} + } + if input.RecentActivityDays <= 7 { + return HealthMetric{Name: "recent_activity", Status: "good", Score: 15, MaxScore: 15, Reason: "recent activity within 7 days"}, ScoringNote{} + } + if input.RecentActivityDays <= 30 { + return HealthMetric{Name: "recent_activity", Status: "attention", Score: 10, MaxScore: 15, Reason: "recent activity within 30 days"}, ScoringNote{} + } + if input.RecentActivityDays <= 90 { + return HealthMetric{Name: "recent_activity", Status: "attention", Score: 6, MaxScore: 15, Reason: "recent activity older than 30 days"}, ScoringNote{} + } + return HealthMetric{Name: "recent_activity", Status: "risk", Score: 2, MaxScore: 15, Reason: "recent activity older than 90 days"}, ScoringNote{} +} + +func scoreReleaseStatus(input HealthInput, lang string) (HealthMetric, ScoringNote) { + if !input.ReleaseKnown { + return HealthMetric{Name: "release_status", Status: "unknown", Score: 8, MaxScore: 15, Reason: message(lang, "health_release_unknown")}, ScoringNote{Metric: "release_status", Note: message(lang, "health_release_unknown")} + } + if input.HasRecentRelease { + return HealthMetric{Name: "release_status", Status: "good", Score: 15, MaxScore: 15, Reason: "recent release found"}, ScoringNote{} + } + return HealthMetric{Name: "release_status", Status: "risk", Score: 4, MaxScore: 15, Reason: "no recent release found"}, ScoringNote{} +} + +func scoreDocumentation(input HealthInput, lang string) HealthMetric { + score := 0 + if input.HasReadme { + score += 7 + } + if input.HasContributing { + score += 3 + } + status := "attention" + reason := message(lang, "rec_docs") + if score == 10 { + status = "good" + reason = "README and contribution guidance are present" + } + return HealthMetric{Name: "documentation", Status: status, Score: score, MaxScore: 10, Reason: reason} +} + +func scoreLicenseAndContributing(input HealthInput, lang string) HealthMetric { + score := 0 + if input.HasLicense { + score += 6 + } + if input.HasContributing { + score += 4 + } + status := "attention" + reason := message(lang, "rec_license") + if score == 10 { + status = "good" + reason = "LICENSE and CONTRIBUTING are present" + } + return HealthMetric{Name: "license_and_contributing", Status: status, Score: score, MaxScore: 10, Reason: reason} +} + +func scoreAgentReadiness(input HealthInput, lang string) (HealthMetric, ScoringNote) { + if !input.AgentReadinessKnown { + return HealthMetric{Name: "agent_readiness", Status: "unknown", Score: 5, MaxScore: 10, Reason: message(lang, "health_agent_unknown")}, ScoringNote{Metric: "agent_readiness", Note: message(lang, "health_agent_unknown")} + } + score := clampInt(input.AgentReadinessScore, 0, 10) + status := "attention" + if score >= 8 { + status = "good" + } else if score < 4 { + status = "risk" + } + return HealthMetric{Name: "agent_readiness", Status: status, Score: score, MaxScore: 10, Reason: "agent readiness score provided"}, ScoringNote{} +} + +func scoreCIStatus(input HealthInput, lang string) (HealthMetric, ScoringNote) { + if !input.CIKnown { + return HealthMetric{Name: "ci_status", Status: "unknown", Score: 0, MaxScore: 0, Reason: message(lang, "health_ci_unknown")}, ScoringNote{Metric: "ci_status", Note: message(lang, "health_ci_unknown")} + } + if input.CIPassing { + return HealthMetric{Name: "ci_status", Status: "good", Score: 0, MaxScore: 0, Reason: "CI status is passing"}, ScoringNote{} + } + return HealthMetric{Name: "ci_status", Status: "risk", Score: 0, MaxScore: 0, Reason: "CI status is failing"}, ScoringNote{} +} + +func riskLevel(score int) string { + switch { + case score >= 85: + return "low" + case score >= 65: + return "medium" + case score >= 40: + return "high" + default: + return "critical" + } +} + +func clampInt(value int, minValue int, maxValue int) int { + if value < minValue { + return minValue + } + if value > maxValue { + return maxValue + } + return value +} + +func minInt(a int, b int) int { + if a < b { + return a + } + return b +} diff --git a/shortcuts/workflow/health_score_test.go b/shortcuts/workflow/health_score_test.go new file mode 100644 index 0000000..13ddcc1 --- /dev/null +++ b/shortcuts/workflow/health_score_test.go @@ -0,0 +1,113 @@ +package workflow + +import ( + "strings" + "testing" +) + +func TestScoreHealthLowRisk(t *testing.T) { + result := ScoreHealth(HealthInput{ + Repository: "Gitlink/gitlink-cli", + OpenIssues: 4, + OpenPRs: 2, + StaleIssues: 1, + StalePRs: 0, + RecentActivityKnown: true, + RecentActivityDays: 3, + ReleaseKnown: true, + HasRecentRelease: true, + CIKnown: true, + CIPassing: true, + HasReadme: true, + HasLicense: true, + HasContributing: true, + AgentReadinessKnown: true, + AgentReadinessScore: 9, + }, "en") + + if result.HealthScore < 85 { + t.Fatalf("HealthScore = %d, want >= 85", result.HealthScore) + } + if result.RiskLevel != "low" { + t.Fatalf("RiskLevel = %q, want low", result.RiskLevel) + } +} + +func TestScoreHealthHighRisk(t *testing.T) { + result := ScoreHealth(HealthInput{ + Repository: "Gitlink/gitlink-cli", + OpenIssues: 80, + OpenPRs: 25, + StaleIssues: 20, + StalePRs: 10, + RecentActivityKnown: true, + RecentActivityDays: 120, + ReleaseKnown: true, + HasRecentRelease: false, + CIKnown: true, + CIPassing: false, + HasReadme: false, + HasLicense: false, + HasContributing: false, + AgentReadinessKnown: true, + AgentReadinessScore: 2, + }, "en") + + if result.HealthScore >= 65 { + t.Fatalf("HealthScore = %d, want < 65", result.HealthScore) + } + if result.RiskLevel != "high" && result.RiskLevel != "critical" { + t.Fatalf("RiskLevel = %q, want high or critical", result.RiskLevel) + } + if len(result.Recommendations) == 0 { + t.Fatal("Recommendations is empty") + } +} + +func TestScoreHealthUnknownMetrics(t *testing.T) { + result := ScoreHealth(HealthInput{ + Repository: "Gitlink/gitlink-cli", + RecentActivityKnown: false, + ReleaseKnown: false, + CIKnown: false, + HasReadme: true, + HasLicense: true, + HasContributing: false, + AgentReadinessKnown: false, + }, "en") + + if len(result.ScoringNotes) == 0 { + t.Fatal("ScoringNotes is empty") + } + if result.HealthScore < 0 || result.HealthScore > 100 { + t.Fatalf("HealthScore = %d, want between 0 and 100", result.HealthScore) + } +} + +func TestScoreHealthChinese(t *testing.T) { + result := ScoreHealth(HealthInput{ + Repository: "Gitlink/gitlink-cli", + OpenIssues: 40, + OpenPRs: 12, + StaleIssues: 8, + StalePRs: 4, + RecentActivityKnown: true, + RecentActivityDays: 45, + ReleaseKnown: true, + HasRecentRelease: false, + CIKnown: false, + HasReadme: false, + HasLicense: false, + HasContributing: false, + AgentReadinessKnown: true, + AgentReadinessScore: 3, + }, "zh-CN") + + if len(result.Recommendations) == 0 { + t.Fatal("Recommendations is empty") + } + joined := strings.Join(result.Recommendations, "") + if !strings.Contains(joined, "建议") && !strings.Contains(joined, "补充") && !strings.Contains(joined, "减少") { + t.Fatalf("Recommendations = %v, want Chinese content", result.Recommendations) + } +} diff --git a/shortcuts/workflow/messages.go b/shortcuts/workflow/messages.go new file mode 100644 index 0000000..f7d9966 --- /dev/null +++ b/shortcuts/workflow/messages.go @@ -0,0 +1,102 @@ +package workflow + +func normalizeLang(lang string) string { + switch lang { + case "", langEN: + return langEN + case langZH: + return langZH + default: + return langEN + } +} + +func message(lang string, key string) string { + lang = normalizeLang(lang) + if value, ok := messages[lang][key]; ok { + return value + } + if value, ok := messages[langEN][key]; ok { + return value + } + return key +} + +var messages = map[string]map[string]string{ + langEN: { + "missing_reproduction_steps": "reproduction_steps", + "missing_expected_behavior": "expected_behavior", + "missing_actual_behavior": "actual_behavior", + "missing_version": "version", + "missing_os_or_platform": "os_or_platform", + "missing_command_output_or_logs": "command_output_or_logs", + "comment_more_info": "Thanks for the report. Please add the missing information so maintainers can reproduce and investigate it: %s.", + "comment_security": "Thanks for the security report. Please avoid sharing secrets publicly and rotate any exposed credentials. Maintainers should verify the sensitive details in a private channel.", + "comment_docs": "Thanks for the documentation report. Please point to the affected document or example if possible.", + "comment_default": "Thanks for the report. Maintainers can use the triage result above to decide the next step.", + "health_issue_backlog_good": "Issue backlog is under control.", + "health_issue_backlog_attention": "Reduce stale or excessive open issues.", + "health_pr_backlog_good": "Pull request backlog is under control.", + "health_pr_backlog_attention": "Review stale or excessive open pull requests.", + "health_recent_unknown": "Recent activity is unknown and was scored conservatively.", + "health_release_unknown": "Release status is unknown and was scored conservatively.", + "health_ci_unknown": "CI status is unknown and is reported without changing the score.", + "health_agent_unknown": "Agent readiness is unknown and was scored conservatively.", + "rec_maintain": "Maintain the current workflow and keep metadata up to date.", + "rec_reduce_issues": "Reduce stale issues and add response labels or next actions.", + "rec_reduce_prs": "Review stale pull requests and clarify merge blockers.", + "rec_restore_activity": "Create recent maintenance activity or document project status.", + "rec_release": "Publish or document a recent release cadence.", + "rec_docs": "Add or improve README and contribution guidance.", + "rec_license": "Add LICENSE and CONTRIBUTING files for contributor clarity.", + "rec_agent": "Improve agent readiness with stable docs, examples, and machine-readable outputs.", + "pr_summary_title": "PR Review Summary", + "pr_summary_overview": "Overview", + "pr_summary_review_focus": "Review Focus", + "pr_summary_test_suggestions": "Test Suggestions", + "pr_summary_merge_checklist": "Merge Checklist", + "pr_summary_reasoning": "Reasoning", + "pr_summary_no_focus": "No specific review focus identified.", + "pr_summary_no_suggestions": "No extra test suggestions.", + "pr_summary_no_checklist": "No extra merge checklist items.", + "pr_summary_no_reasoning": "No additional reasoning.", + }, + langZH: { + "missing_reproduction_steps": "复现步骤", + "missing_expected_behavior": "期望行为", + "missing_actual_behavior": "实际行为", + "missing_version": "版本信息", + "missing_os_or_platform": "操作系统或平台", + "missing_command_output_or_logs": "命令输出或日志", + "comment_more_info": "感谢反馈。请补充以下信息,方便维护者复现和定位问题:%s。", + "comment_security": "感谢安全反馈。请不要公开扩散密钥或敏感信息,并尽快轮换可能泄露的凭据。维护者应优先通过私密渠道确认细节。", + "comment_docs": "感谢文档反馈。请尽量说明受影响的文档、示例或章节位置。", + "comment_default": "感谢反馈。维护者可以根据上面的分诊结果安排下一步处理。", + "health_issue_backlog_good": "Issue 积压处于可控状态。", + "health_issue_backlog_attention": "建议减少长期未处理或数量过多的开放 Issue。", + "health_pr_backlog_good": "PR 积压处于可控状态。", + "health_pr_backlog_attention": "建议审查长期未处理或数量过多的开放 PR。", + "health_recent_unknown": "最近活跃度未知,已按保守方式评分。", + "health_release_unknown": "Release 状态未知,已按保守方式评分。", + "health_ci_unknown": "CI 状态未知,仅记录为说明,不影响总分。", + "health_agent_unknown": "Agent 友好度未知,已按保守方式评分。", + "rec_maintain": "保持当前维护节奏,并持续更新仓库元信息。", + "rec_reduce_issues": "减少长期未处理的 Issue,并补充响应标签或下一步动作。", + "rec_reduce_prs": "审查长期未处理的 PR,并明确合并阻塞点。", + "rec_restore_activity": "恢复近期维护活动,或在文档中说明项目状态。", + "rec_release": "发布近期版本,或在文档中说明发布节奏。", + "rec_docs": "补充或改进 README 与贡献指南。", + "rec_license": "补充 LICENSE 和 CONTRIBUTING,降低贡献者理解成本。", + "rec_agent": "通过稳定文档、示例和机器可读输出提升 Agent 友好度。", + "pr_summary_title": "PR 审阅摘要", + "pr_summary_overview": "概览", + "pr_summary_review_focus": "审查重点", + "pr_summary_test_suggestions": "测试建议", + "pr_summary_merge_checklist": "合并检查清单", + "pr_summary_reasoning": "判断依据", + "pr_summary_no_focus": "未识别到明确审查重点。", + "pr_summary_no_suggestions": "暂无额外测试建议。", + "pr_summary_no_checklist": "暂无额外合并检查项。", + "pr_summary_no_reasoning": "暂无额外判断依据。", + }, +} diff --git a/shortcuts/workflow/messages_test.go b/shortcuts/workflow/messages_test.go new file mode 100644 index 0000000..d998c6b --- /dev/null +++ b/shortcuts/workflow/messages_test.go @@ -0,0 +1,24 @@ +package workflow + +import "testing" + +func TestNormalizeLang(t *testing.T) { + if got := normalizeLang(""); got != langEN { + t.Fatalf("normalizeLang(\"\") = %q, want %q", got, langEN) + } + if got := normalizeLang(langZH); got != langZH { + t.Fatalf("normalizeLang(%q) = %q, want %q", langZH, got, langZH) + } + if got := normalizeLang("fr"); got != langEN { + t.Fatalf("normalizeLang(\"fr\") = %q, want %q", got, langEN) + } +} + +func TestMessageFallback(t *testing.T) { + if got := message("fr", "rec_maintain"); got == "" || got == "rec_maintain" { + t.Fatalf("message fallback = %q, want English message", got) + } + if got := message(langEN, "not_found_key"); got != "not_found_key" { + t.Fatalf("message unknown key = %q, want key", got) + } +} diff --git a/shortcuts/workflow/pr_fetch.go b/shortcuts/workflow/pr_fetch.go new file mode 100644 index 0000000..e8a854f --- /dev/null +++ b/shortcuts/workflow/pr_fetch.go @@ -0,0 +1,333 @@ +package workflow + +import ( + "fmt" + "net/url" + "strings" + "time" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type PRFetchOptions struct { + Owner string + Repo string + Number int + IncludeFiles bool + IncludeCommits bool + MaxFiles int + MaxCommits int +} + +func FetchPRSummaryInput(ctx *common.RuntimeContext, opts PRFetchOptions) (PRSummaryInput, []ScoringNote, error) { + owner, repo, err := resolveFetchRepo(ctx, opts.Owner, opts.Repo) + if err != nil { + return PRSummaryInput{}, nil, err + } + if opts.Number <= 0 { + return PRSummaryInput{}, nil, fmt.Errorf("pull request number is required") + } + if opts.MaxFiles <= 0 { + opts.MaxFiles = 100 + } + if opts.MaxCommits <= 0 { + opts.MaxCommits = 100 + } + + input, err := fetchPRBase(ctx, owner, repo, opts.Number) + if err != nil { + return PRSummaryInput{}, nil, err + } + input.Repository = fmt.Sprintf("%s/%s", owner, repo) + input.Number = opts.Number + input.Source = "remote-read-only-fetch" + + notes := []ScoringNote{} + if opts.IncludeFiles { + files, err := fetchPRFiles(ctx, owner, repo, opts.Number, opts.MaxFiles) + if err != nil { + notes = append(notes, ScoringNote{Metric: "pr_files", Note: fmt.Sprintf("changed files probe failed: %v", err)}) + } else { + input.ChangedFiles = files + fillPRLineTotals(&input) + } + } + if opts.IncludeCommits { + commits, err := fetchPRCommits(ctx, owner, repo, opts.Number, opts.MaxCommits) + if err != nil { + notes = append(notes, ScoringNote{Metric: "pr_commits", Note: fmt.Sprintf("commits probe failed: %v", err)}) + } else { + input.Commits = commits + } + } + + return input, notes, nil +} + +func fetchPRBase(ctx *common.RuntimeContext, owner, repo string, number int) (PRSummaryInput, error) { + env, err := ctx.CallAPI("GET", prPath(owner, repo, number), nil) + if err != nil { + return PRSummaryInput{}, fmt.Errorf("fetch pull request summary: %w", err) + } + item := prAPIObject(env.Data) + if item == nil { + return PRSummaryInput{}, fmt.Errorf("fetch pull request summary: PR response did not contain an object") + } + input, ok := normalizePRSummaryItem(item) + if !ok { + return PRSummaryInput{}, fmt.Errorf("fetch pull request summary: PR response missing title or number") + } + return input, nil +} + +func fetchPRFiles(ctx *common.RuntimeContext, owner, repo string, number, limit int) ([]PRChangedFile, error) { + query := url.Values{} + query.Set("page", "1") + query.Set("limit", fmt.Sprintf("%d", limit)) + env, err := ctx.CallAPIWithQuery("GET", prPath(owner, repo, number)+"/files", query) + if err != nil { + return nil, err + } + rawItems := apiList(env.Data) + files := make([]PRChangedFile, 0, len(rawItems)) + for _, raw := range rawItems { + file, ok := normalizePRChangedFile(raw) + if !ok { + continue + } + files = append(files, file) + if len(files) >= limit { + break + } + } + return files, nil +} + +func fetchPRCommits(ctx *common.RuntimeContext, owner, repo string, number, limit int) ([]PRCommit, error) { + query := url.Values{} + query.Set("page", "1") + query.Set("limit", fmt.Sprintf("%d", limit)) + env, err := ctx.CallAPIWithQuery("GET", prPath(owner, repo, number)+"/commits", query) + if err != nil { + return nil, err + } + rawItems := apiList(env.Data) + commits := make([]PRCommit, 0, len(rawItems)) + for _, raw := range rawItems { + commit, ok := normalizePRCommit(raw) + if !ok { + continue + } + commits = append(commits, commit) + if len(commits) >= limit { + break + } + } + return commits, nil +} + +func prPath(owner, repo string, number int) string { + return fmt.Sprintf("%s/pulls/%d", workflowRepoPath(owner, repo), number) +} + +func prAPIObject(data interface{}) map[string]interface{} { + normalized, err := normalizeAPIData(data) + if err != nil { + return nil + } + switch value := normalized.(type) { + case map[string]interface{}: + for _, key := range []string{"pull_request", "data", "pr"} { + if raw, ok := value[key]; ok { + if item := prAPIObject(raw); item != nil { + return item + } + } + } + return value + case []interface{}: + if len(value) == 1 { + if item, ok := value[0].(map[string]interface{}); ok { + return item + } + } + } + return nil +} + +func normalizePRSummaryItem(item map[string]interface{}) (PRSummaryInput, bool) { + number := firstPRInt(item, "number", "iid", "pull_request_number") + title := firstPRString(item, "title", "subject") + if number == 0 && strings.TrimSpace(title) == "" { + return PRSummaryInput{}, false + } + body := firstPRString(item, "body", "description", "content") + state := firstPRString(item, "state", "status") + author := firstPRAuthor(item) + base := firstPRBranch(item, "base_branch", "target_branch", "base") + head := firstPRBranch(item, "head_branch", "source_branch", "head") + additions := firstPRInt(item, "additions", "additions_count") + deletions := firstPRInt(item, "deletions", "deletions_count") + + return PRSummaryInput{ + Number: number, + Title: title, + Author: author, + State: state, + BaseBranch: base, + HeadBranch: head, + Body: body, + Additions: additions, + Deletions: deletions, + }, true +} + +func normalizePRChangedFile(raw interface{}) (PRChangedFile, bool) { + item, ok := raw.(map[string]interface{}) + if !ok { + return PRChangedFile{}, false + } + filename := firstPRString(item, "filename", "file", "path", "new_path") + if strings.TrimSpace(filename) == "" { + return PRChangedFile{}, false + } + additions := firstPRInt(item, "additions", "additions_count") + deletions := firstPRInt(item, "deletions", "deletions_count") + changes := firstPRInt(item, "changes", "total_changes") + if changes == 0 { + changes = additions + deletions + } + return PRChangedFile{ + Filename: filename, + Status: firstPRString(item, "status", "state"), + Additions: additions, + Deletions: deletions, + Changes: changes, + Patch: firstPRString(item, "patch", "diff"), + }, true +} + +func normalizePRCommit(raw interface{}) (PRCommit, bool) { + item, ok := raw.(map[string]interface{}) + if !ok { + return PRCommit{}, false + } + sha := firstPRString(item, "sha", "id") + message := firstPRString(item, "message", "title") + if strings.TrimSpace(sha) == "" && strings.TrimSpace(message) == "" { + return PRCommit{}, false + } + return PRCommit{ + SHA: sha, + Message: firstLine(message), + Author: firstPRCommitAuthor(item), + Date: firstPRTime(item, "date", "committed_at", "created_at"), + }, true +} + +func fillPRLineTotals(input *PRSummaryInput) { + if input == nil || len(input.ChangedFiles) == 0 { + return + } + additions := 0 + deletions := 0 + for _, file := range input.ChangedFiles { + additions += file.Additions + deletions += file.Deletions + } + if input.Additions == 0 { + input.Additions = additions + } + if input.Deletions == 0 { + input.Deletions = deletions + } +} + +func firstPRString(item map[string]interface{}, keys ...string) string { + for _, key := range keys { + if value, ok := item[key]; ok { + if s := apiString(value); s != "" { + return s + } + } + } + return "" +} + +func firstPRInt(item map[string]interface{}, keys ...string) int { + for _, key := range keys { + if value, ok := item[key]; ok { + if n := apiInt(value); n != 0 { + return n + } + } + } + return 0 +} + +func firstPRTime(item map[string]interface{}, keys ...string) time.Time { + for _, key := range keys { + if value, ok := item[key]; ok { + if t := apiTime(value); !t.IsZero() { + return t + } + } + } + return time.Time{} +} + +func firstPRAuthor(item map[string]interface{}) string { + for _, key := range []string{"author", "user", "creator"} { + if value, ok := item[key]; ok { + if s := apiAuthor(value); s != "" { + return s + } + } + } + return "" +} + +func firstPRCommitAuthor(item map[string]interface{}) string { + for _, key := range []string{"author", "committer", "user", "creator"} { + if value, ok := item[key]; ok { + if s := apiAuthor(value); s != "" { + return s + } + } + } + return "" +} + +func firstPRBranch(item map[string]interface{}, keys ...string) string { + for _, key := range keys { + if value, ok := item[key]; ok { + if s := prBranchString(value); s != "" { + return s + } + } + } + return "" +} + +func prBranchString(value interface{}) string { + switch typed := value.(type) { + case map[string]interface{}: + for _, key := range []string{"ref", "name", "branch", "title"} { + if s := apiString(typed[key]); s != "" { + return s + } + } + return "" + default: + return apiString(value) + } +} + +func firstLine(value string) string { + value = strings.TrimSpace(value) + if value == "" { + return "" + } + lines := strings.Split(value, "\n") + return strings.TrimSpace(lines[0]) +} diff --git a/shortcuts/workflow/pr_fetch_test.go b/shortcuts/workflow/pr_fetch_test.go new file mode 100644 index 0000000..3a2e893 --- /dev/null +++ b/shortcuts/workflow/pr_fetch_test.go @@ -0,0 +1,216 @@ +package workflow + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestFetchPRSummaryInputNormalizesResponseShapes(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/13.json": + writeWorkflowJSON(t, w, map[string]interface{}{ + "data": map[string]interface{}{ + "pull_request_number": 13, + "title": "feat: add workflow PR summary", + "description": "Summarize pull requests without writing remote data.", + "status": "open", + "creator": map[string]interface{}{"name": "carol"}, + "target_branch": "master", + "source_branch": "feature/pr-summary", + "additions": 100, + "deletions": 4, + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/13/files.json": + if got := r.URL.Query().Get("limit"); got != "100" { + t.Fatalf("files limit = %q, want 100", got) + } + writeWorkflowJSON(t, w, map[string]interface{}{ + "files": []map[string]interface{}{ + {"new_path": "shortcuts/workflow/pr_summary.go", "status": "added", "additions": 80, "deletions": 0}, + {"filename": "docs/workflow-agent-design.md", "status": "modified", "additions": 20, "deletions": 4}, + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/13/commits.json": + writeWorkflowJSON(t, w, []map[string]interface{}{ + {"id": "abc123", "title": "feat: add workflow PR summary", "committer": map[string]interface{}{"login": "carol"}, "created_at": "2026-05-20T10:00:00Z"}, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + input, notes, err := FetchPRSummaryInput(workflowTestContext(server), PRFetchOptions{ + Number: 13, + IncludeFiles: true, + IncludeCommits: true, + MaxFiles: 100, + MaxCommits: 100, + }) + if err != nil { + t.Fatalf("FetchPRSummaryInput returned error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("notes = %v, want empty", notes) + } + if input.Repository != "owner/repo" || input.Number != 13 { + t.Fatalf("input = %+v, want owner/repo #13", input) + } + if input.Author != "carol" || input.BaseBranch != "master" || input.HeadBranch != "feature/pr-summary" { + t.Fatalf("author/branches = %q %q %q, want carol master feature/pr-summary", input.Author, input.BaseBranch, input.HeadBranch) + } + if len(input.ChangedFiles) != 2 { + t.Fatalf("len(ChangedFiles) = %d, want 2", len(input.ChangedFiles)) + } + if len(input.Commits) != 1 || input.Commits[0].SHA != "abc123" || input.Commits[0].Author != "carol" { + t.Fatalf("Commits = %+v, want normalized commit", input.Commits) + } +} + +func TestFetchPRSummaryInputPartialFilesFailure(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/2.json": + writeWorkflowJSON(t, w, map[string]interface{}{ + "pull_request": map[string]interface{}{ + "number": 2, + "title": "fix: handle API errors", + "user": map[string]interface{}{"login": "bob"}, + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/2/files.json": + http.Error(w, "files unavailable", http.StatusInternalServerError) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/2/commits.json": + writeWorkflowJSON(t, w, []map[string]interface{}{{"sha": "abc", "message": "fix: handle API errors"}}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + input, notes, err := FetchPRSummaryInput(workflowTestContext(server), PRFetchOptions{ + Number: 2, + IncludeFiles: true, + IncludeCommits: true, + MaxFiles: 10, + MaxCommits: 10, + }) + if err != nil { + t.Fatalf("FetchPRSummaryInput returned error: %v", err) + } + if input.Title != "fix: handle API errors" { + t.Fatalf("Title = %q, want base PR to remain", input.Title) + } + if len(notes) == 0 || !strings.Contains(notes[0].Metric, "pr_files") { + t.Fatalf("notes = %v, want pr_files note", notes) + } +} + +func TestFetchPRSummaryInputPartialCommitsFailure(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/3.json": + writeWorkflowJSON(t, w, map[string]interface{}{ + "number": 3, + "title": "docs: update README", + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/3/files.json": + writeWorkflowJSON(t, w, map[string]interface{}{"files": []map[string]interface{}{{"filename": "README.md"}}}) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/3/commits.json": + http.Error(w, "commits unavailable", http.StatusServiceUnavailable) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + input, notes, err := FetchPRSummaryInput(workflowTestContext(server), PRFetchOptions{ + Number: 3, + IncludeFiles: true, + IncludeCommits: true, + MaxFiles: 10, + MaxCommits: 10, + }) + if err != nil { + t.Fatalf("FetchPRSummaryInput returned error: %v", err) + } + if len(input.ChangedFiles) != 1 { + t.Fatalf("len(ChangedFiles) = %d, want 1", len(input.ChangedFiles)) + } + if len(notes) == 0 || !strings.Contains(notes[0].Metric, "pr_commits") { + t.Fatalf("notes = %v, want pr_commits note", notes) + } +} + +func TestFetchPRSummaryInputReportsErrorInBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeWorkflowJSON(t, w, map[string]interface{}{ + "status": 403, + "message": "permission denied", + }) + })) + defer server.Close() + + _, _, err := FetchPRSummaryInput(workflowTestContext(server), PRFetchOptions{Number: 9, IncludeFiles: true}) + if err == nil { + t.Fatal("FetchPRSummaryInput returned nil error for error-in-body") + } + if !strings.Contains(err.Error(), "permission denied") { + t.Fatalf("error = %v, want permission denied", err) + } +} + +func TestFetchPRSummaryInputRespectsLimits(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/4.json": + writeWorkflowJSON(t, w, map[string]interface{}{ + "data": map[string]interface{}{"number": 4, "title": "feat: limit lists"}, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/4/files.json": + if got := r.URL.Query().Get("limit"); got != "1" { + t.Fatalf("files limit = %q, want 1", got) + } + writeWorkflowJSON(t, w, map[string]interface{}{ + "files": []map[string]interface{}{ + {"filename": "one.go"}, + {"filename": "two.go"}, + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/4/commits.json": + if got := r.URL.Query().Get("limit"); got != "1" { + t.Fatalf("commits limit = %q, want 1", got) + } + writeWorkflowJSON(t, w, map[string]interface{}{ + "commits": []map[string]interface{}{ + {"sha": "one", "message": "feat: first"}, + {"sha": "two", "message": "feat: second"}, + }, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + input, notes, err := FetchPRSummaryInput(workflowTestContext(server), PRFetchOptions{ + Number: 4, + IncludeFiles: true, + IncludeCommits: true, + MaxFiles: 1, + MaxCommits: 1, + }) + if err != nil { + t.Fatalf("FetchPRSummaryInput returned error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("notes = %v, want empty", notes) + } + if len(input.ChangedFiles) != 1 || len(input.Commits) != 1 { + t.Fatalf("files/commits lengths = %d/%d, want 1/1", len(input.ChangedFiles), len(input.Commits)) + } +} diff --git a/shortcuts/workflow/pr_summary.go b/shortcuts/workflow/pr_summary.go new file mode 100644 index 0000000..398bf1e --- /dev/null +++ b/shortcuts/workflow/pr_summary.go @@ -0,0 +1,676 @@ +package workflow + +import ( + "encoding/json" + "fmt" + "os" + "strings" + "time" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +const ( + PRChangeTypeDocs = "docs" + PRChangeTypeTest = "test" + PRChangeTypeFeature = "feature" + PRChangeTypeFix = "fix" + PRChangeTypeRefactor = "refactor" + PRChangeTypeCI = "ci" + PRChangeTypeMixed = "mixed" + PRChangeTypeUnknown = "unknown" +) + +const ( + PRRiskLow = "low" + PRRiskMedium = "medium" + PRRiskHigh = "high" + PRRiskCritical = "critical" +) + +type PRSummaryInput struct { + Repository string `json:"repository"` + Number int `json:"number"` + Title string `json:"title"` + Author string `json:"author"` + State string `json:"state"` + BaseBranch string `json:"base_branch"` + HeadBranch string `json:"head_branch"` + Body string `json:"body,omitempty"` + ChangedFiles []PRChangedFile `json:"changed_files"` + Commits []PRCommit `json:"commits"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Source string `json:"source"` +} + +type PRChangedFile struct { + Filename string `json:"filename"` + Status string `json:"status"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + Changes int `json:"changes"` + Patch string `json:"patch,omitempty"` +} + +type PRCommit struct { + SHA string `json:"sha"` + Message string `json:"message"` + Author string `json:"author"` + Date time.Time `json:"date,omitempty"` +} + +type PRSummaryResult struct { + Repository string `json:"repository"` + Number int `json:"number"` + Title string `json:"title"` + Author string `json:"author"` + State string `json:"state"` + BaseBranch string `json:"base_branch"` + HeadBranch string `json:"head_branch"` + ChangedFilesCount int `json:"changed_files_count"` + Additions int `json:"additions"` + Deletions int `json:"deletions"` + CommitCount int `json:"commit_count"` + ChangeType string `json:"change_type"` + RiskLevel string `json:"risk_level"` + ReviewFocus []string `json:"review_focus"` + TestSuggestions []string `json:"test_suggestions"` + MergeChecklist []string `json:"merge_checklist"` + Reasoning []string `json:"reasoning"` + Source string `json:"source"` +} + +func newPRSummaryShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "pr-summary", + Description: "Generate a read-only pull request review summary", + Flags: []common.Flag{ + {Name: "from", Usage: "Read PR summary input from a JSON file"}, + {Name: "number", Short: "n", Usage: "Pull request number for remote read-only analysis"}, + {Name: "include-commits", Usage: "Fetch commits in remote mode", Bool: true, Default: "true"}, + {Name: "include-files", Usage: "Fetch changed files in remote mode", Bool: true, Default: "true"}, + {Name: "max-files", Usage: "Maximum changed files to analyze", Default: "100"}, + {Name: "max-commits", Usage: "Maximum commits to analyze", Default: "100"}, + {Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN}, + }, + Run: runPRSummary, + } +} + +func runPRSummary(ctx *common.RuntimeContext) error { + lang := normalizeLang(ctx.Arg("lang")) + + input, notes, err := collectPRSummaryInput(ctx) + if err != nil { + return err + } + + result := AnalyzePRSummary(input, lang) + for _, note := range notes { + if note.Metric == "" && note.Note == "" { + continue + } + result.Reasoning = append(result.Reasoning, fmt.Sprintf("%s: %s", note.Metric, note.Note)) + } + + format := ctx.Format + if strings.TrimSpace(cmdutil.Format) == "" { + format = "table" + } + rendered, err := RenderPRSummary(result, format, lang) + if err != nil { + return err + } + _, err = fmt.Fprint(os.Stdout, rendered) + return err +} + +func collectPRSummaryInput(ctx *common.RuntimeContext) (PRSummaryInput, []ScoringNote, error) { + if path := strings.TrimSpace(ctx.Arg("from")); path != "" { + input, err := readPRSummaryInput(path) + if err != nil { + return PRSummaryInput{}, nil, err + } + if strings.TrimSpace(input.Source) == "" { + input.Source = "local-json" + } + return input, nil, nil + } + + number, err := parseIntArg(ctx.Arg("number"), 0, "number") + if err != nil { + return PRSummaryInput{}, nil, err + } + if number <= 0 { + return PRSummaryInput{}, nil, fmt.Errorf("workflow +pr-summary requires --from pr_summary.json or --number with --owner and --repo for read-only fetch") + } + maxFiles, err := parseIntArg(ctx.Arg("max-files"), 100, "max-files") + if err != nil { + return PRSummaryInput{}, nil, err + } + maxCommits, err := parseIntArg(ctx.Arg("max-commits"), 100, "max-commits") + if err != nil { + return PRSummaryInput{}, nil, err + } + + return FetchPRSummaryInput(ctx, PRFetchOptions{ + Number: number, + IncludeFiles: parseBoolArg(ctx.Arg("include-files")), + IncludeCommits: parseBoolArg(ctx.Arg("include-commits")), + MaxFiles: maxFiles, + MaxCommits: maxCommits, + }) +} + +func readPRSummaryInput(path string) (PRSummaryInput, error) { + data, err := os.ReadFile(path) + if err != nil { + return PRSummaryInput{}, fmt.Errorf("read PR summary input: %w", err) + } + var input PRSummaryInput + if err := json.Unmarshal(data, &input); err != nil { + return PRSummaryInput{}, fmt.Errorf("parse PR summary input: %w", err) + } + if strings.TrimSpace(input.Title) == "" && input.Number == 0 { + return PRSummaryInput{}, fmt.Errorf("parse PR summary input: expected PRSummaryInput root object") + } + return input, nil +} + +func AnalyzePRSummary(input PRSummaryInput, lang string) PRSummaryResult { + lang = normalizeLang(lang) + scores, scoreReasons := scorePRChangeTypes(input) + changeType := choosePRChangeType(scores) + riskLevel, riskReasons := scorePRRisk(input, changeType) + reviewFocus := buildPRReviewFocus(input, lang, riskLevel) + testSuggestions := buildPRTestSuggestions(input, lang) + mergeChecklist := buildPRMergeChecklist(input, lang, riskLevel) + reasoning := buildPRReasoning(lang, changeType, riskLevel, scores, scoreReasons, riskReasons) + + source := strings.TrimSpace(input.Source) + if source == "" { + source = "local" + } + + return PRSummaryResult{ + Repository: input.Repository, + Number: input.Number, + Title: input.Title, + Author: input.Author, + State: input.State, + BaseBranch: input.BaseBranch, + HeadBranch: input.HeadBranch, + ChangedFilesCount: len(input.ChangedFiles), + Additions: input.Additions, + Deletions: input.Deletions, + CommitCount: len(input.Commits), + ChangeType: changeType, + RiskLevel: riskLevel, + ReviewFocus: reviewFocus, + TestSuggestions: testSuggestions, + MergeChecklist: mergeChecklist, + Reasoning: reasoning, + Source: source, + } +} + +func scorePRChangeTypes(input PRSummaryInput) (map[string]int, []string) { + scores := map[string]int{ + PRChangeTypeDocs: 0, + PRChangeTypeTest: 0, + PRChangeTypeFeature: 0, + PRChangeTypeFix: 0, + PRChangeTypeRefactor: 0, + PRChangeTypeCI: 0, + } + reasons := []string{} + + for _, file := range input.ChangedFiles { + path := normalizedPath(file.Filename) + switch { + case isDocsPath(path): + scores[PRChangeTypeDocs] += 2 + reasons = append(reasons, "file:docs") + case isTestPath(path): + scores[PRChangeTypeTest] += 2 + reasons = append(reasons, "file:test") + case isCIPath(path): + scores[PRChangeTypeCI] += 2 + reasons = append(reasons, "file:ci") + } + } + + text := strings.ToLower(prTextCorpus(input, false)) + addKeywordScore(scores, &reasons, text, PRChangeTypeDocs, []string{"doc", "docs", "documentation", "readme", "typo", "example", "guide"}) + addKeywordScore(scores, &reasons, text, PRChangeTypeTest, []string{"test", "tests", "coverage"}) + addKeywordScore(scores, &reasons, text, PRChangeTypeFeature, []string{"feat", "feature", "add", "support", "implement"}) + addKeywordScore(scores, &reasons, text, PRChangeTypeFix, []string{"fix", "bug", "error", "crash", "resolve"}) + addKeywordScore(scores, &reasons, text, PRChangeTypeRefactor, []string{"refactor", "cleanup", "simplify", "restructure"}) + addKeywordScore(scores, &reasons, text, PRChangeTypeCI, []string{"ci", "workflow", "action", "build", "pipeline"}) + + return scores, uniqueStrings(reasons) +} + +func choosePRChangeType(scores map[string]int) string { + topType := PRChangeTypeUnknown + topScore := 0 + secondScore := 0 + hits := []string{} + for _, kind := range []string{PRChangeTypeDocs, PRChangeTypeTest, PRChangeTypeFeature, PRChangeTypeFix, PRChangeTypeRefactor, PRChangeTypeCI} { + score := scores[kind] + if score <= 0 { + continue + } + hits = append(hits, kind) + if score > topScore { + secondScore = topScore + topScore = score + topType = kind + } else if score > secondScore { + secondScore = score + } + } + if len(hits) == 0 { + return PRChangeTypeUnknown + } + if len(hits) == 1 { + return topType + } + if len(hits) == 2 && containsString(hits, PRChangeTypeDocs) && containsString(hits, PRChangeTypeTest) { + return topType + } + if topScore >= secondScore+2 { + return topType + } + return PRChangeTypeMixed +} + +func addKeywordScore(scores map[string]int, reasons *[]string, text, kind string, keywords []string) { + for _, keyword := range keywords { + if strings.Contains(text, keyword) { + scores[kind]++ + *reasons = append(*reasons, "keyword:"+kind+":"+keyword) + } + } +} + +func scorePRRisk(input PRSummaryInput, changeType string) (string, []string) { + corpus := strings.ToLower(prTextCorpus(input, true)) + reasons := []string{} + if containsAny(corpus, []string{"token", "secret", "credential", "permission", "vulnerability", "auth bypass", "permission bypass", "leak"}) { + return PRRiskCritical, []string{"security-sensitive keyword"} + } + if touchesCodePath(input, []string{"shortcuts/", "cmd/", "internal/client"}) && + containsAny(corpus, []string{"merge", "approve", "comment", "label", "close", "refuse", "journal"}) { + return PRRiskCritical, []string{"possible remote write operation"} + } + + if touchesCodePath(input, []string{"internal/client", "internal/auth", "internal/config", "cmd/"}) { + reasons = append(reasons, "high-risk core path") + } + if touchesExactPath(input, "shortcuts/register.go") { + reasons = append(reasons, "command registration changed") + } + if input.Deletions >= 200 || deletedFiles(input) >= 5 { + reasons = append(reasons, "large deletion") + } + if len(reasons) > 0 { + return PRRiskHigh, reasons + } + + if changeType == PRChangeTypeDocs || changeType == PRChangeTypeTest { + if len(input.ChangedFiles) <= 8 && input.Deletions <= 80 { + return PRRiskLow, []string{"docs-or-tests only"} + } + } + if touchesGoCode(input) || touchesCodePath(input, []string{"shortcuts/"}) { + return PRRiskMedium, []string{"go or shortcut code changed"} + } + return PRRiskLow, []string{"low-risk file scope"} +} + +func buildPRReviewFocus(input PRSummaryInput, lang, riskLevel string) []string { + focus := []string{} + if touchesCodePath(input, []string{"shortcuts/"}) { + focus = append(focus, prFocusText(lang, "shortcuts")) + } + if touchesExactPath(input, "shortcuts/register.go") { + focus = append(focus, prFocusText(lang, "registration")) + } + if touchesCodePath(input, []string{"internal/client"}) { + focus = append(focus, prFocusText(lang, "client")) + } + if touchesCodePath(input, []string{"internal/output"}) { + focus = append(focus, prFocusText(lang, "output")) + } + if touchesCodePath(input, []string{"internal/auth", "internal/config"}) { + focus = append(focus, prFocusText(lang, "auth")) + } + if touchesDocs(input) { + focus = append(focus, prFocusText(lang, "docs")) + } + if touchesTests(input) { + focus = append(focus, prFocusText(lang, "tests")) + } + if touchesFetchOrAPI(input) { + focus = append(focus, prFocusText(lang, "api")) + } + if riskLevel == PRRiskCritical { + focus = append(focus, prFocusText(lang, "security")) + } + return uniqueStrings(focus) +} + +func buildPRTestSuggestions(input PRSummaryInput, lang string) []string { + suggestions := []string{} + if touchesGoCode(input) { + suggestions = append(suggestions, prTestText(lang, "go_all")) + } + if touchesCodePath(input, []string{"shortcuts/workflow"}) { + suggestions = append(suggestions, prTestText(lang, "workflow")) + } + if touchesDocs(input) { + suggestions = append(suggestions, prTestText(lang, "docs")) + } + if touchesFetchOrAPI(input) { + suggestions = append(suggestions, prTestText(lang, "fetch")) + } + if touchesCodePath(input, []string{"render", "internal/output"}) { + suggestions = append(suggestions, prTestText(lang, "render")) + } + if len(suggestions) == 0 { + suggestions = append(suggestions, prTestText(lang, "go_all")) + } + return uniqueStrings(suggestions) +} + +func buildPRMergeChecklist(input PRSummaryInput, lang, riskLevel string) []string { + checklist := []string{ + prChecklistText(lang, "tests"), + prChecklistText(lang, "readme"), + prChecklistText(lang, "no_write"), + prChecklistText(lang, "json_stable"), + prChecklistText(lang, "errors"), + } + if riskLevel == PRRiskCritical || riskLevel == PRRiskHigh { + checklist = append(checklist, prChecklistText(lang, "credentials")) + } + if touchesFetchOrAPI(input) || riskLevel == PRRiskHigh { + checklist = append(checklist, prChecklistText(lang, "api_fallback")) + } + if touchesExactPath(input, "shortcuts/register.go") { + checklist = append(checklist, prChecklistText(lang, "registration")) + } + if touchesCodePath(input, []string{"internal/output", "render"}) { + checklist = append(checklist, prChecklistText(lang, "contract")) + } + return uniqueStrings(checklist) +} + +func buildPRReasoning(lang, changeType, riskLevel string, scores map[string]int, scoreReasons, riskReasons []string) []string { + reasoning := []string{} + if lang == langZH { + reasoning = append(reasoning, fmt.Sprintf("变更类型判定:%s", changeType)) + reasoning = append(reasoning, fmt.Sprintf("风险等级判定:%s", riskLevel)) + } else { + reasoning = append(reasoning, fmt.Sprintf("change type: %s", changeType)) + reasoning = append(reasoning, fmt.Sprintf("risk level: %s", riskLevel)) + } + for _, kind := range []string{PRChangeTypeDocs, PRChangeTypeTest, PRChangeTypeFeature, PRChangeTypeFix, PRChangeTypeRefactor, PRChangeTypeCI} { + if scores[kind] > 0 { + if lang == langZH { + reasoning = append(reasoning, fmt.Sprintf("规则得分 %s=%d", kind, scores[kind])) + } else { + reasoning = append(reasoning, fmt.Sprintf("rule score %s=%d", kind, scores[kind])) + } + } + } + for _, reason := range append(scoreReasons, riskReasons...) { + if lang == langZH { + reasoning = append(reasoning, "命中规则:"+reason) + } else { + reasoning = append(reasoning, "matched rule: "+reason) + } + } + return uniqueStrings(reasoning) +} + +func prTextCorpus(input PRSummaryInput, includeFiles bool) string { + parts := []string{input.Title, input.Body} + for _, commit := range input.Commits { + parts = append(parts, commit.Message) + } + if includeFiles { + for _, file := range input.ChangedFiles { + parts = append(parts, file.Filename, file.Status, file.Patch) + } + } + return strings.Join(parts, "\n") +} + +func prFocusText(lang, key string) string { + zh := lang == langZH + switch key { + case "shortcuts": + if zh { + return "检查 shortcuts 命令兼容性和参数行为。" + } + return "Check shortcut command compatibility and flag behavior." + case "registration": + if zh { + return "确认命令注册和 shortcut 挂载兼容。" + } + return "Confirm command registration and shortcut mounting compatibility." + case "client": + if zh { + return "检查 API 错误处理和响应归一化。" + } + return "Check API error handling and response normalization." + case "output": + if zh { + return "检查输出格式兼容性和稳定性。" + } + return "Check output format compatibility and stability." + case "auth": + if zh { + return "检查凭据处理和安全边界。" + } + return "Check credential handling and security boundaries." + case "docs": + if zh { + return "检查文档示例是否与实现一致。" + } + return "Check that documentation examples match implementation." + case "tests": + if zh { + return "检查测试是否真实覆盖行为和失败路径。" + } + return "Check that tests reflect behavior and failure paths." + case "api": + if zh { + return "检查 fetch/API 失败时的降级和归一化。" + } + return "Check fetch/API failure fallback and normalization." + case "security": + if zh { + return "确认没有凭据泄露或不安全的远端写操作。" + } + return "Confirm no credential leakage or unsafe remote write operation." + default: + return key + } +} + +func prTestText(lang, key string) string { + zh := lang == langZH + switch key { + case "go_all": + if zh { + return "运行 `go test ./...`。" + } + return "Run `go test ./...`." + case "workflow": + if zh { + return "运行 `go test ./shortcuts/workflow`。" + } + return "Run `go test ./shortcuts/workflow`." + case "docs": + if zh { + return "手动检查 README 和文档示例命令。" + } + return "Manually check README and documentation examples." + case "fetch": + if zh { + return "运行 httptest mock,必要时执行只读远端 smoke。" + } + return "Run httptest mocks and a read-only remote smoke check if needed." + case "render": + if zh { + return "验证 json/table/markdown 输出结构。" + } + return "Verify json/table/markdown output structures." + default: + return key + } +} + +func prChecklistText(lang, key string) string { + zh := lang == langZH + switch key { + case "tests": + if zh { + return "测试通过。" + } + return "Tests pass." + case "readme": + if zh { + return "命令行为变化已更新 README。" + } + return "README updated if command behavior changed." + case "no_write": + if zh { + return "未引入远端写操作。" + } + return "No remote write operation introduced." + case "json_stable": + if zh { + return "JSON 输出字段保持稳定。" + } + return "JSON output remains stable." + case "errors": + if zh { + return "错误处理已覆盖。" + } + return "Error handling is covered." + case "credentials": + if zh { + return "确认没有凭据泄露。" + } + return "Confirm no credential leakage." + case "api_fallback": + if zh { + return "验证 API 失败时的降级路径。" + } + return "Verify API failure fallback." + case "registration": + if zh { + return "确认命令注册兼容。" + } + return "Confirm command registration compatibility." + case "contract": + if zh { + return "复核 Agent 消费的输出协议。" + } + return "Review output contract for Agent consumers." + default: + return key + } +} + +func normalizedPath(path string) string { + path = strings.ReplaceAll(path, "\\", "/") + return strings.ToLower(strings.TrimSpace(path)) +} + +func isDocsPath(path string) bool { + return strings.Contains(path, "docs/") || + strings.Contains(path, "/docs/") || + strings.Contains(path, "readme") || + strings.HasSuffix(path, ".md") +} + +func isTestPath(path string) bool { + return strings.Contains(path, "test") || strings.HasSuffix(path, "_test.go") +} + +func isCIPath(path string) bool { + return strings.Contains(path, ".github") || + strings.Contains(path, "workflow") || + strings.Contains(path, "ci") || + strings.Contains(path, "build") +} + +func touchesDocs(input PRSummaryInput) bool { + for _, file := range input.ChangedFiles { + if isDocsPath(normalizedPath(file.Filename)) { + return true + } + } + return false +} + +func touchesTests(input PRSummaryInput) bool { + for _, file := range input.ChangedFiles { + if isTestPath(normalizedPath(file.Filename)) { + return true + } + } + return false +} + +func touchesGoCode(input PRSummaryInput) bool { + for _, file := range input.ChangedFiles { + if strings.HasSuffix(normalizedPath(file.Filename), ".go") { + return true + } + } + return false +} + +func touchesFetchOrAPI(input PRSummaryInput) bool { + return touchesCodePath(input, []string{"fetch", "api", "internal/client"}) +} + +func touchesCodePath(input PRSummaryInput, fragments []string) bool { + for _, file := range input.ChangedFiles { + path := normalizedPath(file.Filename) + for _, fragment := range fragments { + if strings.Contains(path, strings.ToLower(fragment)) { + return true + } + } + } + return false +} + +func touchesExactPath(input PRSummaryInput, target string) bool { + target = normalizedPath(target) + for _, file := range input.ChangedFiles { + if normalizedPath(file.Filename) == target { + return true + } + } + return false +} + +func deletedFiles(input PRSummaryInput) int { + count := 0 + for _, file := range input.ChangedFiles { + if strings.EqualFold(file.Status, "removed") || strings.EqualFold(file.Status, "deleted") { + count++ + } + } + return count +} diff --git a/shortcuts/workflow/pr_summary_test.go b/shortcuts/workflow/pr_summary_test.go new file mode 100644 index 0000000..bbc957b --- /dev/null +++ b/shortcuts/workflow/pr_summary_test.go @@ -0,0 +1,286 @@ +package workflow + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestAnalyzePRSummaryDocsOnlyLowRisk(t *testing.T) { + result := AnalyzePRSummary(PRSummaryInput{ + Repository: "owner/repo", + Number: 1, + Title: "docs: update README examples", + ChangedFiles: []PRChangedFile{ + {Filename: "README.md", Status: "modified", Additions: 12}, + {Filename: "docs/workflow-agent-design.md", Status: "modified", Additions: 8}, + }, + Additions: 20, + }, "en") + + if result.ChangeType != PRChangeTypeDocs { + t.Fatalf("ChangeType = %q, want %q", result.ChangeType, PRChangeTypeDocs) + } + if result.RiskLevel != PRRiskLow { + t.Fatalf("RiskLevel = %q, want %q", result.RiskLevel, PRRiskLow) + } +} + +func TestAnalyzePRSummaryWorkflowCodeMediumRisk(t *testing.T) { + result := AnalyzePRSummary(PRSummaryInput{ + Repository: "owner/repo", + Number: 2, + Title: "feat: add PR summary workflow", + ChangedFiles: []PRChangedFile{ + {Filename: "shortcuts/workflow/pr_summary.go", Status: "added", Additions: 80}, + }, + Additions: 80, + }, "en") + + if result.RiskLevel != PRRiskMedium { + t.Fatalf("RiskLevel = %q, want %q", result.RiskLevel, PRRiskMedium) + } +} + +func TestAnalyzePRSummaryInternalClientHighRisk(t *testing.T) { + result := AnalyzePRSummary(PRSummaryInput{ + Repository: "owner/repo", + Number: 3, + Title: "fix: normalize API errors", + ChangedFiles: []PRChangedFile{ + {Filename: "internal/client/client.go", Status: "modified", Additions: 20, Deletions: 4}, + }, + Additions: 20, + Deletions: 4, + }, "en") + + if result.RiskLevel != PRRiskHigh { + t.Fatalf("RiskLevel = %q, want %q", result.RiskLevel, PRRiskHigh) + } +} + +func TestAnalyzePRSummaryAuthTokenCriticalRisk(t *testing.T) { + result := AnalyzePRSummary(PRSummaryInput{ + Repository: "owner/repo", + Number: 4, + Title: "fix: prevent token permission leak", + Body: "Avoid auth bypass and secret exposure.", + ChangedFiles: []PRChangedFile{ + {Filename: "internal/auth/auth.go", Status: "modified", Additions: 20}, + }, + }, "en") + + if result.RiskLevel != PRRiskCritical { + t.Fatalf("RiskLevel = %q, want %q", result.RiskLevel, PRRiskCritical) + } +} + +func TestAnalyzePRSummaryMixedFiles(t *testing.T) { + result := AnalyzePRSummary(PRSummaryInput{ + Repository: "owner/repo", + Number: 5, + Title: "feat: add workflow command and docs", + ChangedFiles: []PRChangedFile{ + {Filename: "shortcuts/workflow/pr_summary.go", Status: "added", Additions: 80}, + {Filename: "docs/workflow-agent-design.md", Status: "modified", Additions: 10}, + }, + }, "en") + + if result.ChangeType != PRChangeTypeMixed { + t.Fatalf("ChangeType = %q, want %q", result.ChangeType, PRChangeTypeMixed) + } +} + +func TestAnalyzePRSummaryChineseText(t *testing.T) { + result := AnalyzePRSummary(PRSummaryInput{ + Repository: "owner/repo", + Number: 6, + Title: "feat: add workflow command", + ChangedFiles: []PRChangedFile{ + {Filename: "shortcuts/workflow/pr_summary.go", Status: "added", Additions: 80}, + }, + }, "zh-CN") + + if len(result.ReviewFocus) == 0 || len(result.TestSuggestions) == 0 || len(result.MergeChecklist) == 0 { + t.Fatalf("expected non-empty zh-CN recommendations, got focus=%v suggestions=%v checklist=%v", result.ReviewFocus, result.TestSuggestions, result.MergeChecklist) + } + joined := strings.Join(append(append(result.ReviewFocus, result.TestSuggestions...), result.MergeChecklist...), "") + if !strings.Contains(joined, "检查") && !strings.Contains(joined, "运行") { + t.Fatalf("expected zh-CN text, got %q", joined) + } +} + +func TestPRSummaryShortcutFromJSONFile(t *testing.T) { + restoreFormat := setCommandFormatForTest(t, "json") + defer restoreFormat() + + ctx := &common.RuntimeContext{ + Format: "json", + Args: map[string]string{ + "from": "testdata/pr_summary.json", + "lang": "en", + }, + } + + output := captureStdout(t, func() error { + return findWorkflowShortcut(t, "pr-summary").Run(ctx) + }) + var result PRSummaryResult + if err := json.Unmarshal([]byte(output), &result); err != nil { + t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, output) + } + if result.Number != 1 { + t.Fatalf("Number = %d, want 1", result.Number) + } +} + +func TestPRSummaryShortcutRemoteFetch(t *testing.T) { + restoreFormat := setCommandFormatForTest(t, "json") + defer restoreFormat() + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/5.json": + writeWorkflowJSON(t, w, map[string]interface{}{ + "data": map[string]interface{}{ + "number": 5, + "title": "feat: add remote summary", + "state": "open", + "user": map[string]interface{}{"login": "alice"}, + "base_branch": "master", + "head_branch": "feature/pr-summary", + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/5/files.json": + writeWorkflowJSON(t, w, map[string]interface{}{ + "files": []map[string]interface{}{ + {"filename": "shortcuts/workflow/pr_summary.go", "status": "added", "additions": 50}, + }, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/5/commits.json": + writeWorkflowJSON(t, w, []map[string]interface{}{ + {"sha": "abc123", "message": "feat: add remote summary", "author": map[string]interface{}{"name": "alice"}}, + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: map[string]string{ + "number": "5", + "include-files": "true", + "include-commits": "true", + "max-files": "100", + "max-commits": "100", + "lang": "en", + }, + } + + output := captureStdout(t, func() error { + return findWorkflowShortcut(t, "pr-summary").Run(ctx) + }) + var result PRSummaryResult + if err := json.Unmarshal([]byte(output), &result); err != nil { + t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, output) + } + if result.Number != 5 || result.Source != "remote-read-only-fetch" { + t.Fatalf("result = %+v, want number 5 remote source", result) + } +} + +func TestPRSummaryShortcutMissingParameters(t *testing.T) { + ctx := &common.RuntimeContext{ + Format: "json", + Args: map[string]string{"lang": "en"}, + } + + err := findWorkflowShortcut(t, "pr-summary").Run(ctx) + if err == nil { + t.Fatal("Run returned nil error for missing parameters") + } + if !strings.Contains(err.Error(), "requires --from") { + t.Fatalf("error = %v, want clear missing input error", err) + } +} + +func findWorkflowShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, shortcut := range Shortcuts() { + if shortcut.Name == name { + return shortcut + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func setCommandFormatForTest(t *testing.T, format string) func() { + t.Helper() + old := cmdutil.Format + cmdutil.Format = format + return func() { + cmdutil.Format = old + } +} + +func captureStdout(t *testing.T, fn func() error) string { + t.Helper() + old := os.Stdout + reader, writer, err := os.Pipe() + if err != nil { + t.Fatalf("os.Pipe returned error: %v", err) + } + os.Stdout = writer + runErr := fn() + closeErr := writer.Close() + os.Stdout = old + if runErr != nil { + t.Fatalf("function returned error: %v", runErr) + } + if closeErr != nil { + t.Fatalf("writer.Close returned error: %v", closeErr) + } + data, err := io.ReadAll(reader) + if err != nil { + t.Fatalf("io.ReadAll returned error: %v", err) + } + return string(data) +} + +func TestReadPRSummaryInput(t *testing.T) { + path := filepath.Join(t.TempDir(), "pr_summary.json") + input := PRSummaryInput{Number: 9, Title: "docs: update README", Repository: "owner/repo"} + encoded, err := json.Marshal(input) + if err != nil { + t.Fatalf("json.Marshal returned error: %v", err) + } + if err := os.WriteFile(path, encoded, 0600); err != nil { + t.Fatalf("os.WriteFile returned error: %v", err) + } + + got, err := readPRSummaryInput(path) + if err != nil { + t.Fatalf("readPRSummaryInput returned error: %v", err) + } + if got.Number != 9 || got.Title != "docs: update README" { + t.Fatalf("got = %+v, want input fields", got) + } +} diff --git a/shortcuts/workflow/render.go b/shortcuts/workflow/render.go new file mode 100644 index 0000000..366c2e2 --- /dev/null +++ b/shortcuts/workflow/render.go @@ -0,0 +1,422 @@ +package workflow + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "sort" + "strings" + "text/tabwriter" +) + +func renderTriageReport(w io.Writer, report TriageReport, format string) error { + switch normalizeFormat(format) { + case "json": + return writeJSON(w, report) + case "markdown": + return writeTriageMarkdown(w, report) + case "table": + return writeTriageTable(w, report) + default: + return fmt.Errorf("unsupported workflow output format %q", format) + } +} + +func renderHealthResult(w io.Writer, result HealthResult, format string) error { + switch normalizeFormat(format) { + case "json": + return writeJSON(w, result) + case "markdown": + return writeHealthMarkdown(w, result) + case "table": + return writeHealthTable(w, result) + 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) { + case "json": + if err := writeJSON(&buf, result); err != nil { + return "", err + } + case "markdown": + if err := writePRSummaryMarkdown(&buf, result, lang); err != nil { + return "", err + } + case "table": + if err := writePRSummaryTable(&buf, result); err != nil { + return "", err + } + default: + return "", fmt.Errorf("unsupported workflow output format %q", format) + } + return buf.String(), nil +} + +func RenderRepoReport(result RepoReportResult, format string, lang string) (string, error) { + var buf bytes.Buffer + switch normalizeFormat(format) { + case "json": + if err := writeJSON(&buf, result); err != nil { + return "", err + } + case "markdown": + if err := writeRepoReportMarkdown(&buf, result, lang); err != nil { + return "", err + } + case "table": + if err := writeRepoReportTable(&buf, result, lang); err != nil { + return "", err + } + default: + return "", fmt.Errorf("unsupported workflow output format %q", format) + } + return buf.String(), nil +} + +func normalizeFormat(format string) string { + format = strings.ToLower(strings.TrimSpace(format)) + if format == "" { + return "json" + } + return format +} + +func writeJSON(w io.Writer, data interface{}) error { + encoded, err := json.MarshalIndent(data, "", " ") + if err != nil { + return err + } + _, err = fmt.Fprintln(w, string(encoded)) + return err +} + +func writeTriageTable(w io.Writer, report TriageReport) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "NUMBER\tTYPE\tPRIORITY\tCONFIDENCE\tMISSING\tACTION"); err != nil { + return err + } + for _, result := range report.Results { + missing := "-" + if len(result.MissingInformation) > 0 { + missing = strings.Join(result.MissingInformation, ",") + } + if _, err := fmt.Fprintf(tw, "%d\t%s\t%s\t%d\t%s\t%s\n", + result.Issue.Number, + result.DetectedType, + result.Priority, + result.Confidence, + missing, + result.RecommendedAction, + ); err != nil { + return err + } + } + return tw.Flush() +} + +func writePRSummaryTable(w io.Writer, result PRSummaryResult) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintln(tw, "PR\tTITLE\tTYPE\tRISK\tFILES\tCOMMITS\tSOURCE"); err != nil { + return err + } + if _, err := fmt.Fprintf(tw, "#%d\t%s\t%s\t%s\t%d\t%d\t%s\n", + result.Number, + truncateTableText(result.Title, 72), + result.ChangeType, + result.RiskLevel, + result.ChangedFilesCount, + result.CommitCount, + result.Source, + ); err != nil { + return err + } + return tw.Flush() +} + +func writeHealthTable(w io.Writer, result HealthResult) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + if _, err := fmt.Fprintf(tw, "REPOSITORY\tSCORE\tRISK\n%s\t%d\t%s\n\n", result.Repository, result.HealthScore, result.RiskLevel); err != nil { + return err + } + if _, err := fmt.Fprintln(tw, "METRIC\tSTATUS\tSCORE\tMAX\tREASON"); err != nil { + return err + } + for _, metric := range result.Metrics { + if _, err := fmt.Fprintf(tw, "%s\t%s\t%d\t%d\t%s\n", metric.Name, metric.Status, metric.Score, metric.MaxScore, metric.Reason); err != nil { + return err + } + } + return tw.Flush() +} + +func writeRepoReportTable(w io.Writer, result RepoReportResult, lang string) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + healthScore := repoReportText(lang, "not_available") + if result.Health != nil { + healthScore = fmt.Sprintf("%d", result.Health.HealthScore) + } + topRecommendation := repoReportText(lang, "not_available") + if len(result.Recommendations) > 0 { + topRecommendation = truncateTableText(result.Recommendations[0], 96) + } + if _, err := fmt.Fprintln(tw, "REPOSITORY\tREPORT_SCORE\tRISK\tHEALTH_SCORE\tISSUES\tHIGH_RISK_ISSUES\tPRS\tHIGH_RISK_PRS\tTOP_RECOMMENDATION"); err != nil { + return err + } + if _, err := fmt.Fprintf(tw, "%s\t%d\t%s\t%s\t%d\t%d\t%d\t%d\t%s\n", + result.Repository, + result.ReportScore, + result.RiskLevel, + healthScore, + result.IssueSummary.Total, + result.IssueSummary.HighRisk, + result.PRSummary.Total, + result.PRSummary.HighRisk, + topRecommendation, + ); 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 + } + if _, err := fmt.Fprintln(w, "| Issue | Type | Priority | Confidence | Action | Missing Information |"); err != nil { + return err + } + if _, err := fmt.Fprintln(w, "| --- | --- | --- | ---: | --- | --- |"); err != nil { + return err + } + for _, result := range report.Results { + missing := "-" + if len(result.MissingInformation) > 0 { + missing = strings.Join(result.MissingInformation, ", ") + } + if _, err := fmt.Fprintf(w, "| #%d | %s | %s | %d | %s | %s |\n", + result.Issue.Number, + result.DetectedType, + result.Priority, + result.Confidence, + result.RecommendedAction, + missing, + ); err != nil { + return err + } + } + return nil +} + +func writeRepoReportMarkdown(w io.Writer, result RepoReportResult, lang string) error { + lang = normalizeLang(lang) + if _, err := fmt.Fprintf(w, "# %s\n\n", repoReportText(lang, "title")); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "## %s\n\n", repoReportText(lang, "overview")); err != nil { + return err + } + healthScore := repoReportText(lang, "not_available") + if result.Health != nil { + healthScore = fmt.Sprintf("%d", result.Health.HealthScore) + } + overview := []string{ + fmt.Sprintf("- Repository: `%s`", result.Repository), + fmt.Sprintf("- Report score: `%d`", result.ReportScore), + fmt.Sprintf("- Risk level: `%s`", result.RiskLevel), + fmt.Sprintf("- Health score: `%s`", healthScore), + fmt.Sprintf("- Issues analyzed: `%d`", result.IssueSummary.Total), + fmt.Sprintf("- Pull requests analyzed: `%d`", result.PRSummary.Total), + fmt.Sprintf("- Source: `%s`", result.Source), + } + for _, line := range overview { + if _, err := fmt.Fprintln(w, line); err != nil { + return err + } + } + + if _, err := fmt.Fprintf(w, "\n## %s\n\n", repoReportText(lang, "health")); err != nil { + return err + } + if result.Health == nil { + if _, err := fmt.Fprintf(w, "- %s\n", repoReportText(lang, "not_available")); err != nil { + return err + } + } else { + if _, err := fmt.Fprintf(w, "- Score: `%d`\n- Risk: `%s`\n", result.Health.HealthScore, result.Health.RiskLevel); err != nil { + return err + } + } + + if _, err := fmt.Fprintf(w, "\n## %s\n\n", repoReportText(lang, "issues")); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "- Total: `%d`\n- High risk: `%d`\n- Missing information: `%d`\n", result.IssueSummary.Total, result.IssueSummary.HighRisk, result.IssueSummary.MissingInfo); err != nil { + return err + } + writeCountMapMarkdown(w, "By type", result.IssueSummary.ByType) + writeCountMapMarkdown(w, "By priority", result.IssueSummary.ByPriority) + + if _, err := fmt.Fprintf(w, "\n## %s\n\n", repoReportText(lang, "prs")); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "- Total: `%d`\n- High risk: `%d`\n", result.PRSummary.Total, result.PRSummary.HighRisk); err != nil { + return err + } + writeCountMapMarkdown(w, "By type", result.PRSummary.ByType) + writeCountMapMarkdown(w, "By risk", result.PRSummary.ByRisk) + if len(result.PRSummary.ReviewFocus) > 0 { + if _, err := fmt.Fprintln(w, "- Review focus:"); err != nil { + return err + } + for _, focus := range result.PRSummary.ReviewFocus { + if _, err := fmt.Fprintf(w, " - %s\n", focus); err != nil { + return err + } + } + } + + if err := writeRepoReportMarkdownList(w, repoReportText(lang, "recommendations"), result.Recommendations, repoReportText(lang, "not_available")); err != nil { + return err + } + return writeRepoReportMarkdownList(w, repoReportText(lang, "reasoning"), result.Reasoning, repoReportText(lang, "not_available")) +} + +func writeCountMapMarkdown(w io.Writer, title string, values map[string]int) error { + if len(values) == 0 { + return nil + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + if _, err := fmt.Fprintf(w, "- %s:\n", title); err != nil { + return err + } + for _, key := range keys { + if _, err := fmt.Fprintf(w, " - `%s`: `%d`\n", key, values[key]); err != nil { + return err + } + } + return nil +} + +func writeRepoReportMarkdownList(w io.Writer, title string, values []string, fallback string) error { + if _, err := fmt.Fprintf(w, "\n## %s\n\n", title); err != nil { + return err + } + if len(values) == 0 { + _, err := fmt.Fprintf(w, "- %s\n", fallback) + return err + } + for _, value := range values { + if _, err := fmt.Fprintf(w, "- %s\n", value); err != nil { + return err + } + } + return nil +} + +func writePRSummaryMarkdown(w io.Writer, result PRSummaryResult, lang string) error { + lang = normalizeLang(lang) + if _, err := fmt.Fprintf(w, "# %s\n\n", message(lang, "pr_summary_title")); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "## %s\n\n", message(lang, "pr_summary_overview")); err != nil { + return err + } + lines := []string{ + fmt.Sprintf("- Repository: `%s`", result.Repository), + fmt.Sprintf("- PR: `#%d` %s", result.Number, result.Title), + fmt.Sprintf("- Author: `%s`", result.Author), + fmt.Sprintf("- State: `%s`", result.State), + fmt.Sprintf("- Base branch: `%s`", result.BaseBranch), + fmt.Sprintf("- Head branch: `%s`", result.HeadBranch), + fmt.Sprintf("- Change type: `%s`", result.ChangeType), + fmt.Sprintf("- Risk level: `%s`", result.RiskLevel), + fmt.Sprintf("- Changed files: `%d`", result.ChangedFilesCount), + fmt.Sprintf("- Commits: `%d`", result.CommitCount), + fmt.Sprintf("- Source: `%s`", result.Source), + } + for _, line := range lines { + if _, err := fmt.Fprintln(w, line); err != nil { + return err + } + } + + if err := writePRSummaryMarkdownList(w, message(lang, "pr_summary_review_focus"), result.ReviewFocus, message(lang, "pr_summary_no_focus")); err != nil { + return err + } + if err := writePRSummaryMarkdownList(w, message(lang, "pr_summary_test_suggestions"), result.TestSuggestions, message(lang, "pr_summary_no_suggestions")); err != nil { + return err + } + if err := writePRSummaryMarkdownList(w, message(lang, "pr_summary_merge_checklist"), result.MergeChecklist, message(lang, "pr_summary_no_checklist")); err != nil { + return err + } + return writePRSummaryMarkdownList(w, message(lang, "pr_summary_reasoning"), result.Reasoning, message(lang, "pr_summary_no_reasoning")) +} + +func writePRSummaryMarkdownList(w io.Writer, title string, values []string, fallback string) error { + if _, err := fmt.Fprintf(w, "\n## %s\n\n", title); err != nil { + return err + } + if len(values) == 0 { + _, err := fmt.Fprintf(w, "- %s\n", fallback) + return err + } + for _, value := range values { + if _, err := fmt.Fprintf(w, "- %s\n", value); err != nil { + return err + } + } + return nil +} + +func writeHealthMarkdown(w io.Writer, result HealthResult) error { + if _, err := fmt.Fprintf(w, "# Repository Health Report\n\nRepository: `%s`\n\nHealth score: **%d**\n\nRisk level: **%s**\n\n", result.Repository, result.HealthScore, result.RiskLevel); err != nil { + return err + } + if _, err := fmt.Fprintln(w, "| Metric | Status | Score | Max | Reason |"); err != nil { + return err + } + if _, err := fmt.Fprintln(w, "| --- | --- | ---: | ---: | --- |"); err != nil { + return err + } + for _, metric := range result.Metrics { + if _, err := fmt.Fprintf(w, "| %s | %s | %d | %d | %s |\n", metric.Name, metric.Status, metric.Score, metric.MaxScore, metric.Reason); err != nil { + return err + } + } + if len(result.Recommendations) == 0 { + return nil + } + if _, err := fmt.Fprintln(w, "\n## Recommendations"); err != nil { + return err + } + if _, err := fmt.Fprintln(w); err != nil { + return err + } + for _, recommendation := range result.Recommendations { + if _, err := fmt.Fprintf(w, "- %s\n", recommendation); err != nil { + return err + } + } + return nil +} + +func truncateTableText(value string, max int) string { + value = strings.Join(strings.Fields(value), " ") + runes := []rune(value) + if max <= 0 || len(runes) <= max { + return value + } + if max <= 3 { + return string(runes[:max]) + } + return string(runes[:max-3]) + "..." +} diff --git a/shortcuts/workflow/render_test.go b/shortcuts/workflow/render_test.go new file mode 100644 index 0000000..427f05a --- /dev/null +++ b/shortcuts/workflow/render_test.go @@ -0,0 +1,84 @@ +package workflow + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestRenderPRSummaryJSON(t *testing.T) { + rendered, err := RenderPRSummary(samplePRSummaryResult(), "json", "en") + if err != nil { + t.Fatalf("RenderPRSummary returned error: %v", err) + } + var result PRSummaryResult + if err := json.Unmarshal([]byte(rendered), &result); err != nil { + t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, rendered) + } + if result.Number != 42 { + t.Fatalf("Number = %d, want 42", result.Number) + } +} + +func TestRenderPRSummaryMarkdown(t *testing.T) { + result := samplePRSummaryResult() + rendered, err := RenderPRSummary(result, "markdown", "en") + if err != nil { + t.Fatalf("RenderPRSummary returned error: %v", err) + } + for _, want := range []string{result.Title, "Risk level", "Review Focus"} { + if !strings.Contains(rendered, want) { + t.Fatalf("markdown output missing %q:\n%s", want, rendered) + } + } +} + +func TestRenderPRSummaryTable(t *testing.T) { + rendered, err := RenderPRSummary(samplePRSummaryResult(), "table", "en") + if err != nil { + t.Fatalf("RenderPRSummary returned error: %v", err) + } + if !strings.Contains(rendered, "#42") || !strings.Contains(rendered, "medium") { + t.Fatalf("table output = %q, want PR number and risk", rendered) + } +} + +func TestRenderPRSummaryUnknownFormat(t *testing.T) { + _, err := RenderPRSummary(samplePRSummaryResult(), "xml", "en") + if err == nil { + t.Fatal("RenderPRSummary returned nil error for unknown format") + } +} + +func TestRenderPRSummaryChineseMarkdown(t *testing.T) { + rendered, err := RenderPRSummary(samplePRSummaryResult(), "markdown", "zh-CN") + if err != nil { + t.Fatalf("RenderPRSummary returned error: %v", err) + } + if !strings.Contains(rendered, "PR 审阅摘要") { + t.Fatalf("zh-CN markdown output missing Chinese title:\n%s", rendered) + } +} + +func samplePRSummaryResult() PRSummaryResult { + return PRSummaryResult{ + Repository: "owner/repo", + Number: 42, + Title: "feat: add workflow PR summary", + Author: "alice", + State: "open", + BaseBranch: "master", + HeadBranch: "feature/pr-summary", + ChangedFilesCount: 2, + Additions: 100, + Deletions: 4, + CommitCount: 2, + ChangeType: PRChangeTypeFeature, + RiskLevel: PRRiskMedium, + ReviewFocus: []string{"Check shortcut command compatibility and flag behavior."}, + TestSuggestions: []string{"Run `go test ./shortcuts/workflow`."}, + MergeChecklist: []string{"Tests pass."}, + Reasoning: []string{"change type: feature", "risk level: medium"}, + Source: "local-json", + } +} diff --git a/shortcuts/workflow/repo_report.go b/shortcuts/workflow/repo_report.go new file mode 100644 index 0000000..d120f28 --- /dev/null +++ b/shortcuts/workflow/repo_report.go @@ -0,0 +1,404 @@ +package workflow + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strings" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type RepoReportInput struct { + Repository string `json:"repository"` + Health *HealthInput `json:"health,omitempty"` + Issues []IssueInput `json:"issues,omitempty"` + PullRequests []PRSummaryInput `json:"pull_requests,omitempty"` + Source string `json:"source"` +} + +type RepoReportResult struct { + Repository string `json:"repository"` + Health *HealthResult `json:"health,omitempty"` + IssueSummary RepoIssueSummary `json:"issue_summary"` + PRSummary RepoPRSummary `json:"pr_summary"` + Recommendations []string `json:"recommendations"` + RiskLevel string `json:"risk_level"` + ReportScore int `json:"report_score"` + Sections []string `json:"sections"` + Reasoning []string `json:"reasoning"` + Source string `json:"source"` +} + +type RepoIssueSummary struct { + Total int `json:"total"` + ByType map[string]int `json:"by_type"` + ByPriority map[string]int `json:"by_priority"` + HighRisk int `json:"high_risk"` + MissingInfo int `json:"missing_info"` +} + +type RepoPRSummary struct { + Total int `json:"total"` + ByType map[string]int `json:"by_type"` + ByRisk map[string]int `json:"by_risk"` + HighRisk int `json:"high_risk"` + ReviewFocus []string `json:"review_focus"` +} + +func newRepoReportShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "repo-report", + Description: "Generate a read-only repository workflow report", + Flags: []common.Flag{ + {Name: "from", Usage: "Read repository report input from a JSON file"}, + {Name: "issue-limit", Usage: "Maximum issues to fetch and analyze", Default: "20"}, + {Name: "pr-limit", Usage: "Maximum pull requests to fetch and summarize", Default: "10"}, + {Name: "stale-days", Usage: "Days before an issue or PR is considered stale", Default: "30"}, + {Name: "include-issues", Usage: "Include issue triage summary", Bool: true, Default: "true"}, + {Name: "include-prs", Usage: "Include pull request summary", Bool: true, Default: "true"}, + {Name: "include-health", Usage: "Include repository health summary", Bool: true, Default: "true"}, + {Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN}, + }, + Run: runRepoReport, + } +} + +func runRepoReport(ctx *common.RuntimeContext) error { + lang := normalizeLang(ctx.Arg("lang")) + input, notes, err := collectRepoReportInput(ctx) + if err != nil { + return err + } + result := AnalyzeRepoReport(input, lang) + for _, note := range notes { + if note.Metric == "" && note.Note == "" { + continue + } + result.Reasoning = append(result.Reasoning, fmt.Sprintf("%s: %s", note.Metric, note.Note)) + } + + format := ctx.Format + if strings.TrimSpace(cmdutil.Format) == "" { + format = "markdown" + } + rendered, err := RenderRepoReport(result, format, lang) + if err != nil { + return err + } + _, err = fmt.Fprint(os.Stdout, rendered) + return err +} + +func collectRepoReportInput(ctx *common.RuntimeContext) (RepoReportInput, []ScoringNote, error) { + if path := strings.TrimSpace(ctx.Arg("from")); path != "" { + input, err := readRepoReportInput(path) + if err != nil { + return RepoReportInput{}, nil, err + } + if strings.TrimSpace(input.Source) == "" { + input.Source = "local-json" + } + return input, nil, nil + } + + issueLimit, err := parseIntArg(ctx.Arg("issue-limit"), 20, "issue-limit") + if err != nil { + return RepoReportInput{}, nil, err + } + prLimit, err := parseIntArg(ctx.Arg("pr-limit"), 10, "pr-limit") + if err != nil { + return RepoReportInput{}, nil, err + } + staleDays, err := parseIntArg(ctx.Arg("stale-days"), 30, "stale-days") + if err != nil { + return RepoReportInput{}, nil, err + } + + return FetchRepoReportInput(ctx, RepoReportFetchOptions{ + IssueLimit: issueLimit, + PRLimit: prLimit, + StaleDays: staleDays, + IncludeIssues: parseBoolArg(ctx.Arg("include-issues")), + IncludePRs: parseBoolArg(ctx.Arg("include-prs")), + IncludeHealth: parseBoolArg(ctx.Arg("include-health")), + }) +} + +func readRepoReportInput(path string) (RepoReportInput, error) { + data, err := os.ReadFile(path) + if err != nil { + return RepoReportInput{}, fmt.Errorf("read repo report input: %w", err) + } + var input RepoReportInput + if err := json.Unmarshal(data, &input); err != nil { + return RepoReportInput{}, fmt.Errorf("parse repo report input: %w", err) + } + if strings.TrimSpace(input.Repository) == "" && input.Health == nil && len(input.Issues) == 0 && len(input.PullRequests) == 0 { + return RepoReportInput{}, fmt.Errorf("parse repo report input: expected RepoReportInput root object") + } + return input, nil +} + +func AnalyzeRepoReport(input RepoReportInput, lang string) RepoReportResult { + lang = normalizeLang(lang) + source := strings.TrimSpace(input.Source) + if source == "" { + source = "local" + } + repository := strings.TrimSpace(input.Repository) + sections := []string{} + reasoning := []string{} + + var healthResult *HealthResult + baseScore := 70 + if input.Health != nil { + health := *input.Health + if repository == "" { + repository = health.Repository + } + scored := ScoreHealth(health, lang) + healthResult = &scored + baseScore = scored.HealthScore + sections = append(sections, "health") + reasoning = append(reasoning, fmt.Sprintf("health score: %d", scored.HealthScore)) + } else { + reasoning = append(reasoning, repoReportText(lang, "health_missing")) + } + + issueSummary, issueResults := summarizeRepoIssues(input.Issues, lang) + if len(input.Issues) > 0 { + sections = append(sections, "issues") + reasoning = append(reasoning, fmt.Sprintf("issues analyzed: %d", len(input.Issues))) + } + + prSummary, prResults := summarizeRepoPRs(input.PullRequests, lang) + if len(input.PullRequests) > 0 { + sections = append(sections, "pull_requests") + reasoning = append(reasoning, fmt.Sprintf("pull requests analyzed: %d", len(input.PullRequests))) + } + + reportScore := computeRepoReportScore(baseScore, input.Health != nil, issueSummary, prSummary) + risk := riskLevel(reportScore) + if hasSecurityP0(issueResults) || hasCriticalPR(prResults) { + risk = "critical" + reportScore = minInt(reportScore, 39) + reasoning = append(reasoning, repoReportText(lang, "critical_signal")) + } + + recommendations := buildRepoReportRecommendations(lang, healthResult, issueSummary, prSummary, risk) + if repository == "" { + repository = "local" + } + + return RepoReportResult{ + Repository: repository, + Health: healthResult, + IssueSummary: issueSummary, + PRSummary: prSummary, + Recommendations: recommendations, + RiskLevel: risk, + ReportScore: reportScore, + Sections: sections, + Reasoning: uniqueStrings(reasoning), + Source: source, + } +} + +func summarizeRepoIssues(issues []IssueInput, lang string) (RepoIssueSummary, []TriageResult) { + summary := RepoIssueSummary{ + ByType: map[string]int{}, + ByPriority: map[string]int{}, + } + results := make([]TriageResult, 0, len(issues)) + for _, issue := range issues { + result := AnalyzeIssue(issue, lang) + results = append(results, result) + summary.Total++ + summary.ByType[result.DetectedType]++ + summary.ByPriority[result.Priority]++ + if result.Priority == PriorityP0 || result.Priority == PriorityP1 || containsString(result.RiskFlags, RiskSecuritySensitive) { + summary.HighRisk++ + } + if len(result.MissingInformation) > 0 { + summary.MissingInfo++ + } + } + return summary, results +} + +func summarizeRepoPRs(inputs []PRSummaryInput, lang string) (RepoPRSummary, []PRSummaryResult) { + summary := RepoPRSummary{ + ByType: map[string]int{}, + ByRisk: map[string]int{}, + } + results := make([]PRSummaryResult, 0, len(inputs)) + focus := []string{} + for _, input := range inputs { + result := AnalyzePRSummary(input, lang) + results = append(results, result) + summary.Total++ + summary.ByType[result.ChangeType]++ + summary.ByRisk[result.RiskLevel]++ + if result.RiskLevel == PRRiskHigh || result.RiskLevel == PRRiskCritical { + summary.HighRisk++ + } + focus = append(focus, result.ReviewFocus...) + } + summary.ReviewFocus = uniqueStrings(focus) + sort.Strings(summary.ReviewFocus) + return summary, results +} + +func computeRepoReportScore(baseScore int, hasHealth bool, issueSummary RepoIssueSummary, prSummary RepoPRSummary) int { + score := baseScore + if !hasHealth { + score = 70 + } + score -= issueSummary.HighRisk * 8 + score -= issueSummary.MissingInfo * 3 + score -= prSummary.HighRisk * 8 + score -= prSummary.ByRisk[PRRiskCritical] * 10 + if issueSummary.Total == 0 && prSummary.Total == 0 && !hasHealth { + score = 50 + } + return clampInt(score, 0, 100) +} + +func hasSecurityP0(results []TriageResult) bool { + for _, result := range results { + if result.Priority == PriorityP0 || result.DetectedType == IssueTypeSecurity || containsString(result.RiskFlags, RiskSecuritySensitive) { + return true + } + } + return false +} + +func hasCriticalPR(results []PRSummaryResult) bool { + for _, result := range results { + if result.RiskLevel == PRRiskCritical { + return true + } + } + return false +} + +func buildRepoReportRecommendations(lang string, health *HealthResult, issueSummary RepoIssueSummary, prSummary RepoPRSummary, risk string) []string { + recommendations := []string{} + if issueSummary.ByPriority[PriorityP0] > 0 || issueSummary.ByType[IssueTypeSecurity] > 0 { + recommendations = append(recommendations, repoReportText(lang, "rec_security_issues")) + } + if issueSummary.MissingInfo > 0 { + recommendations = append(recommendations, repoReportText(lang, "rec_missing_info")) + } + if prSummary.HighRisk > 0 { + recommendations = append(recommendations, repoReportText(lang, "rec_high_risk_prs")) + } + if health != nil { + recommendations = append(recommendations, health.Recommendations...) + } + if health != nil && health.HealthScore < 65 { + recommendations = append(recommendations, repoReportText(lang, "rec_health")) + } + if risk == "low" && len(recommendations) == 0 { + recommendations = append(recommendations, repoReportText(lang, "rec_maintain_report")) + } + if len(recommendations) == 0 { + recommendations = append(recommendations, repoReportText(lang, "rec_review_report")) + } + recommendations = uniqueStrings(recommendations) + if len(recommendations) > 8 { + recommendations = recommendations[:8] + } + return recommendations +} + +func repoReportText(lang, key string) string { + zh := normalizeLang(lang) == langZH + switch key { + case "title": + if zh { + return "仓库工作流报告" + } + return "Repository Workflow Report" + case "overview": + if zh { + return "总览" + } + return "Overview" + case "health": + if zh { + return "健康度摘要" + } + return "Health Summary" + case "issues": + if zh { + return "Issue 分诊摘要" + } + return "Issue Triage Summary" + case "prs": + if zh { + return "PR 审阅摘要" + } + return "PR Review Summary" + case "recommendations": + if zh { + return "建议操作" + } + return "Recommendations" + case "reasoning": + if zh { + return "判断依据" + } + return "Reasoning" + case "not_available": + if zh { + return "不可用" + } + return "Not available" + case "health_missing": + if zh { + return "未提供健康度输入。" + } + return "health input not provided" + case "critical_signal": + if zh { + return "发现安全 P0 Issue 或 critical PR,整体风险上调。" + } + return "security P0 issue or critical PR raised overall risk" + case "rec_security_issues": + if zh { + return "优先处理安全相关或 P0 Issue。" + } + return "Prioritize security-related or P0 issues." + case "rec_missing_info": + if zh { + return "要求补充复现步骤、版本、命令输出或日志。" + } + return "Request missing reproduction steps, version, command output, or logs." + case "rec_high_risk_prs": + if zh { + return "优先审阅 high / critical 风险 PR。" + } + return "Prioritize high or critical risk pull requests." + case "rec_health": + if zh { + return "根据健康度建议降低仓库治理风险。" + } + return "Use the health recommendations to reduce repository governance risk." + case "rec_maintain_report": + if zh { + return "保持当前维护节奏,并定期复查仓库工作流报告。" + } + return "Maintain the current workflow and review the repository report regularly." + case "rec_review_report": + if zh { + return "复查报告中的风险项并安排下一步维护动作。" + } + return "Review report risks and schedule the next maintenance actions." + default: + return key + } +} diff --git a/shortcuts/workflow/repo_report_fetch.go b/shortcuts/workflow/repo_report_fetch.go new file mode 100644 index 0000000..9610a71 --- /dev/null +++ b/shortcuts/workflow/repo_report_fetch.go @@ -0,0 +1,138 @@ +package workflow + +import ( + "fmt" + "net/url" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type RepoReportFetchOptions struct { + Owner string + Repo string + IssueLimit int + PRLimit int + StaleDays int + IncludeIssues bool + IncludePRs bool + IncludeHealth bool +} + +func FetchRepoReportInput(ctx *common.RuntimeContext, opts RepoReportFetchOptions) (RepoReportInput, []ScoringNote, error) { + owner, repo, err := resolveFetchRepo(ctx, opts.Owner, opts.Repo) + if err != nil { + return RepoReportInput{}, nil, fmt.Errorf("workflow +repo-report remote mode requires --owner and --repo or a Git remote: %w", err) + } + if opts.IssueLimit <= 0 { + opts.IssueLimit = 20 + } + if opts.PRLimit <= 0 { + opts.PRLimit = 10 + } + if opts.StaleDays <= 0 { + opts.StaleDays = 30 + } + + input := RepoReportInput{ + Repository: fmt.Sprintf("%s/%s", owner, repo), + Source: "remote-read-only-fetch", + } + notes := []ScoringNote{} + successes := 0 + + if opts.IncludeHealth { + health, healthNotes, err := FetchHealthInput(ctx, HealthFetchOptions{ + Owner: owner, + Repo: repo, + StaleDays: opts.StaleDays, + IncludeCI: true, + IncludeRelease: true, + IncludeDocs: true, + }) + notes = append(notes, healthNotes...) + if err != nil { + notes = append(notes, ScoringNote{Metric: "repo_report_health", Note: fmt.Sprintf("health fetch failed: %v", err)}) + } else { + input.Health = &health + successes++ + } + } + + if opts.IncludeIssues { + issues, err := FetchIssuesForTriage(ctx, TriageFetchOptions{ + Owner: owner, + Repo: repo, + State: "open", + Limit: opts.IssueLimit, + Page: 1, + }) + if err != nil { + notes = append(notes, ScoringNote{Metric: "repo_report_issues", Note: fmt.Sprintf("issue fetch failed: %v", err)}) + } else { + input.Issues = issues + successes++ + } + } + + if opts.IncludePRs { + prs, err := fetchPRListForReport(ctx, owner, repo, opts.PRLimit) + if err != nil { + notes = append(notes, ScoringNote{Metric: "repo_report_prs", Note: fmt.Sprintf("pull request list fetch failed: %v", err)}) + } else { + input.PullRequests = prs + successes++ + if len(prs) > 0 { + notes = append(notes, ScoringNote{ + Metric: "repo_report_prs", + Note: "PR report uses list metadata only; changed files and commits require workflow +pr-summary with a PR number.", + }) + } + } + } + + if successes == 0 { + if len(notes) == 0 { + notes = append(notes, ScoringNote{Metric: "repo_report", Note: "no report sections were enabled or fetched"}) + } + return RepoReportInput{}, uniqueScoringNotes(notes), fmt.Errorf("fetch repo report: all enabled sections failed") + } + return input, uniqueScoringNotes(notes), nil +} + +func fetchPRListForReport(ctx *common.RuntimeContext, owner, repo string, limit int) ([]PRSummaryInput, error) { + if limit <= 0 { + limit = 10 + } + query := url.Values{} + query.Set("state", "open") + query.Set("page", "1") + query.Set("limit", fmt.Sprintf("%d", limit)) + + env, err := ctx.CallAPIWithQuery("GET", workflowRepoPath(owner, repo)+"/pulls", query) + if err != nil { + return nil, err + } + items := apiList(env.Data) + inputs := make([]PRSummaryInput, 0, len(items)) + for _, raw := range items { + item, ok := raw.(map[string]interface{}) + if !ok { + continue + } + input, ok := normalizePRSummaryItem(item) + if !ok { + continue + } + input.Repository = fmt.Sprintf("%s/%s", owner, repo) + input.Source = "remote-read-only-fetch:list-metadata" + if strings.TrimSpace(input.State) == "" { + input.State = "open" + } + inputs = append(inputs, input) + if len(inputs) >= limit { + break + } + } + return inputs, nil +} diff --git a/shortcuts/workflow/repo_report_fetch_test.go b/shortcuts/workflow/repo_report_fetch_test.go new file mode 100644 index 0000000..37e9838 --- /dev/null +++ b/shortcuts/workflow/repo_report_fetch_test.go @@ -0,0 +1,183 @@ +package workflow + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +func TestFetchRepoReportInputPartialPRUnavailable(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.json": + writeWorkflowJSON(t, w, map[string]interface{}{ + "updated_at": "2026-05-20T00:00:00Z", + "has_readme": true, + "has_license": true, + "has_contributing": true, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json": + writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{ + {"number": 1, "title": "Install failed", "body": "error on install", "updated_at": "2026-05-20T00:00:00Z"}, + }}) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json": + http.Error(w, "pulls unavailable", http.StatusServiceUnavailable) + case r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json": + writeWorkflowJSON(t, w, map[string]interface{}{"releases": []map[string]interface{}{{"created_at": "2026-05-01T00:00:00Z"}}}) + case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json": + writeWorkflowJSON(t, w, map[string]interface{}{"builds": []map[string]interface{}{{"status": "success"}}}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{ + IssueLimit: 10, + PRLimit: 10, + StaleDays: 30, + IncludeHealth: true, + IncludeIssues: true, + IncludePRs: true, + }) + if err != nil { + t.Fatalf("FetchRepoReportInput returned error: %v", err) + } + if input.Health == nil || len(input.Issues) != 1 { + t.Fatalf("input = %+v, want health and issues", input) + } + if len(input.PullRequests) != 0 { + t.Fatalf("len(PullRequests) = %d, want 0", len(input.PullRequests)) + } + if !hasNote(notes, "repo_report_prs") { + t.Fatalf("notes = %+v, want repo_report_prs note", notes) + } +} + +func TestFetchRepoReportInputHealthFailureIssuesSuccess(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.json": + http.Error(w, "repo unavailable", http.StatusInternalServerError) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json": + writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{ + {"number": 1, "title": "README typo", "body": "docs typo"}, + }}) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json": + writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{}}) + case r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json": + writeWorkflowJSON(t, w, map[string]interface{}{"releases": []map[string]interface{}{}}) + case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json": + writeWorkflowJSON(t, w, map[string]interface{}{"builds": []map[string]interface{}{}}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{ + IssueLimit: 10, + IncludeHealth: true, + IncludeIssues: true, + IncludePRs: false, + }) + if err != nil { + t.Fatalf("FetchRepoReportInput returned error: %v", err) + } + if input.Health == nil || len(input.Issues) != 1 { + t.Fatalf("input = %+v, want degraded health and one issue", input) + } + if !hasNote(notes, "repository") { + t.Fatalf("notes = %+v, want repository note", notes) + } +} + +func TestFetchRepoReportInputAllSectionsFail(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "unavailable", http.StatusInternalServerError) + })) + defer server.Close() + + _, _, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{ + IncludeHealth: false, + IncludeIssues: true, + IncludePRs: true, + }) + if err == nil { + t.Fatal("FetchRepoReportInput returned nil error when all sections failed") + } +} + +func TestFetchRepoReportInputRespectsIssueLimitAndIncludeFlags(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("limit"); got != "1" { + t.Fatalf("issue limit = %q, want 1", got) + } + writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{ + {"number": 1, "title": "First bug", "body": "error"}, + {"number": 2, "title": "Second bug", "body": "error"}, + }}) + })) + defer server.Close() + + input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{ + IssueLimit: 1, + IncludeHealth: false, + IncludeIssues: true, + IncludePRs: false, + }) + if err != nil { + t.Fatalf("FetchRepoReportInput returned error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("notes = %+v, want empty", notes) + } + if len(input.Issues) != 1 { + t.Fatalf("len(Issues) = %d, want 1", len(input.Issues)) + } +} + +func TestFetchRepoReportInputPRListMetadata(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/pulls.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("limit"); got != "1" { + t.Fatalf("PR limit = %q, want 1", got) + } + writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{ + {"number": 1, "title": "feat: add report", "user": map[string]interface{}{"login": "alice"}}, + {"number": 2, "title": "docs: update guide"}, + }}) + })) + defer server.Close() + + input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{ + PRLimit: 1, + IncludeHealth: false, + IncludeIssues: false, + IncludePRs: true, + }) + if err != nil { + t.Fatalf("FetchRepoReportInput returned error: %v", err) + } + if len(input.PullRequests) != 1 { + t.Fatalf("len(PullRequests) = %d, want 1", len(input.PullRequests)) + } + if !hasNote(notes, "repo_report_prs") || !strings.Contains(notes[0].Note, "list metadata") { + t.Fatalf("notes = %+v, want list metadata note", notes) + } +} + +func hasNote(notes []ScoringNote, metric string) bool { + for _, note := range notes { + if note.Metric == metric { + return true + } + } + return false +} diff --git a/shortcuts/workflow/repo_report_test.go b/shortcuts/workflow/repo_report_test.go new file mode 100644 index 0000000..059ea94 --- /dev/null +++ b/shortcuts/workflow/repo_report_test.go @@ -0,0 +1,195 @@ +package workflow + +import ( + "encoding/json" + "strings" + "testing" +) + +func TestAnalyzeRepoReportAggregatesHealthIssuesAndPRs(t *testing.T) { + input := sampleRepoReportInput() + result := AnalyzeRepoReport(input, "en") + if result.ReportScore < 0 || result.ReportScore > 100 { + t.Fatalf("ReportScore = %d, want 0..100", result.ReportScore) + } + if result.IssueSummary.Total != 3 { + t.Fatalf("IssueSummary.Total = %d, want 3", result.IssueSummary.Total) + } + if result.IssueSummary.ByType[IssueTypeBug] == 0 { + t.Fatalf("IssueSummary.ByType = %+v, want bug count", result.IssueSummary.ByType) + } + if result.PRSummary.Total != 3 { + t.Fatalf("PRSummary.Total = %d, want 3", result.PRSummary.Total) + } + if result.PRSummary.ByRisk[PRRiskHigh] == 0 { + t.Fatalf("PRSummary.ByRisk = %+v, want high risk count", result.PRSummary.ByRisk) + } + if len(result.Recommendations) == 0 { + t.Fatal("Recommendations empty") + } +} + +func TestAnalyzeRepoReportWithSecurityIssueRaisesRisk(t *testing.T) { + input := RepoReportInput{ + Repository: "owner/repo", + Issues: []IssueInput{{ + Number: 1, + Title: "Token leaked in logs", + Body: "A secret token leaked from command output.", + Labels: []string{"security"}, + }}, + } + result := AnalyzeRepoReport(input, "en") + if result.RiskLevel != "critical" { + t.Fatalf("RiskLevel = %q, want critical", result.RiskLevel) + } +} + +func TestAnalyzeRepoReportPartialInput(t *testing.T) { + input := RepoReportInput{ + Repository: "owner/repo", + Health: &HealthInput{ + Repository: "owner/repo", + OpenIssues: 1, + OpenPRs: 0, + RecentActivityKnown: true, + RecentActivityDays: 2, + ReleaseKnown: true, + HasRecentRelease: true, + HasReadme: true, + HasLicense: true, + HasContributing: true, + AgentReadinessKnown: true, + AgentReadinessScore: 9, + }, + } + result := AnalyzeRepoReport(input, "en") + if result.Health == nil { + t.Fatal("Health result nil") + } + if len(result.Recommendations) == 0 { + t.Fatal("Recommendations empty") + } +} + +func TestAnalyzeRepoReportChinese(t *testing.T) { + result := AnalyzeRepoReport(sampleRepoReportInput(), "zh-CN") + if len(result.Recommendations) == 0 { + t.Fatal("Recommendations empty") + } + rendered, err := RenderRepoReport(result, "markdown", "zh-CN") + if err != nil { + t.Fatalf("RenderRepoReport returned error: %v", err) + } + if !strings.Contains(rendered, "仓库工作流报告") { + t.Fatalf("markdown output missing Chinese title:\n%s", rendered) + } +} + +func TestRenderRepoReportJSON(t *testing.T) { + rendered, err := RenderRepoReport(AnalyzeRepoReport(sampleRepoReportInput(), "en"), "json", "en") + if err != nil { + t.Fatalf("RenderRepoReport returned error: %v", err) + } + var result RepoReportResult + if err := json.Unmarshal([]byte(rendered), &result); err != nil { + t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, rendered) + } + if result.Repository != "owner/repo" { + t.Fatalf("Repository = %q, want owner/repo", result.Repository) + } +} + +func TestRenderRepoReportMarkdown(t *testing.T) { + result := AnalyzeRepoReport(sampleRepoReportInput(), "en") + rendered, err := RenderRepoReport(result, "markdown", "en") + if err != nil { + t.Fatalf("RenderRepoReport returned error: %v", err) + } + for _, want := range []string{"Repository Workflow Report", "Report score", "Recommendations"} { + if !strings.Contains(rendered, want) { + t.Fatalf("markdown output missing %q:\n%s", want, rendered) + } + } +} + +func TestRenderRepoReportTable(t *testing.T) { + rendered, err := RenderRepoReport(AnalyzeRepoReport(sampleRepoReportInput(), "en"), "table", "en") + if err != nil { + t.Fatalf("RenderRepoReport returned error: %v", err) + } + if !strings.Contains(rendered, "REPORT_SCORE") || !strings.Contains(rendered, "owner/repo") { + t.Fatalf("table output = %q, want report score and repository", rendered) + } +} + +func TestRenderRepoReportUnknownFormat(t *testing.T) { + _, err := RenderRepoReport(AnalyzeRepoReport(sampleRepoReportInput(), "en"), "xml", "en") + if err == nil { + t.Fatal("RenderRepoReport returned nil error for unknown format") + } +} + +func TestReadRepoReportInput(t *testing.T) { + input, err := readRepoReportInput("testdata/repo_report.json") + if err != nil { + t.Fatalf("readRepoReportInput returned error: %v", err) + } + if input.Repository == "" || len(input.Issues) == 0 || len(input.PullRequests) == 0 { + t.Fatalf("input = %+v, want populated fixture", input) + } +} + +func sampleRepoReportInput() RepoReportInput { + return RepoReportInput{ + Repository: "owner/repo", + Health: &HealthInput{ + Repository: "owner/repo", + OpenIssues: 5, + OpenPRs: 3, + StaleIssues: 1, + StalePRs: 1, + RecentActivityKnown: true, + RecentActivityDays: 2, + ReleaseKnown: true, + HasRecentRelease: true, + HasReadme: true, + HasLicense: true, + HasContributing: true, + AgentReadinessKnown: true, + AgentReadinessScore: 9, + }, + Issues: []IssueInput{ + {Number: 1, Title: "Install failed", Body: "error on install", Labels: []string{"bug"}}, + {Number: 2, Title: "README typo", Body: "docs typo", Labels: []string{"docs"}}, + {Number: 3, Title: "Crash on login", Body: "panic", Labels: []string{"bug"}}, + }, + PullRequests: []PRSummaryInput{ + { + Repository: "owner/repo", + Number: 1, + Title: "docs: update guide", + ChangedFiles: []PRChangedFile{ + {Filename: "README.md", Additions: 10, Deletions: 1, Changes: 11}, + }, + }, + { + Repository: "owner/repo", + Number: 2, + Title: "feat: add workflow command", + ChangedFiles: []PRChangedFile{ + {Filename: "shortcuts/workflow/workflow.go", Additions: 40, Deletions: 4, Changes: 44}, + }, + }, + { + Repository: "owner/repo", + Number: 3, + Title: "fix: normalize API client errors", + ChangedFiles: []PRChangedFile{ + {Filename: "internal/client/client.go", Additions: 30, Deletions: 10, Changes: 40}, + }, + }, + }, + Source: "local-json", + } +} diff --git a/shortcuts/workflow/testdata/health_good.json b/shortcuts/workflow/testdata/health_good.json new file mode 100644 index 0000000..b36b018 --- /dev/null +++ b/shortcuts/workflow/testdata/health_good.json @@ -0,0 +1,18 @@ +{ + "repository": "Gitlink/gitlink-cli", + "open_issues": 3, + "open_prs": 1, + "stale_issues": 0, + "stale_prs": 0, + "recent_activity_known": true, + "recent_activity_days": 3, + "release_known": true, + "has_recent_release": true, + "ci_known": true, + "ci_passing": true, + "has_readme": true, + "has_license": true, + "has_contributing": true, + "agent_readiness_known": true, + "agent_readiness_score": 9 +} diff --git a/shortcuts/workflow/testdata/health_risky.json b/shortcuts/workflow/testdata/health_risky.json new file mode 100644 index 0000000..370ac81 --- /dev/null +++ b/shortcuts/workflow/testdata/health_risky.json @@ -0,0 +1,18 @@ +{ + "repository": "demo/repo", + "open_issues": 60, + "open_prs": 12, + "stale_issues": 25, + "stale_prs": 6, + "recent_activity_known": true, + "recent_activity_days": 120, + "release_known": true, + "has_recent_release": false, + "ci_known": false, + "ci_passing": false, + "has_readme": false, + "has_license": false, + "has_contributing": false, + "agent_readiness_known": true, + "agent_readiness_score": 2 +} diff --git a/shortcuts/workflow/testdata/issue_bug.json b/shortcuts/workflow/testdata/issue_bug.json new file mode 100644 index 0000000..75092dd --- /dev/null +++ b/shortcuts/workflow/testdata/issue_bug.json @@ -0,0 +1,9 @@ +{ + "number": 12, + "title": "Install failed on Windows", + "body": "go install failed with error. Expected behavior: install succeeds. Actual behavior: the command returns a build failure. Version: v1.0.0. OS: Windows 11. Output: build failed with exit code 1.", + "state": "open", + "author": "alice", + "url": "https://example.com/issues/12", + "labels": ["bug"] +} diff --git a/shortcuts/workflow/testdata/issue_security.json b/shortcuts/workflow/testdata/issue_security.json new file mode 100644 index 0000000..2e536cf --- /dev/null +++ b/shortcuts/workflow/testdata/issue_security.json @@ -0,0 +1,9 @@ +{ + "number": 21, + "title": "Token leaked in logs", + "body": "The access token appears in command output. This looks like a possible secret leak and auth problem.", + "state": "open", + "author": "bob", + "url": "https://example.com/issues/21", + "labels": ["security", "urgent"] +} diff --git a/shortcuts/workflow/testdata/pr_summary.json b/shortcuts/workflow/testdata/pr_summary.json new file mode 100644 index 0000000..6a23ec5 --- /dev/null +++ b/shortcuts/workflow/testdata/pr_summary.json @@ -0,0 +1,37 @@ +{ + "repository": "Gitlink/gitlink-cli", + "number": 1, + "title": "feat: add workflow PR summary", + "author": "alice", + "state": "open", + "base_branch": "master", + "head_branch": "feature/workflow-pr-summary", + "body": "Add a read-only workflow PR summary command for maintainers and agents.", + "changed_files": [ + { + "filename": "shortcuts/workflow/pr_summary.go", + "status": "added", + "additions": 120, + "deletions": 0, + "changes": 120 + }, + { + "filename": "docs/workflow-agent-design.md", + "status": "modified", + "additions": 18, + "deletions": 2, + "changes": 20 + } + ], + "commits": [ + { + "sha": "abc1234", + "message": "feat: add workflow PR summary", + "author": "alice", + "date": "2026-05-20T10:00:00Z" + } + ], + "additions": 138, + "deletions": 2, + "source": "local-json" +} diff --git a/shortcuts/workflow/testdata/repo_report.json b/shortcuts/workflow/testdata/repo_report.json new file mode 100644 index 0000000..9506fc2 --- /dev/null +++ b/shortcuts/workflow/testdata/repo_report.json @@ -0,0 +1,134 @@ +{ + "repository": "Gitlink/gitlink-cli", + "health": { + "repository": "Gitlink/gitlink-cli", + "open_issues": 8, + "open_prs": 3, + "stale_issues": 2, + "stale_prs": 1, + "recent_activity_known": true, + "recent_activity_days": 3, + "release_known": true, + "has_recent_release": true, + "ci_known": false, + "ci_passing": false, + "has_readme": true, + "has_license": true, + "has_contributing": true, + "agent_readiness_known": true, + "agent_readiness_score": 9 + }, + "issues": [ + { + "number": 1, + "title": "Install failed on Windows", + "body": "go install failed with error", + "state": "open", + "author": "alice", + "labels": ["bug"] + }, + { + "number": 2, + "title": "README typo in installation guide", + "body": "The docs example has a typo.", + "state": "open", + "author": "bob", + "labels": ["docs"] + }, + { + "number": 3, + "title": "Command crashes on login", + "body": "panic on login", + "state": "open", + "author": "carol", + "labels": ["bug"] + } + ], + "pull_requests": [ + { + "repository": "Gitlink/gitlink-cli", + "number": 11, + "title": "docs: update workflow examples", + "author": "dana", + "state": "open", + "base_branch": "master", + "head_branch": "docs/workflow", + "changed_files": [ + { + "filename": "docs/workflow-agent-design.md", + "status": "modified", + "additions": 20, + "deletions": 4, + "changes": 24 + } + ], + "commits": [ + { + "sha": "1111111", + "message": "docs: update workflow examples", + "author": "dana" + } + ], + "additions": 20, + "deletions": 4, + "source": "local-json" + }, + { + "repository": "Gitlink/gitlink-cli", + "number": 12, + "title": "feat: add workflow command", + "author": "erin", + "state": "open", + "base_branch": "master", + "head_branch": "feature/workflow", + "changed_files": [ + { + "filename": "shortcuts/workflow/workflow.go", + "status": "modified", + "additions": 70, + "deletions": 8, + "changes": 78 + } + ], + "commits": [ + { + "sha": "2222222", + "message": "feat: add workflow command", + "author": "erin" + } + ], + "additions": 70, + "deletions": 8, + "source": "local-json" + }, + { + "repository": "Gitlink/gitlink-cli", + "number": 13, + "title": "fix: normalize API client errors", + "author": "frank", + "state": "open", + "base_branch": "master", + "head_branch": "fix/client-errors", + "changed_files": [ + { + "filename": "internal/client/client.go", + "status": "modified", + "additions": 30, + "deletions": 12, + "changes": 42 + } + ], + "commits": [ + { + "sha": "3333333", + "message": "fix: normalize API client errors", + "author": "frank" + } + ], + "additions": 30, + "deletions": 12, + "source": "local-json" + } + ], + "source": "local-json" +} diff --git a/shortcuts/workflow/triage_fetch.go b/shortcuts/workflow/triage_fetch.go new file mode 100644 index 0000000..a509cf8 --- /dev/null +++ b/shortcuts/workflow/triage_fetch.go @@ -0,0 +1,170 @@ +package workflow + +import ( + "fmt" + "net/url" + "strings" + "time" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func FetchIssuesForTriage(ctx *common.RuntimeContext, opts TriageFetchOptions) ([]IssueInput, error) { + owner, repo, err := resolveFetchRepo(ctx, opts.Owner, opts.Repo) + if err != nil { + return nil, err + } + + limit := opts.Limit + if limit <= 0 { + limit = 30 + } + page := opts.Page + if page <= 0 { + page = 1 + } + state := strings.TrimSpace(opts.State) + if state == "" { + state = "open" + } + + query := url.Values{} + query.Set("state", state) + query.Set("limit", fmt.Sprintf("%d", limit)) + query.Set("page", fmt.Sprintf("%d", page)) + if len(opts.Labels) > 0 { + query.Set("labels", strings.Join(opts.Labels, ",")) + } + if strings.TrimSpace(opts.Since) != "" { + query.Set("since", strings.TrimSpace(opts.Since)) + } + + env, err := ctx.CallAPIWithQuery("GET", workflowRepoPath(owner, repo)+"/issues", query) + if err != nil { + return nil, fmt.Errorf("fetch issues for triage: %w", err) + } + + items := apiList(env.Data) + issues := make([]IssueInput, 0, len(items)) + for _, raw := range items { + issue, ok := normalizeIssueItem(raw) + if !ok { + continue + } + issues = append(issues, issue) + if len(issues) >= limit { + break + } + } + + if len(issues) == 0 { + return nil, fmt.Errorf("fetch issues for triage: no issues found in API response") + } + + return issues, nil +} + +func normalizeIssueItem(raw interface{}) (IssueInput, bool) { + item, ok := raw.(map[string]interface{}) + if !ok { + return IssueInput{}, false + } + + title := firstIssueString(item, "title", "subject") + body := firstIssueString(item, "body", "description", "content") + if strings.TrimSpace(title) == "" && strings.TrimSpace(body) == "" { + return IssueInput{}, false + } + + number := firstIssueInt(item, "number", "iid", "issue_number", "project_issues_index", "id") + id := firstIssueString(item, "id") + if id == "" { + id = fmt.Sprintf("%d", number) + } + state := firstIssueString(item, "state", "status") + author := firstIssueString(item, "author", "user", "creator") + urlValue := firstIssueString(item, "html_url", "url", "web_url") + labels := firstIssueLabels(item["labels"]) + createdAt := firstIssueTime(item, "created_at", "created") + updatedAt := firstIssueTime(item, "updated_at", "updated", "last_updated_at") + comments := firstIssueInt(item, "comments_count", "comments") + + return IssueInput{ + ID: id, + Number: number, + Title: title, + Body: body, + State: state, + Author: apiAuthor(authorValue(item, author)), + URL: urlValue, + Labels: labels, + CreatedAt: createdAt, + UpdatedAt: updatedAt, + CommentsCount: comments, + }, true +} + +func authorValue(item map[string]interface{}, fallback string) interface{} { + if raw, ok := item["author"]; ok { + return raw + } + if raw, ok := item["user"]; ok { + return raw + } + if raw, ok := item["creator"]; ok { + return raw + } + return fallback +} + +func firstIssueString(item map[string]interface{}, keys ...string) string { + for _, key := range keys { + if value, ok := item[key]; ok { + if s := apiString(value); s != "" { + return s + } + } + } + return "" +} + +func firstIssueInt(item map[string]interface{}, keys ...string) int { + for _, key := range keys { + if value, ok := item[key]; ok { + if n := apiInt(value); n != 0 { + return n + } + } + } + return 0 +} + +func firstIssueTime(item map[string]interface{}, keys ...string) time.Time { + for _, key := range keys { + if value, ok := item[key]; ok { + if t := apiTime(value); !t.IsZero() { + return t + } + } + } + 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) + } + } + return out + case []string: + return append([]string(nil), labels...) + case string: + return apiStringSlice(labels) + default: + return nil + } +} diff --git a/shortcuts/workflow/triage_fetch_test.go b/shortcuts/workflow/triage_fetch_test.go new file mode 100644 index 0000000..f7af88a --- /dev/null +++ b/shortcuts/workflow/triage_fetch_test.go @@ -0,0 +1,257 @@ +package workflow + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestFetchIssuesForTriageNormalizesAPIResponse(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + if got := r.URL.Query().Get("state"); got != "open" { + t.Fatalf("state query = %q, want open", got) + } + if got := r.URL.Query().Get("limit"); got != "30" { + t.Fatalf("limit query = %q, want 30", got) + } + writeWorkflowJSON(t, w, map[string]interface{}{ + "issues": []map[string]interface{}{ + { + "id": 12345, + "project_issues_index": 7, + "subject": "Crash on login", + "description": "panic error when running login", + "status": "open", + "author": map[string]interface{}{"login": "alice"}, + "labels": []map[string]interface{}{{"name": "bug"}, {"name": "login"}}, + "created_at": "2026-05-01T10:00:00Z", + "updated_at": "2026-05-02T10:00:00Z", + "comments_count": 2, + "html_url": "https://www.gitlink.org.cn/owner/repo/issues/7", + }, + }, + }) + })) + defer server.Close() + + ctx := workflowTestContext(server) + issues, err := FetchIssuesForTriage(ctx, TriageFetchOptions{State: "open", Limit: 30, Page: 1}) + if err != nil { + t.Fatalf("FetchIssuesForTriage returned error: %v", err) + } + if len(issues) != 1 { + t.Fatalf("len(issues) = %d, want 1", len(issues)) + } + issue := issues[0] + if issue.ID != "12345" { + t.Fatalf("ID = %q, want 12345", issue.ID) + } + if issue.Number != 7 { + t.Fatalf("Number = %d, want 7", issue.Number) + } + if issue.Title != "Crash on login" { + t.Fatalf("Title = %q, want Crash on login", issue.Title) + } + if issue.Author != "alice" { + t.Fatalf("Author = %q, want alice", issue.Author) + } + if len(issue.Labels) != 2 || issue.Labels[0] != "bug" || issue.Labels[1] != "login" { + t.Fatalf("Labels = %v, want [bug login]", issue.Labels) + } + if issue.CreatedAt.IsZero() || issue.UpdatedAt.IsZero() { + t.Fatalf("expected parsed timestamps, got created=%v updated=%v", issue.CreatedAt, issue.UpdatedAt) + } +} + +func TestFetchIssuesForTriageSupportsDataString(t *testing.T) { + payload, err := json.Marshal([]map[string]interface{}{ + { + "number": 3, + "title": "README typo", + "body": "documentation example typo", + "state": "open", + }, + }) + if err != nil { + t.Fatalf("json.Marshal returned error: %v", err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeWorkflowJSON(t, w, map[string]interface{}{"data": string(payload)}) + })) + defer server.Close() + + issues, err := FetchIssuesForTriage(workflowTestContext(server), TriageFetchOptions{Limit: 10}) + if err != nil { + t.Fatalf("FetchIssuesForTriage returned error: %v", err) + } + if len(issues) != 1 { + t.Fatalf("len(issues) = %d, want 1", len(issues)) + } + if issues[0].Number != 3 || issues[0].Title != "README typo" { + t.Fatalf("issue = %+v, want number 3 title README typo", issues[0]) + } +} + +func TestFetchIssuesForTriageEmptyResponseReturnsError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{}}) + })) + defer server.Close() + + _, err := FetchIssuesForTriage(workflowTestContext(server), TriageFetchOptions{Limit: 10}) + if err == nil { + t.Fatal("FetchIssuesForTriage returned nil error for empty response") + } + if !strings.Contains(err.Error(), "no issues found") { + t.Fatalf("error = %v, want empty-response message", err) + } +} + +func TestFetchIssuesForTriageNormalizesLabelAndAuthorShapes(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.URL.Query().Get("page"); got != "1" { + t.Fatalf("page query = %q, want 1", got) + } + if got := r.URL.Query().Get("limit"); got != "3" { + t.Fatalf("limit query = %q, want 3", got) + } + writeWorkflowJSON(t, w, map[string]interface{}{ + "issues": []map[string]interface{}{ + { + "number": 1, + "body": "panic on install", + "labels": []string{"bug", "help wanted"}, + "author": "alice", + }, + { + "number": 2, + "title": "token leak", + "labels": []map[string]interface{}{{"name": "bug"}, {"name": "security"}}, + "user": map[string]interface{}{"login": "bob"}, + }, + { + "number": 3, + "title": "README typo", + "labels": []map[string]interface{}{{"title": "docs"}}, + "creator": map[string]interface{}{"name": "carol"}, + }, + }, + }) + })) + defer server.Close() + + issues, err := FetchIssuesForTriage(workflowTestContext(server), TriageFetchOptions{State: "open", Limit: 3, Page: 1}) + if err != nil { + t.Fatalf("FetchIssuesForTriage returned error: %v", err) + } + if len(issues) != 3 { + t.Fatalf("len(issues) = %d, want 3", len(issues)) + } + if issues[0].Title != "" || issues[0].Body != "panic on install" { + t.Fatalf("issue[0] = %+v, want body-only item with empty title", issues[0]) + } + if got := strings.Join(issues[0].Labels, ","); got != "bug,help wanted" { + t.Fatalf("issue[0].Labels = %q, want bug,help wanted", got) + } + if issues[0].Author != "alice" { + t.Fatalf("issue[0].Author = %q, want alice", issues[0].Author) + } + if got := strings.Join(issues[1].Labels, ","); got != "bug,security" { + t.Fatalf("issue[1].Labels = %q, want bug,security", got) + } + if issues[1].Author != "bob" { + t.Fatalf("issue[1].Author = %q, want bob", issues[1].Author) + } + if got := strings.Join(issues[2].Labels, ","); got != "docs" { + t.Fatalf("issue[2].Labels = %q, want docs", got) + } + if issues[2].Author != "carol" { + t.Fatalf("issue[2].Author = %q, want carol", issues[2].Author) + } +} + +func TestFetchIssuesForTriageRespectsLimitAndReportsRequestErrors(t *testing.T) { + requestCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requestCount++ + if got := r.URL.Query().Get("page"); got != "2" { + t.Fatalf("page query = %q, want 2", got) + } + if got := r.URL.Query().Get("limit"); got != "1" { + t.Fatalf("limit query = %q, want 1", got) + } + writeWorkflowJSON(t, w, map[string]interface{}{ + "issues": []map[string]interface{}{ + {"number": 1, "title": "first"}, + {"number": 2, "title": "second"}, + }, + }) + })) + defer server.Close() + + issues, err := FetchIssuesForTriage(workflowTestContext(server), TriageFetchOptions{State: "open", Limit: 1, Page: 2}) + if err != nil { + t.Fatalf("FetchIssuesForTriage returned error: %v", err) + } + if requestCount != 1 { + t.Fatalf("requestCount = %d, want 1", requestCount) + } + if len(issues) != 1 { + t.Fatalf("len(issues) = %d, want 1", len(issues)) + } + if issues[0].Number != 1 { + t.Fatalf("issues[0].Number = %d, want 1", issues[0].Number) + } +} + +func TestFetchIssuesForTriageReportsGitLinkErrorInBody(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + writeWorkflowJSON(t, w, map[string]interface{}{ + "status": 403, + "message": "permission denied", + }) + })) + defer server.Close() + + _, err := FetchIssuesForTriage(workflowTestContext(server), TriageFetchOptions{Limit: 10}) + if err == nil { + t.Fatal("FetchIssuesForTriage returned nil error for error-in-body response") + } + if !strings.Contains(err.Error(), "permission denied") { + t.Fatalf("error = %v, want permission denied", err) + } +} + +func workflowTestContext(server *httptest.Server) *common.RuntimeContext { + return &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: map[string]string{}, + } +} + +func writeWorkflowJSON(t *testing.T, w http.ResponseWriter, payload interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(payload); err != nil { + t.Fatalf("failed to write response: %v", err) + } +} diff --git a/shortcuts/workflow/triage_rules.go b/shortcuts/workflow/triage_rules.go new file mode 100644 index 0000000..2ecaf5a --- /dev/null +++ b/shortcuts/workflow/triage_rules.go @@ -0,0 +1,306 @@ +package workflow + +import ( + "fmt" + "sort" + "strings" +) + +type keywordRule struct { + issueType string + keywords []string + weight int +} + +var triageKeywordRules = []keywordRule{ + {IssueTypeBug, []string{"crash", "error", "panic", "exception", "fail", "failed", "failure", "broken", "cannot", "bug", "报错", "崩溃", "异常", "失败", "无法", "不能", "问题"}, 10}, + {IssueTypeFeature, []string{"feature", "request", "support", "add", "implement", "enhancement", "功能", "支持", "增加", "建议", "增强"}, 8}, + {IssueTypeQuestion, []string{"how", "why", "help", "usage", "question", "如何", "怎么", "为什么", "请问", "求助"}, 7}, + {IssueTypeDocs, []string{"doc", "docs", "documentation", "readme", "typo", "example", "guide", "文档", "说明", "错别字", "示例", "教程"}, 8}, + {IssueTypeCI, []string{"ci", "build", "workflow", "action", "test failed", "pipeline", "构建", "测试失败", "流水线"}, 10}, + {IssueTypeSecurity, []string{"token", "leak", "leaked", "secret", "auth", "permission", "vulnerability", "cve", "泄露", "密钥", "权限", "漏洞", "安全"}, 12}, + {IssueTypePerformance, []string{"slow", "timeout", "latency", "memory", "cpu", "performance", "慢", "超时", "性能", "内存"}, 8}, + {IssueTypeRefactor, []string{"refactor", "cleanup", "simplify", "restructure", "重构", "清理", "简化", "结构调整"}, 6}, +} + +var typeTieOrder = []string{ + IssueTypeSecurity, + IssueTypeBug, + IssueTypeCI, + IssueTypePerformance, + IssueTypeFeature, + IssueTypeDocs, + IssueTypeQuestion, + IssueTypeRefactor, +} + +func AnalyzeIssue(input IssueInput, lang string) TriageResult { + lang = normalizeLang(lang) + text := normalizeIssueText(input) + + scores, matchedRules, reasoning := scoreIssueTypes(text, input.Labels) + detectedType := chooseDetectedType(scores) + riskFlags := detectRiskFlags(text, detectedType) + priority, priorityReason := determinePriority(text, detectedType) + if priorityReason != "" { + matchedRules = append(matchedRules, priorityReason) + reasoning = append(reasoning, priorityReason) + } + + missingInformation := detectMissingInformation(text, detectedType, lang) + if len(missingInformation) > 0 { + riskFlags = append(riskFlags, RiskInsufficientInfo) + matchedRules = append(matchedRules, "missing information detected") + reasoning = append(reasoning, "missing information detected") + } + + confidence := calculateConfidence(detectedType, scores, matchedRules, input.Labels) + return TriageResult{ + Issue: IssueRef{ + ID: input.ID, + Number: input.Number, + Title: input.Title, + URL: input.URL, + Author: input.Author, + State: input.State, + }, + DetectedType: detectedType, + Priority: priority, + Confidence: confidence, + SuggestedLabels: suggestedLabels(detectedType, priority, riskFlags), + MissingInformation: missingInformation, + RiskFlags: uniqueStrings(riskFlags), + RecommendedAction: recommendedAction(detectedType, priority, riskFlags, missingInformation), + SuggestedComment: suggestedComment(lang, detectedType, missingInformation), + Reasoning: uniqueStrings(reasoning), + MatchedRules: uniqueStrings(matchedRules), + } +} + +func normalizeIssueText(input IssueInput) string { + parts := []string{input.Title, input.Body} + parts = append(parts, input.Labels...) + return strings.ToLower(strings.Join(parts, "\n")) +} + +func scoreIssueTypes(text string, labels []string) (map[string]int, []string, []string) { + scores := make(map[string]int) + matchedRules := []string{} + reasoning := []string{} + + for _, rule := range triageKeywordRules { + for _, keyword := range rule.keywords { + if strings.Contains(text, strings.ToLower(keyword)) { + scores[rule.issueType] += rule.weight + ruleText := "matched keyword: " + keyword + matchedRules = append(matchedRules, ruleText) + reasoning = append(reasoning, fmt.Sprintf("%s -> %s", ruleText, rule.issueType)) + } + } + } + + for _, label := range labels { + normalized := strings.ToLower(strings.TrimSpace(label)) + for _, issueType := range typeTieOrder { + if normalized == issueType || strings.Contains(normalized, issueType) { + scores[issueType] += 14 + ruleText := "label matched: " + normalized + matchedRules = append(matchedRules, ruleText) + reasoning = append(reasoning, fmt.Sprintf("%s -> %s", ruleText, issueType)) + } + } + } + + return scores, matchedRules, reasoning +} + +func chooseDetectedType(scores map[string]int) string { + bestType := IssueTypeUnknown + bestScore := 0 + for _, issueType := range typeTieOrder { + score := scores[issueType] + if score > bestScore { + bestType = issueType + bestScore = score + } + } + return bestType +} + +func determinePriority(text string, detectedType string) (string, string) { + if detectedType == IssueTypeSecurity || containsAny(text, []string{"token leak", "secret leak", "leaked token", "leaked secret", "auth bypass", "permission bypass", "vulnerability", "cve", "密钥泄露", "漏洞", "认证绕过", "权限绕过"}) { + return PriorityP0, "priority rule: security sensitive token leak" + } + if containsAny(text, []string{"cannot login", "install failed", "installation failed", "core command unavailable", "command unavailable", "login failed", "crash", "panic", "无法登录", "安装失败", "核心命令不可用", "崩溃"}) { + return PriorityP1, "priority rule: core blocker" + } + if detectedType == IssueTypeBug || detectedType == IssueTypeCI || detectedType == IssueTypePerformance { + return PriorityP2, "priority rule: normal bug or operational failure" + } + if detectedType == IssueTypeFeature { + return PriorityP2, "priority rule: feature request" + } + return PriorityP3, "priority rule: low risk request" +} + +func calculateConfidence(detectedType string, scores map[string]int, matchedRules []string, labels []string) int { + if detectedType == IssueTypeUnknown { + return 20 + } + + confidence := 35 + scores[detectedType] + if len(matchedRules) > 1 { + confidence += minInt(len(matchedRules)*3, 18) + } + if len(labels) > 0 { + confidence += 8 + } + if detectedType == IssueTypeSecurity || detectedType == IssueTypeBug || detectedType == IssueTypeCI { + confidence += 10 + } + return clampInt(confidence, 0, 100) +} + +func detectMissingInformation(text string, detectedType string, lang string) []string { + if detectedType != IssueTypeBug { + return nil + } + + checks := []struct { + key string + present []string + messageK string + }{ + {"reproduction_steps", []string{"reproduction", "reproduce", "steps", "复现", "步骤"}, "missing_reproduction_steps"}, + {"expected_behavior", []string{"expected", "expect", "期望", "预期"}, "missing_expected_behavior"}, + {"actual_behavior", []string{"actual", "实际"}, "missing_actual_behavior"}, + {"version", []string{"version", "版本"}, "missing_version"}, + {"os_or_platform", []string{"os", "platform", "windows", "linux", "macos", "darwin", "系统", "平台"}, "missing_os_or_platform"}, + {"command_output_or_logs", []string{"output", "log", "trace", "stdout", "stderr", "输出", "日志"}, "missing_command_output_or_logs"}, + } + + missing := []string{} + for _, check := range checks { + if !containsAny(text, check.present) { + if normalizeLang(lang) == langZH { + missing = append(missing, message(lang, check.messageK)) + } else { + missing = append(missing, check.key) + } + } + } + return missing +} + +func detectRiskFlags(text string, detectedType string) []string { + flags := []string{} + if detectedType == IssueTypeSecurity || containsAny(text, []string{"vulnerability", "cve", "漏洞", "安全", "auth bypass", "permission bypass", "认证绕过", "权限绕过"}) { + flags = append(flags, RiskSecuritySensitive) + } + if containsAny(text, []string{"token leak", "secret leak", "leaked token", "leaked secret", "token leaked", "secret leaked", "密钥泄露", "泄露"}) { + flags = append(flags, RiskPossibleSecretLeak) + } + if containsAny(text, []string{"install failed", "installation failed", "安装失败"}) { + flags = append(flags, RiskInstallationBlocker) + } + if containsAny(text, []string{"cannot login", "login failed", "auth failed", "无法登录", "登录失败"}) { + flags = append(flags, RiskAuthenticationBlocker) + } + if detectedType == IssueTypeCI || containsAny(text, []string{"test failed", "pipeline failed", "build failed", "测试失败", "构建失败"}) { + flags = append(flags, RiskCIBlocker) + } + return flags +} + +func suggestedLabels(detectedType string, priority string, riskFlags []string) []string { + labels := []string{} + if detectedType != IssueTypeUnknown { + labels = append(labels, detectedType) + } + labels = append(labels, strings.ToLower(priority)) + for _, flag := range riskFlags { + switch flag { + case RiskSecuritySensitive, RiskPossibleSecretLeak: + labels = append(labels, "security") + case RiskCIBlocker: + labels = append(labels, "ci") + } + } + return uniqueStrings(labels) +} + +func recommendedAction(detectedType string, priority string, riskFlags []string, missingInformation []string) string { + if containsString(riskFlags, RiskSecuritySensitive) || containsString(riskFlags, RiskPossibleSecretLeak) || detectedType == IssueTypeSecurity { + return ActionReviewSecurity + } + if priority == PriorityP0 { + return ActionPrioritizeImmediate + } + if len(missingInformation) > 0 { + return ActionRequestMoreInfo + } + switch detectedType { + case IssueTypeQuestion: + return ActionConvertToDiscussion + case IssueTypeDocs: + return ActionUpdateDocs + case IssueTypeCI: + return ActionInvestigateCI + default: + return ActionScheduleFix + } +} + +func suggestedComment(lang string, detectedType string, missingInformation []string) string { + lang = normalizeLang(lang) + if detectedType == IssueTypeSecurity { + return message(lang, "comment_security") + } + if len(missingInformation) > 0 { + return fmt.Sprintf(message(lang, "comment_more_info"), strings.Join(missingInformation, ", ")) + } + if detectedType == IssueTypeDocs { + return message(lang, "comment_docs") + } + return message(lang, "comment_default") +} + +func containsAny(text string, keywords []string) bool { + for _, keyword := range keywords { + if strings.Contains(text, strings.ToLower(keyword)) { + return true + } + } + return false +} + +func containsString(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} + +func uniqueStrings(values []string) []string { + seen := map[string]struct{}{} + unique := []string{} + for _, value := range values { + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + unique = append(unique, value) + } + return unique +} + +func sortedStrings(values []string) []string { + copied := append([]string(nil), values...) + sort.Strings(copied) + return copied +} diff --git a/shortcuts/workflow/triage_rules_test.go b/shortcuts/workflow/triage_rules_test.go new file mode 100644 index 0000000..e7780ef --- /dev/null +++ b/shortcuts/workflow/triage_rules_test.go @@ -0,0 +1,92 @@ +package workflow + +import "testing" + +func TestAnalyzeIssueDetectsSecurityP0(t *testing.T) { + result := AnalyzeIssue(IssueInput{ + Title: "Token leaked in command output", + Body: "A secret token leaked and may allow permission escalation.", + Labels: []string{"security"}, + }, "en") + + if result.DetectedType != IssueTypeSecurity { + t.Fatalf("DetectedType = %q, want %q", result.DetectedType, IssueTypeSecurity) + } + if result.Priority != PriorityP0 { + t.Fatalf("Priority = %q, want %q", result.Priority, PriorityP0) + } + if !containsString(result.RiskFlags, RiskPossibleSecretLeak) && !containsString(result.RiskFlags, RiskSecuritySensitive) { + t.Fatalf("RiskFlags = %v, want security or secret leak flag", result.RiskFlags) + } + if result.Confidence < 70 { + t.Fatalf("Confidence = %d, want >= 70", result.Confidence) + } +} + +func TestAnalyzeIssueDetectsBugAndMissingInfo(t *testing.T) { + result := AnalyzeIssue(IssueInput{ + Title: "CLI crash with error", + Body: "It crashes.", + }, "en") + + if result.DetectedType != IssueTypeBug { + t.Fatalf("DetectedType = %q, want %q", result.DetectedType, IssueTypeBug) + } + if result.Priority != PriorityP1 && result.Priority != PriorityP2 { + t.Fatalf("Priority = %q, want P1 or P2", result.Priority) + } + if len(result.MissingInformation) == 0 { + t.Fatal("MissingInformation is empty, want bug info requirements") + } + if !containsString(result.RiskFlags, RiskInsufficientInfo) { + t.Fatalf("RiskFlags = %v, want %q", result.RiskFlags, RiskInsufficientInfo) + } +} + +func TestAnalyzeIssueDetectsDocs(t *testing.T) { + result := AnalyzeIssue(IssueInput{ + Title: "README typo in documentation example", + Body: "The docs guide has a typo.", + }, "en") + + if result.DetectedType != IssueTypeDocs { + t.Fatalf("DetectedType = %q, want %q", result.DetectedType, IssueTypeDocs) + } + if result.Priority != PriorityP3 { + t.Fatalf("Priority = %q, want %q", result.Priority, PriorityP3) + } + if result.RecommendedAction != ActionUpdateDocs { + t.Fatalf("RecommendedAction = %q, want %q", result.RecommendedAction, ActionUpdateDocs) + } +} + +func TestAnalyzeIssueChinese(t *testing.T) { + result := AnalyzeIssue(IssueInput{ + Title: "安装失败并且报错,无法登录", + Body: "执行登录命令后失败。", + }, "zh-CN") + + if result.DetectedType != IssueTypeBug { + t.Fatalf("DetectedType = %q, want %q", result.DetectedType, IssueTypeBug) + } + if result.Priority != PriorityP1 { + t.Fatalf("Priority = %q, want %q", result.Priority, PriorityP1) + } + if result.SuggestedComment == "" { + t.Fatal("SuggestedComment is empty") + } +} + +func TestAnalyzeIssueUnknownLowConfidence(t *testing.T) { + result := AnalyzeIssue(IssueInput{ + Title: "General repository note", + Body: "This is a neutral note without clear maintenance signal.", + }, "en") + + if result.DetectedType != IssueTypeUnknown { + t.Fatalf("DetectedType = %q, want %q", result.DetectedType, IssueTypeUnknown) + } + if result.Confidence > 40 { + t.Fatalf("Confidence = %d, want <= 40", result.Confidence) + } +} diff --git a/shortcuts/workflow/types.go b/shortcuts/workflow/types.go new file mode 100644 index 0000000..416900c --- /dev/null +++ b/shortcuts/workflow/types.go @@ -0,0 +1,124 @@ +package workflow + +import "time" + +const ( + langEN = "en" + langZH = "zh-CN" +) + +const ( + IssueTypeBug = "bug" + IssueTypeFeature = "feature" + IssueTypeQuestion = "question" + IssueTypeDocs = "docs" + IssueTypeCI = "ci" + IssueTypeSecurity = "security" + IssueTypePerformance = "performance" + IssueTypeRefactor = "refactor" + IssueTypeUnknown = "unknown" +) + +const ( + PriorityP0 = "P0" + PriorityP1 = "P1" + PriorityP2 = "P2" + PriorityP3 = "P3" +) + +const ( + RiskSecuritySensitive = "security_sensitive" + RiskPossibleSecretLeak = "possible_secret_leak" + RiskInstallationBlocker = "installation_blocker" + RiskAuthenticationBlocker = "authentication_blocker" + RiskCIBlocker = "ci_blocker" + RiskInsufficientInfo = "insufficient_information" +) + +const ( + ActionRequestMoreInfo = "request_more_info" + ActionPrioritizeImmediate = "prioritize_immediately" + ActionScheduleFix = "schedule_fix" + ActionConvertToDiscussion = "convert_to_discussion" + ActionUpdateDocs = "update_docs" + ActionInvestigateCI = "investigate_ci" + ActionReviewSecurity = "review_security" +) + +type IssueInput struct { + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + State string `json:"state"` + Author string `json:"author"` + URL string `json:"url"` + Labels []string `json:"labels"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + CommentsCount int `json:"comments_count"` +} + +type IssueRef struct { + ID string `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + URL string `json:"url"` + Author string `json:"author"` + State string `json:"state"` +} + +type TriageResult struct { + Issue IssueRef `json:"issue"` + DetectedType string `json:"detected_type"` + Priority string `json:"priority"` + Confidence int `json:"confidence"` + SuggestedLabels []string `json:"suggested_labels"` + MissingInformation []string `json:"missing_information"` + RiskFlags []string `json:"risk_flags"` + RecommendedAction string `json:"recommended_action"` + SuggestedComment string `json:"suggested_comment"` + Reasoning []string `json:"reasoning"` + MatchedRules []string `json:"matched_rules"` +} + +type HealthInput struct { + Repository string `json:"repository"` + OpenIssues int `json:"open_issues"` + OpenPRs int `json:"open_prs"` + StaleIssues int `json:"stale_issues"` + StalePRs int `json:"stale_prs"` + RecentActivityKnown bool `json:"recent_activity_known"` + RecentActivityDays int `json:"recent_activity_days"` + ReleaseKnown bool `json:"release_known"` + HasRecentRelease bool `json:"has_recent_release"` + CIKnown bool `json:"ci_known"` + CIPassing bool `json:"ci_passing"` + HasReadme bool `json:"has_readme"` + HasLicense bool `json:"has_license"` + HasContributing bool `json:"has_contributing"` + AgentReadinessKnown bool `json:"agent_readiness_known"` + AgentReadinessScore int `json:"agent_readiness_score"` +} + +type HealthResult struct { + Repository string `json:"repository"` + HealthScore int `json:"health_score"` + RiskLevel string `json:"risk_level"` + Metrics []HealthMetric `json:"metrics"` + Recommendations []string `json:"recommendations"` + ScoringNotes []ScoringNote `json:"scoring_notes"` +} + +type HealthMetric struct { + Name string `json:"name"` + Status string `json:"status"` + Score int `json:"score"` + MaxScore int `json:"max_score"` + Reason string `json:"reason"` +} + +type ScoringNote struct { + Metric string `json:"metric"` + Note string `json:"note"` +} diff --git a/shortcuts/workflow/workflow.go b/shortcuts/workflow/workflow.go new file mode 100644 index 0000000..4dc69e5 --- /dev/null +++ b/shortcuts/workflow/workflow.go @@ -0,0 +1,386 @@ +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 fmt.Errorf("%w\nhint: use --title or --from issues.json for local rule analysis", 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 { + if strings.TrimSpace(ctx.Arg("from")) != "" || strings.TrimSpace(ctx.Arg("title")) != "" { + return true + } + // When user passes triage-specific flags without --title or --from, + // treat it as local input so argument validation kicks in early. + for _, name := range []string{"body", "number", "author", "url", "labels"} { + if strings.TrimSpace(ctx.Arg(name)) != "" { + return true + } + } + return false +} + +func hasLocalHealthInput(ctx *common.RuntimeContext) bool { + if strings.TrimSpace(ctx.Arg("from")) != "" || strings.TrimSpace(ctx.Arg("repository")) != "" { + return true + } + // When user passes any health-flag value (even invalid ones like + // --open-issues abc), stay in local mode so parseIntArg validates them. + for _, name := range []string{"open-issues", "stale-issues", "open-prs", "stale-prs", "recent-activity-known", "recent-activity-days", "release-known", "has-recent-release", "ci-known", "ci-passing", "has-readme", "has-license", "has-contributing", "agent-readiness-known", "agent-readiness-score"} { + if strings.TrimSpace(ctx.Arg(name)) != "" { + return true + } + } + return false +} + +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 +} diff --git a/shortcuts/workflow/workflow_test.go b/shortcuts/workflow/workflow_test.go new file mode 100644 index 0000000..b7d03cf --- /dev/null +++ b/shortcuts/workflow/workflow_test.go @@ -0,0 +1,232 @@ +package workflow + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestShortcutsExposesWorkflowCommands(t *testing.T) { + shortcuts := Shortcuts() + names := map[string]bool{} + for _, shortcut := range shortcuts { + names[shortcut.Name] = true + } + if !names["triage"] { + t.Fatal("Shortcuts missing triage") + } + if !names["health"] { + t.Fatal("Shortcuts missing health") + } + if !names["pr-summary"] { + t.Fatal("Shortcuts missing pr-summary") + } + if !names["repo-report"] { + t.Fatal("Shortcuts missing repo-report") + } +} + +func TestRunTriageWithSingleIssueArgs(t *testing.T) { + ctx := &common.RuntimeContext{ + Format: "json", + Args: map[string]string{ + "title": "Token leaked in output", + "body": "A secret token leaked from logs.", + "number": "7", + "state": "open", + "limit": "30", + "dry-run": "true", + "lang": "en", + }, + } + + issues, err := collectIssuesFromArgs(ctx) + if err != nil { + t.Fatalf("collectIssuesFromArgs returned error: %v", err) + } + if len(issues) != 1 { + t.Fatalf("len(issues) = %d, want 1", len(issues)) + } + + result := AnalyzeIssue(issues[0], ctx.Arg("lang")) + if result.DetectedType != IssueTypeSecurity { + t.Fatalf("DetectedType = %q, want %q", result.DetectedType, IssueTypeSecurity) + } +} + +func TestReadIssueInputsFromJSONFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "issues.json") + data := []IssueInput{{ + Number: 1, + Title: "README typo", + State: "open", + }} + writeJSONFixture(t, path, map[string]interface{}{"issues": data}) + + issues, err := readIssueInputs(path) + if err != nil { + t.Fatalf("readIssueInputs returned error: %v", err) + } + if len(issues) != 1 { + t.Fatalf("len(issues) = %d, want 1", len(issues)) + } + if issues[0].Title != "README typo" { + t.Fatalf("Title = %q, want README typo", issues[0].Title) + } +} + +func TestRenderHealthMarkdown(t *testing.T) { + result := ScoreHealth(HealthInput{ + Repository: "owner/repo", + OpenIssues: 1, + OpenPRs: 1, + RecentActivityKnown: true, + RecentActivityDays: 1, + ReleaseKnown: true, + HasRecentRelease: true, + HasReadme: true, + HasLicense: true, + HasContributing: true, + AgentReadinessKnown: true, + AgentReadinessScore: 9, + }, "en") + + var buf bytes.Buffer + if err := renderHealthResult(&buf, result, "markdown"); err != nil { + t.Fatalf("renderHealthResult returned error: %v", err) + } + if !bytes.Contains(buf.Bytes(), []byte("# Repository Health Report")) { + t.Fatalf("markdown output missing title: %s", buf.String()) + } +} + +func TestRunTriageRemoteModeUsesFetch(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues.json" { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + writeWorkflowJSON(t, w, map[string]interface{}{ + "issues": []map[string]interface{}{ + { + "number": 7, + "title": "Token leaked in logs", + "body": "The access token appears in command output.", + "state": "open", + "author": map[string]interface{}{"login": "bob"}, + "labels": []map[string]interface{}{{"name": "security"}}, + "created_at": "2026-05-01T00:00:00Z", + "updated_at": "2026-05-02T00:00:00Z", + "html_url": "https://example.com/issues/7", + "comments": 1, + }, + }, + }) + })) + defer server.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: map[string]string{ + "limit": "10", + "state": "open", + "dry-run": "true", + "lang": "en", + }, + } + + if err := runTriage(ctx); err != nil { + t.Fatalf("runTriage returned error: %v", err) + } +} + +func TestRunHealthRemoteModeUsesFetch(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.json": + writeWorkflowJSON(t, w, map[string]interface{}{ + "updated_at": "2026-05-19T00:00:00Z", + "has_readme": true, + "has_license": true, + "has_contributing": true, + }) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json": + writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{}}) + case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json": + writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{}}) + case r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json": + writeWorkflowJSON(t, w, map[string]interface{}{"releases": []map[string]interface{}{}}) + case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json": + writeWorkflowJSON(t, w, map[string]interface{}{"builds": []map[string]interface{}{}}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{ + HTTP: server.Client(), + BaseURL: server.URL, + }, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: map[string]string{ + "lang": "en", + }, + } + + if err := runHealth(ctx); err != nil { + t.Fatalf("runHealth returned error: %v", err) + } +} + +func TestCollectRepoReportFromJSONFile(t *testing.T) { + ctx := &common.RuntimeContext{ + Args: map[string]string{ + "from": filepath.Join("testdata", "repo_report.json"), + }, + } + input, notes, err := collectRepoReportInput(ctx) + if err != nil { + t.Fatalf("collectRepoReportInput returned error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("notes = %+v, want empty", notes) + } + if input.Repository == "" || len(input.Issues) == 0 || len(input.PullRequests) == 0 { + t.Fatalf("input = %+v, want populated report fixture", input) + } +} + +func TestCollectRepoReportMissingInputs(t *testing.T) { + ctx := &common.RuntimeContext{Args: map[string]string{}} + _, _, err := collectRepoReportInput(ctx) + if err == nil { + t.Fatal("collectRepoReportInput returned nil error without --from or owner/repo") + } +} + +func writeJSONFixture(t *testing.T, path string, data interface{}) { + t.Helper() + encoded, err := json.Marshal(data) + if err != nil { + t.Fatalf("json.Marshal returned error: %v", err) + } + if err := os.WriteFile(path, encoded, 0600); err != nil { + t.Fatalf("write fixture returned error: %v", err) + } +} diff --git a/skills/gitlink-compare/SKILL.md b/skills/gitlink-compare/SKILL.md new file mode 100644 index 0000000..af7dbe9 --- /dev/null +++ b/skills/gitlink-compare/SKILL.md @@ -0,0 +1,40 @@ +--- +name: gitlink-compare +version: 1.0.0 +description: "Compare GitLink branches, tags, or commits and inspect changed files." +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli compare --help" +--- + +# gitlink-compare + +Read [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) first for authentication, global flags, and API behavior. + +## Shortcuts + +| Shortcut | Description | +|----------|-------------| +| `compare +view` | Compare two branches, tags, or commits | +| `compare +files` | List changed files between two refs | + +## Examples + +```bash +# Compare two refs and include commit/diff summary +gitlink-cli compare +view --owner Gitlink --repo forgeplus --head feature/api --base master + +# List changed files +gitlink-cli compare +files --owner Gitlink --repo forgeplus --head feature/api --base master + +# Filter a single file in the file diff endpoint +gitlink-cli compare +files --owner Gitlink --repo forgeplus \ + --head feature/api --base master --file cmd/api/api.go +``` + +## Notes + +- Pass normal branch, tag, or commit names. The CLI base64-url encodes refs before calling GitLink compare endpoints. +- `compare +view` calls `/api/{owner}/{repo}/compare/{head}...{base}`. +- `compare +files` calls `/api/v1/{owner}/{repo}/compare/{head}...{base}/files`. diff --git a/skills/gitlink-milestone/SKILL.md b/skills/gitlink-milestone/SKILL.md new file mode 100644 index 0000000..cd77948 --- /dev/null +++ b/skills/gitlink-milestone/SKILL.md @@ -0,0 +1,66 @@ +--- +name: gitlink-milestone +version: 1.0.0 +description: "Milestone management: list, create, view, update, delete, close, and reopen GitLink project milestones." +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli milestone --help" +--- + +# gitlink-milestone + +**CRITICAL**: Read [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) before starting. It covers authentication, permissions, global flags, and GitLink API behavior. +**CRITICAL**: Confirm user intent before running write or destructive operations such as `+create`, `+update`, `+delete`, `+close`, or `+reopen`. +**CRITICAL**: Use `gitlink-cli` for GitLink resources. Do not use GitHub-only tools such as `gh`. + +## Shortcuts + +| Shortcut | Description | Operation | +|----------|-------------|-----------| +| `milestone +list` | List repository milestones | Read | +| `milestone +create` | Create a milestone | Write | +| `milestone +view` | View milestone details and linked issues | Read | +| `milestone +update` | Update milestone fields | Write | +| `milestone +delete` | Delete a milestone | Destructive | +| `milestone +close` | Close a milestone | Write | +| `milestone +reopen` | Reopen a closed milestone | Write | + +## Examples + +```bash +# List open milestones +gitlink-cli milestone +list --owner Gitlink --repo forgeplus --category opening + +# Create a milestone +gitlink-cli milestone +create --owner Gitlink --repo forgeplus \ + --name v1.0 --description "First stable release" --due-date 2026-07-01 + +# View milestone details and linked opened issues +gitlink-cli milestone +view --owner Gitlink --repo forgeplus --id 7 --category opened + +# Update the due date +gitlink-cli milestone +update --owner Gitlink --repo forgeplus --id 7 --due-date 2026-08-01 + +# Close and reopen +gitlink-cli milestone +close --owner Gitlink --repo forgeplus --id 7 +gitlink-cli milestone +reopen --owner Gitlink --repo forgeplus --id 7 +``` + +## Parameters + +| Command | Key parameters | +|---------|----------------| +| `+list` | `--keyword`, `--category opening,closed`, `--only-name`, `--sort-by`, `--sort-direction`, `--page`, `--limit` | +| `+create` | `--name`, `--description`, `--due-date` | +| `+view` | `--id`, `--category all,opened,closed`, `--author-id`, `--assigner-id`, `--issue-tag-ids`, `--page`, `--limit` | +| `+update` | `--id` plus at least one of `--name`, `--description`, `--due-date` | +| `+delete` | `--id` | +| `+close` / `+reopen` | `--id` | + +## API Notes + +- Milestone list/create/view/update/delete use `/api/v1/{owner}/{repo}/milestones`. +- Status updates use `/api/{owner}/{repo}/milestones/{id}/update_status`. +- `--due-date` maps to the GitLink API field `effective_date`. +- `--issue-tag-ids` accepts comma-separated IDs and normalizes whitespace before calling the API. diff --git a/skills/gitlink-pr/references/gitlink-pr-reopen.md b/skills/gitlink-pr/references/gitlink-pr-reopen.md new file mode 100644 index 0000000..6c2505e --- /dev/null +++ b/skills/gitlink-pr/references/gitlink-pr-reopen.md @@ -0,0 +1,30 @@ +# pr +reopen + +Reopen a closed Pull Request. + +## Usage + +```bash +gitlink-cli pr +reopen --id 3 +gitlink-cli pr +reopen -i 3 +``` + +## Parameters + +| Parameter | Required | Description | +|-----------|----------|-------------| +| `--id` / `-i` | Yes | PR number from the web URL `/pulls/N` | +| `--owner` | No | Repository owner, auto-detected from git remote when omitted | +| `--repo` | No | Repository name, auto-detected from git remote when omitted | +| `--format` | No | Output format: `json`, `table`, or `yaml` | + +## API + +```text +POST /v1/{owner}/{repo}/pulls/{number}/reopen +``` + +## Notes + +- Use `pr +view -i ` first to confirm the PR is currently closed. +- This command uses the PR number shown in the web URL, not the internal database ID. diff --git a/skills/gitlink-workflow/SKILL.md b/skills/gitlink-workflow/SKILL.md index 9997883..0f7aa2a 100644 --- a/skills/gitlink-workflow/SKILL.md +++ b/skills/gitlink-workflow/SKILL.md @@ -101,6 +101,49 @@ gitlink-cli pr +list --state merged --format json gitlink-cli api GET /:owner/:repo/activity --format json ``` +## Workflow: PR Summary (Read-only) + +Use `workflow +pr-summary` when a maintainer or Agent needs a structured PR review summary, review focus, test suggestions, or a markdown report that can be copied into a PR discussion. + +```bash +# Read-only GitLink fetch mode +gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown + +# Local JSON input mode for Agent pipelines +gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json +``` + +Rules: +- Prefer `--format json` when another Agent consumes the output. +- Prefer `--format markdown` when a human maintainer needs a report. +- This command is read-only: it does not comment, approve, reject, merge, label, or close pull requests. +- Do not use LLM APIs for this workflow; it is rule-based and explainable. + +## Workflow: Repo Report (Read-only) + +Use `workflow +repo-report` when a maintainer or Agent needs a single repository workflow report +that aggregates health, issue triage, and PR review signals. +适用于需要生成仓库治理报告、比赛材料、维护者汇总或 Agent 综合分析的场景。 + +```bash +# Maintainer report +gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown + +# Agent-readable report +gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format json + +# Local fixture mode +gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json +``` + +Rules: +- Prefer `--format json` when another Agent consumes the output. +- Prefer `--format markdown` for maintainer reports, competition materials, and review handoff. +- Treat remote mode as read-only aggregation only. +- Do not perform remote write operations. +- Do not comment, label, close, approve, reject, or merge from this workflow. +- PR details in remote report mode may be partial; use `workflow +pr-summary --number ` for a focused PR review. + ## 最佳实践 - 所有工作流命令使用 `--format json` 以便解析输出