diff --git a/README.md b/README.md index f412bcf..b79afca 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,9 @@ gitlink-cli search +users -k "zhangsan" - `workflow +triage` - `workflow +health` +- `workflow +pr-summary` + +`workflow +pr-summary` defaults to `table` when `--format` is omitted. Examples: @@ -326,6 +329,12 @@ gitlink-cli workflow +health --repository Gitlink/gitlink-cli --open-issues 3 -- # 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 ``` Output formats: @@ -339,6 +348,7 @@ 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. ### Raw API @@ -361,7 +371,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`. diff --git a/WORK_CONTINUATION.md b/WORK_CONTINUATION.md index 0134965..92c2217 100644 --- a/WORK_CONTINUATION.md +++ b/WORK_CONTINUATION.md @@ -2,22 +2,20 @@ ## Current Goal -Implement the first PR slice for the GitLink CLI Agent Workflow enhancement suite for `track1_2026GitLinkCli`. - -First PR scope: -- `gitlink-cli workflow +triage` -- `gitlink-cli workflow +health` +Implement the GitLink CLI Agent Workflow enhancement suite for `track1_2026GitLinkCli`. Current implemented slice: -- Pure workflow DTOs and rule engines. -- Local command layer for `workflow +triage` and `workflow +health`. -- Read-only GitLink API fetch layer for workflow triage and health. -- Fetch layer hardened and documented. -- No remote write behavior. +- `workflow +triage` +- `workflow +health` +- `workflow +pr-summary` + +Planned next: +- `workflow +release-notes` +- `workflow +stale` ## Current Branch -- Branch: `master` +- Branch: `codex/workflow-agent` - Remote: `origin https://gitlink.org.cn/Gitlink/gitlink-cli.git` - Repository path: `E:\GitLinkCLI-Competition\gitlink-cli` - Local Go toolchain: `E:\GitLinkCLI-Competition\tools\go1.26.1\go` @@ -36,21 +34,22 @@ Current implemented slice: - Added lightweight language messages under `shortcuts/workflow/messages.go`. - Added unit tests for triage, health, messages, renderers, and local workflow command helpers. - Installed Go 1.26.1 locally for Windows amd64 after verifying the machine is Intel x64. -- Added `workflow.Shortcuts()` with `+triage` and `+health`. +- Added `workflow.Shortcuts()` with `+triage`, `+health`, and `+pr-summary`. - Registered the `workflow` shortcut group in `shortcuts/register.go`. - Added workflow-local JSON, table, and markdown renderers. - Added local input support: - `workflow +triage`: single issue flags or `--from` JSON file. - `workflow +health`: explicit metric flags or `--from` JSON file. -- Verified both commands run locally without GitLink API access. + - `workflow +pr-summary`: PR number fetch or `--from` JSON file. +- Verified all three commands run locally without GitLink API write access. - Added read-only workflow API fetch helpers and mock tests. -- Added command-level fetch-path smoke tests for `runTriage` and `runHealth`. +- Added command-level fetch-path smoke tests for `runTriage`, `runHealth`, and `runPRSummary`. - Added README workflow command usage section. - Added `docs/workflow-agent-test-report.md`. - Added `docs/competition-solution.md`. - Added `docs/pr-draft.md`. - Added workflow testdata fixtures under `shortcuts/workflow/testdata/`. -- Expanded fetch-layer boundary coverage for empty responses, label/author normalization, error-in-body handling, alternative activity timestamps, release shapes, and CI unavailability. +- Expanded fetch-layer boundary coverage for empty responses, label and author normalization, error-in-body handling, alternative activity timestamps, release shapes, CI unavailability, and PR summary normalization. ## Current Go Toolchain Status @@ -64,52 +63,44 @@ Current implemented slice: ## Current Test Status -- `gofmt` on `shortcuts/workflow/*.go` and `shortcuts/register.go`: passed. +- `gofmt` on `shortcuts/workflow/*.go`: passed. - `go test ./shortcuts/workflow`: passed. - `go test ./...`: passed. - Smoke command passed: - `go run . --format json workflow +triage --title "Token leaked in logs" --body "secret token leaked" --number 1 --labels security` - Smoke command passed: - `go run . --format table workflow +health --repository owner/repo --open-issues 2 --open-prs 1 --recent-activity-known --recent-activity-days 3 --release-known --has-recent-release --has-readme --has-license --has-contributing --agent-readiness-known --agent-readiness-score 9` +- Smoke command passed: + - `go run . --format json workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json` - Remote read-only smoke command passed: - `go run . --format table workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5` - Remote read-only smoke command passed: - `go run . --format markdown --lang zh-CN workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30` -- Documentation examples now cover both local-parameter and local-JSON-file usage. -- `docs/pr-draft.md`: present and current. +- Documentation examples now cover local-parameter, local-JSON-file, and read-only fetch usage. ## Recent Changed Files -- `WORK_CONTINUATION.md` -- `docs/workflow-agent-design.md` +- `README.md` - `docs/competition-solution.md` - `docs/pr-draft.md` -- `shortcuts/register.go` -- `shortcuts/workflow/types.go` +- `docs/workflow-agent-design.md` +- `docs/workflow-agent-test-report.md` +- `shortcuts/workflow/api_types.go` - `shortcuts/workflow/messages.go` -- `shortcuts/workflow/triage_rules.go` -- `shortcuts/workflow/health_score.go` - `shortcuts/workflow/render.go` - `shortcuts/workflow/workflow.go` -- `shortcuts/workflow/api_types.go` -- `shortcuts/workflow/triage_fetch.go` -- `shortcuts/workflow/health_fetch.go` -- `shortcuts/workflow/triage_rules_test.go` -- `shortcuts/workflow/health_score_test.go` -- `shortcuts/workflow/messages_test.go` - `shortcuts/workflow/workflow_test.go` -- `shortcuts/workflow/triage_fetch_test.go` -- `shortcuts/workflow/health_fetch_test.go` -- `shortcuts/workflow/testdata/issue_bug.json` -- `shortcuts/workflow/testdata/issue_security.json` -- `shortcuts/workflow/testdata/health_good.json` -- `shortcuts/workflow/testdata/health_risky.json` -- `README.md` -- `docs/workflow-agent-test-report.md` +- `shortcuts/workflow/pr_summary.go` +- `shortcuts/workflow/pr_fetch.go` +- `shortcuts/workflow/pr_summary_test.go` +- `shortcuts/workflow/pr_fetch_test.go` +- `shortcuts/workflow/render_test.go` +- `shortcuts/workflow/testdata/pr_summary.json` +- `skills/gitlink-workflow/SKILL.md` +- `pr-test-file.txt` deleted ## Uncompleted Content -- `workflow +pr-summary` is not implemented. - `workflow +release-notes` is not implemented. - `workflow +stale` is not implemented. - Remote write operations remain intentionally deferred. @@ -119,10 +110,11 @@ Current implemented slice: - `codex status` is unavailable from the non-interactive shell: `stdin is not a terminal`. - Quota reset time unavailable. - Workflow commands support both local input and read-only GitLink fetch mode. -- Existing global help says default format is table, but shortcut runtime defaults to json when `--format` is omitted. +- Existing global help says default format is table, but shortcut runtime still defaults to json when `--format` is omitted. - Existing output formatter supports `json`, `yaml`, and `table`; workflow-local renderers currently support `json`, `table`, and `markdown`. - Workflow Skill examples use some older flag names such as `--id`, while current issue commands use `--number` for issues and PR commands use `--id`. - API response shapes vary across endpoints and should be normalized behind workflow-specific fetch/parsing helpers. +- `README.zh-CN.md` currently shows encoding/garbling risk in the shell and was left untouched in this slice. ## Key Design Decisions @@ -133,12 +125,13 @@ Current implemented slice: - All remote-write behavior remains out of scope. - `+triage` supports local single-issue flags, JSON file input, and read-only GitLink fetch mode. - `+health` supports local metric flags, JSON file input, and read-only GitLink fetch mode. +- `+pr-summary` supports local JSON input and read-only PR metadata fetch mode. - Treat unavailable future API metrics as `unknown` and include them in `scoring_notes`. - Workflow-local renderers keep json/table/markdown output isolated from the global formatter. ## Next Minimal Executable Task -Design workflow +pr-summary: read-only PR metadata, changed files, and commits; implement with httptest mock first; do not add LLM or write operations. +Design workflow +release-notes with read-only PR titles and commit messages; implement with httptest mock first; do not add LLM or write operations. ## How To Continue After Interruption @@ -148,9 +141,9 @@ Design workflow +pr-summary: read-only PR metadata, changed files, and commits; 4. Set temporary GOPROXY if dependency download fails: `https://goproxy.cn,direct`. 5. Run `go test ./shortcuts/workflow`. 6. Run `go test ./...`. -7. Start `workflow +pr-summary` design only after confirming the existing workflow tests still pass. +7. Start `workflow +release-notes` design only after confirming the existing workflow tests still pass. 8. Keep all new workflow commands read-only by default. ## Recommended Next Codex Instruction -Design workflow +pr-summary with read-only PR metadata, changed files, and commits; implement with httptest mock first; do not add LLM or write operations. +Design workflow +release-notes with read-only PR titles and commit messages; implement with httptest mock first; do not add LLM or write operations. diff --git a/docs/competition-solution.md b/docs/competition-solution.md index ce88672..284083e 100644 --- a/docs/competition-solution.md +++ b/docs/competition-solution.md @@ -22,14 +22,15 @@ Implemented now: - `workflow +triage` - `workflow +health` +- `workflow +pr-summary` - read-only GitLink fetch layer for workflow triage and health +- read-only PR metadata, changed files, and commits fetch layer for PR summary - expanded fetch boundary tests for empty responses, label and author normalization, error-in-body handling, alternative activity timestamps, release shapes, and CI unavailability - local-first analysis with no LLM dependency - stable Agent-facing JSON / table / markdown output Planned next: -- `workflow +pr-summary` - `workflow +release-notes` - `workflow +stale` @@ -65,6 +66,15 @@ Planned next: - recommendations - unknown metric tolerance +### workflow +pr-summary + +- change type detection +- risk level analysis +- review focus generation +- test suggestion generation +- merge checklist generation +- read-only fetch of PR metadata, changed files, and commits + ## 6. Innovation Points - Agent-native structured output @@ -78,8 +88,9 @@ Planned next: ## 7. Testing and Verification - Unit tests cover triage, health scoring, messages, rendering, and command helpers. -- Fetch-layer tests cover issue normalization and repository health probing with `httptest`. +- Fetch-layer tests cover issue normalization, repository health probing, and PR metadata/file/commit normalization with `httptest`. - Boundary tests cover empty responses, label and author normalization, error-in-body handling, alternative activity timestamps, release response shapes, and CI unavailability. +- PR summary tests cover docs-only, workflow code, internal client, security-sensitive, mixed-file, zh-CN, render, command, and fetch-failure cases. - Local command examples were executed successfully. - Full repository testing passed in the current environment. - Automated tests use `httptest` and do not depend on real remote API availability. @@ -95,7 +106,8 @@ Use `Gitlink/gitlink-cli` as the reference repository: 3. `workflow +triage` with Chinese markdown output 4. `workflow +health` with table output 5. `workflow +health` with risky JSON output -6. Explain how agents consume stable JSON +6. `workflow +pr-summary` with markdown output +7. Explain how agents consume stable JSON ### Self-built test repository @@ -111,11 +123,13 @@ Use a small demo repository to show: - Phase 1: local workflow prototype, completed - Phase 2: API fetch and normalization, completed -- Phase 3: `pr-summary`, `release-notes`, `stale` +- Phase 3: `pr-summary`, completed +- Phase 4: `release-notes`, `stale` ## 10. PR Plan - PR 1: workflow rule engine and local commands - PR 2: documentation and tests - PR 3: API fetch layer -- PR 4: `pr-summary` / `release-notes` +- PR 4: `pr-summary` +- PR 5: `release-notes` / `stale` diff --git a/docs/pr-draft.md b/docs/pr-draft.md index badea8e..b2e6daa 100644 --- a/docs/pr-draft.md +++ b/docs/pr-draft.md @@ -1,8 +1,8 @@ -# PR Draft: Add workflow agent commands for issue triage and repository health analysis +# PR Draft: Add workflow agent commands for issue triage, repository health, and PR summaries ## Summary -This PR adds `workflow +triage` and `workflow +health` with three execution modes: +This PR adds `workflow +triage`, `workflow +health`, and `workflow +pr-summary` with safe read-only analysis modes: - local flags - local JSON input @@ -14,6 +14,7 @@ It also adds stable `json`, `table`, and `markdown` rendering for Agent consumpt - Help maintainers triage issues faster - Provide a structured repository health overview +- Summarize pull requests with review focus, test suggestions, and merge checklist output - Give AI Agents stable machine-readable output - Keep the workflow local-first and safe by default - Avoid any dependency on external LLM APIs @@ -22,6 +23,7 @@ It also adds stable `json`, `table`, and `markdown` rendering for Agent consumpt - New `shortcuts/workflow` rule engine and DTOs - Local command layer for `workflow +triage` and `workflow +health` +- Local and remote command layer for `workflow +pr-summary` - Workflow-local renderer for `json`, `table`, and `markdown` - Read-only GitLink fetch and normalization helpers - Unit tests for rules, fetch normalization, rendering, and command wiring @@ -30,7 +32,7 @@ It also adds stable `json`, `table`, and `markdown` rendering for Agent consumpt ## Safety - Remote mode is read-only -- No comment, label, close, merge, or release write actions +- No comment, label, close, approve, reject, merge, or release write actions - Health scoring tolerates unknown or unavailable metrics - Test fixtures do not contain secrets or tokens @@ -41,12 +43,13 @@ It also adds stable `json`, `table`, and `markdown` rendering for Agent consumpt - `go test ./...` - `httptest` coverage for API normalization and fetch tolerance - Manual command examples in local and remote read-only modes +- PR summary tests for change type, risk level, partial fetch failures, renderers, and command wiring ## Known Limitations - Real API response shapes may still require minor normalization tweaks - Write operations are intentionally deferred to a later PR -- `pr-summary`, `release-notes`, and `stale` are planned next +- `release-notes` and `stale` are planned next ## Screenshots or Examples @@ -54,4 +57,5 @@ It also adds stable `json`, `table`, and `markdown` rendering for Agent consumpt gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --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 +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table +gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown ``` diff --git a/docs/workflow-agent-design.md b/docs/workflow-agent-design.md index 2b8b20c..f3a5944 100644 --- a/docs/workflow-agent-design.md +++ b/docs/workflow-agent-design.md @@ -17,16 +17,17 @@ First PR: - Produce markdown output for reports, PR comments, Issue comments, and competition materials. - Support `--lang en` and `--lang zh-CN` with a lightweight message helper. -Later PRs: -- `workflow +pr-summary` -- `workflow +release-notes` -- `workflow +stale` +Additional workflow commands: +- `workflow +pr-summary`: 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 ## Current Repository Findings @@ -341,8 +342,17 @@ Command tests: ### `workflow +pr-summary` Inputs: -- `--id` -- optional `--lang` +- `--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 @@ -352,10 +362,22 @@ Data: Output: - `change_type` - `risk_level` -- `summary` - `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 +release-notes` @@ -407,7 +429,8 @@ Design goals already applied: Planned fetch-layer extension: - `triage_fetch.go` and `health_fetch.go` remain the normalization boundary for remote mode. -- Future `pr-summary` and `release-notes` should reuse the same stable DTO and message patterns. +- `pr_fetch.go` now reuses the same stable DTO and message patterns for read-only PR metadata, changed files, and commits. +- 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 diff --git a/docs/workflow-agent-test-report.md b/docs/workflow-agent-test-report.md index 725f1f0..b064229 100644 --- a/docs/workflow-agent-test-report.md +++ b/docs/workflow-agent-test-report.md @@ -6,6 +6,7 @@ This phase covers: - Issue triage rules - health scoring rules +- PR summary rules - local command execution - API fetch boundary tests - remote read-only manual verification @@ -43,6 +44,7 @@ Results: - render tests - command tests - fetch boundary tests +- PR summary rules and fetch tests ## API Fetch Boundary Tests @@ -55,6 +57,9 @@ Results: - 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 ## Manual Command Examples @@ -66,6 +71,8 @@ gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.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 ``` ## Remote Manual Verification @@ -81,6 +88,7 @@ gitlink-cli workflow +health --repository Gitlink/gitlink-cli --open-issues 3 -- - 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. - `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. 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/workflow/api_types.go b/shortcuts/workflow/api_types.go index af5aead..3a0bf3e 100644 --- a/shortcuts/workflow/api_types.go +++ b/shortcuts/workflow/api_types.go @@ -104,7 +104,7 @@ func apiList(data interface{}) []interface{} { case []interface{}: return v case map[string]interface{}: - for _, key := range []string{"issues", "pulls", "releases", "builds", "items", "records", "data"} { + 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 diff --git a/shortcuts/workflow/messages.go b/shortcuts/workflow/messages.go index 4b9c604..f7d9966 100644 --- a/shortcuts/workflow/messages.go +++ b/shortcuts/workflow/messages.go @@ -50,6 +50,16 @@ var messages = map[string]map[string]string{ "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": "复现步骤", @@ -78,5 +88,15 @@ var messages = map[string]map[string]string{ "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/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 index 7e33b10..7d1191b 100644 --- a/shortcuts/workflow/render.go +++ b/shortcuts/workflow/render.go @@ -1,6 +1,7 @@ package workflow import ( + "bytes" "encoding/json" "fmt" "io" @@ -34,6 +35,27 @@ func renderHealthResult(w io.Writer, result HealthResult, format string) error { } } +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 normalizeFormat(format string) string { format = strings.ToLower(strings.TrimSpace(format)) if format == "" { @@ -75,6 +97,25 @@ func writeTriageTable(w io.Writer, report TriageReport) error { 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 { @@ -120,6 +161,61 @@ func writeTriageMarkdown(w io.Writer, report TriageReport) error { 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 @@ -151,3 +247,15 @@ func writeHealthMarkdown(w io.Writer, result HealthResult) error { } 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/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/workflow.go b/shortcuts/workflow/workflow.go index 4b49e70..27ece5f 100644 --- a/shortcuts/workflow/workflow.go +++ b/shortcuts/workflow/workflow.go @@ -23,6 +23,7 @@ func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ newTriageShortcut(), newHealthShortcut(), + newPRSummaryShortcut(), } } diff --git a/shortcuts/workflow/workflow_test.go b/shortcuts/workflow/workflow_test.go index 04a5bd1..b0ed3b9 100644 --- a/shortcuts/workflow/workflow_test.go +++ b/shortcuts/workflow/workflow_test.go @@ -25,6 +25,9 @@ func TestShortcutsExposesWorkflowCommands(t *testing.T) { if !names["health"] { t.Fatal("Shortcuts missing health") } + if !names["pr-summary"] { + t.Fatal("Shortcuts missing pr-summary") + } } func TestRunTriageWithSingleIssueArgs(t *testing.T) { diff --git a/skills/gitlink-workflow/SKILL.md b/skills/gitlink-workflow/SKILL.md index 9997883..d5a3798 100644 --- a/skills/gitlink-workflow/SKILL.md +++ b/skills/gitlink-workflow/SKILL.md @@ -101,6 +101,24 @@ 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. + ## 最佳实践 - 所有工作流命令使用 `--format json` 以便解析输出