Add workflow pr-summary command

This commit is contained in:
whzy 2026-05-21 20:33:54 +08:00 committed by wbtiger
parent 5673a109a4
commit 7730347253
19 changed files with 1896 additions and 63 deletions

View File

@ -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`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1 +0,0 @@
PR Test 2026年 4月 7日 星期二 11时45分56秒 CST

View File

@ -104,7 +104,7 @@ func apiList(data interface{}) []interface{} {
case []interface{}:
return v
case map[string]interface{}:
for _, key := range []string{"issues", "pulls", "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

View File

@ -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": "暂无额外判断依据。",
},
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -23,6 +23,7 @@ func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newTriageShortcut(),
newHealthShortcut(),
newPRSummaryShortcut(),
}
}

View File

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

View File

@ -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` 以便解析输出