Add workflow pr-summary command
This commit is contained in:
parent
5673a109a4
commit
7730347253
12
README.md
12
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`.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
PR Test 2026年 4月 7日 星期二 11时45分56秒 CST
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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": "暂无额外判断依据。",
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
}
|
||||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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]) + "..."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -23,6 +23,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
return []*common.Shortcut{
|
||||
newTriageShortcut(),
|
||||
newHealthShortcut(),
|
||||
newPRSummaryShortcut(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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` 以便解析输出
|
||||
|
|
|
|||
Loading…
Reference in New Issue