forked from Gitlink/gitlink-cli
Add workflow repo-report command
This commit is contained in:
parent
7730347253
commit
a904f48099
51
README.md
51
README.md
|
|
@ -297,8 +297,10 @@ gitlink-cli search +users -k "zhangsan"
|
|||
- `workflow +triage`
|
||||
- `workflow +health`
|
||||
- `workflow +pr-summary`
|
||||
- `workflow +repo-report`
|
||||
|
||||
`workflow +pr-summary` defaults to `table` when `--format` is omitted.
|
||||
`workflow +repo-report` defaults to `markdown` when `--format` is omitted.
|
||||
|
||||
Examples:
|
||||
|
||||
|
|
@ -310,7 +312,11 @@ gitlink-cli workflow +triage --title "Install failed on Windows" --body "go inst
|
|||
gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --format json
|
||||
|
||||
# Triage with Chinese markdown output
|
||||
gitlink-cli workflow +triage --title "安装失败,无法登录" --body "运行命令时报错" --lang zh-CN --format markdown
|
||||
gitlink-cli workflow +triage \
|
||||
--title "安装失败,无法登录" \
|
||||
--body "运行命令时报错" \
|
||||
--lang zh-CN \
|
||||
--format markdown
|
||||
|
||||
# Triage from a local JSON file
|
||||
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format json
|
||||
|
|
@ -319,13 +325,39 @@ gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json -
|
|||
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table
|
||||
|
||||
# Health for a healthy repository
|
||||
gitlink-cli workflow +health --repository Gitlink/gitlink-cli --open-issues 3 --open-prs 1 --has-readme --has-license --has-contributing --agent-readiness-known --agent-readiness-score 9 --format table
|
||||
gitlink-cli workflow +health \
|
||||
--repository Gitlink/gitlink-cli \
|
||||
--open-issues 3 \
|
||||
--open-prs 1 \
|
||||
--has-readme \
|
||||
--has-license \
|
||||
--has-contributing \
|
||||
--agent-readiness-known \
|
||||
--agent-readiness-score 9 \
|
||||
--format table
|
||||
|
||||
# Health for a risky repository
|
||||
gitlink-cli workflow +health --repository demo/repo --open-issues 60 --stale-issues 25 --open-prs 12 --stale-prs 6 --recent-activity-known --recent-activity-days 120 --release-known=false --format json
|
||||
gitlink-cli workflow +health \
|
||||
--repository demo/repo \
|
||||
--open-issues 60 \
|
||||
--stale-issues 25 \
|
||||
--open-prs 12 \
|
||||
--stale-prs 6 \
|
||||
--recent-activity-known \
|
||||
--recent-activity-days 120 \
|
||||
--release-known=false \
|
||||
--format json
|
||||
|
||||
# Health with Chinese markdown output
|
||||
gitlink-cli workflow +health --repository Gitlink/gitlink-cli --open-issues 3 --open-prs 1 --has-readme --has-license --has-contributing --lang zh-CN --format markdown
|
||||
gitlink-cli workflow +health \
|
||||
--repository Gitlink/gitlink-cli \
|
||||
--open-issues 3 \
|
||||
--open-prs 1 \
|
||||
--has-readme \
|
||||
--has-license \
|
||||
--has-contributing \
|
||||
--lang zh-CN \
|
||||
--format markdown
|
||||
|
||||
# Health by read-only GitLink fetch
|
||||
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --format table
|
||||
|
|
@ -335,6 +367,12 @@ gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 -
|
|||
|
||||
# PR review summary from a local JSON file
|
||||
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json
|
||||
|
||||
# Repository workflow report by read-only GitLink fetch
|
||||
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
|
||||
|
||||
# Repository workflow report from a local JSON file
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
|
||||
```
|
||||
|
||||
Output formats:
|
||||
|
|
@ -349,6 +387,7 @@ Safety:
|
|||
- They do not modify remote GitLink data.
|
||||
- They do not depend on LLM APIs.
|
||||
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
|
||||
- `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes.
|
||||
|
||||
### Raw API
|
||||
|
||||
|
|
@ -520,7 +559,9 @@ Reinstall first:
|
|||
npm install -g @gitlink-ai/cli
|
||||
```
|
||||
|
||||
If the error persists, check whether the release page contains the asset for your platform, for example `gitlink-cli_<version>_windows_amd64.zip` on Windows x64. You can also download the binary manually from the release page or build from source with `go install .`.
|
||||
If the error persists, check whether the release page contains the asset for your platform,
|
||||
for example `gitlink-cli_<version>_windows_amd64.zip` on Windows x64.
|
||||
You can also download the binary manually from the release page or build from source with `go install .`.
|
||||
|
||||
### Q: Where are credentials stored on Windows?
|
||||
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ Current implemented slice:
|
|||
- `workflow +triage`
|
||||
- `workflow +health`
|
||||
- `workflow +pr-summary`
|
||||
- `workflow +repo-report`
|
||||
|
||||
Planned next:
|
||||
- `workflow +release-notes`
|
||||
|
|
@ -40,7 +41,8 @@ Planned next:
|
|||
- Added local input support:
|
||||
- `workflow +triage`: single issue flags or `--from` JSON file.
|
||||
- `workflow +health`: explicit metric flags or `--from` JSON file.
|
||||
- `workflow +pr-summary`: PR number fetch or `--from` JSON file.
|
||||
- `workflow +pr-summary`: PR number fetch or `--from` JSON file.
|
||||
- `workflow +repo-report`: aggregate health, issues, and PR list metadata 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`, `runHealth`, and `runPRSummary`.
|
||||
|
|
@ -50,6 +52,8 @@ Planned next:
|
|||
- Added `docs/pr-draft.md`.
|
||||
- Added workflow testdata fixtures under `shortcuts/workflow/testdata/`.
|
||||
- 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.
|
||||
- Added `workflow +repo-report` with local JSON input, read-only partial fetch aggregation,
|
||||
report score, overall risk level, markdown/table/json rendering, and tests.
|
||||
|
||||
## Current Go Toolchain Status
|
||||
|
||||
|
|
@ -67,15 +71,54 @@ Planned next:
|
|||
- `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`
|
||||
```bash
|
||||
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`
|
||||
```bash
|
||||
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`
|
||||
```bash
|
||||
go run . --format json workflow +pr-summary \
|
||||
--from shortcuts/workflow/testdata/pr_summary.json
|
||||
```
|
||||
- Smoke command passed:
|
||||
```bash
|
||||
go run . --format markdown workflow +repo-report \
|
||||
--from shortcuts/workflow/testdata/repo_report.json
|
||||
```
|
||||
- Remote read-only smoke command passed:
|
||||
- `go run . --format table workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5`
|
||||
```bash
|
||||
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`
|
||||
```bash
|
||||
go run . --format markdown --lang zh-CN workflow +health \
|
||||
--owner Gitlink \
|
||||
--repo gitlink-cli \
|
||||
--stale-days 30
|
||||
```
|
||||
- Documentation examples now cover local-parameter, local-JSON-file, and read-only fetch usage.
|
||||
|
||||
## Recent Changed Files
|
||||
|
|
@ -96,6 +139,11 @@ Planned next:
|
|||
- `shortcuts/workflow/pr_fetch_test.go`
|
||||
- `shortcuts/workflow/render_test.go`
|
||||
- `shortcuts/workflow/testdata/pr_summary.json`
|
||||
- `shortcuts/workflow/repo_report.go`
|
||||
- `shortcuts/workflow/repo_report_fetch.go`
|
||||
- `shortcuts/workflow/repo_report_test.go`
|
||||
- `shortcuts/workflow/repo_report_fetch_test.go`
|
||||
- `shortcuts/workflow/testdata/repo_report.json`
|
||||
- `skills/gitlink-workflow/SKILL.md`
|
||||
- `pr-test-file.txt` deleted
|
||||
|
||||
|
|
@ -126,6 +174,7 @@ Planned next:
|
|||
- `+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.
|
||||
- `+repo-report` supports local JSON input and read-only partial aggregation of health, issue, and PR list metadata.
|
||||
- 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,9 @@
|
|||
|
||||
## 1. Background
|
||||
|
||||
GitLink CLI serves both human maintainers and AI Agents. The competition focuses on intelligent open-source contribution workflows, where structured analysis, stable output, and safe automation matter more than raw command count.
|
||||
GitLink CLI serves both human maintainers and AI Agents.
|
||||
The competition focuses on intelligent open-source contribution workflows,
|
||||
where structured analysis, stable output, and safe automation matter more than raw command count.
|
||||
|
||||
## 2. Problem
|
||||
|
||||
|
|
@ -23,9 +25,12 @@ Implemented now:
|
|||
- `workflow +triage`
|
||||
- `workflow +health`
|
||||
- `workflow +pr-summary`
|
||||
- `workflow +repo-report`
|
||||
- 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
|
||||
- partial read-only repository report aggregation for health, issues, and PR list metadata
|
||||
- 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
|
||||
|
||||
|
|
@ -75,6 +80,15 @@ Planned next:
|
|||
- merge checklist generation
|
||||
- read-only fetch of PR metadata, changed files, and commits
|
||||
|
||||
### workflow +repo-report
|
||||
|
||||
- one-command repository workflow report
|
||||
- health, issue triage, and PR summary aggregation
|
||||
- report score and overall risk level
|
||||
- partial report behavior when optional remote sections fail
|
||||
- markdown output for competition and maintainer reports
|
||||
- JSON output for Agent consumption
|
||||
|
||||
## 6. Innovation Points
|
||||
|
||||
- Agent-native structured output
|
||||
|
|
@ -88,9 +102,15 @@ Planned next:
|
|||
## 7. Testing and Verification
|
||||
|
||||
- Unit tests cover triage, health scoring, messages, rendering, and command helpers.
|
||||
- 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.
|
||||
- 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.
|
||||
- Repo report tests cover aggregation, scoring, JSON/table/markdown rendering,
|
||||
command wiring, local JSON input, partial fetch behavior, and include flags.
|
||||
- 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.
|
||||
|
|
@ -107,7 +127,8 @@ Use `Gitlink/gitlink-cli` as the reference repository:
|
|||
4. `workflow +health` with table output
|
||||
5. `workflow +health` with risky JSON output
|
||||
6. `workflow +pr-summary` with markdown output
|
||||
7. Explain how agents consume stable JSON
|
||||
7. `workflow +repo-report` with markdown output for the full competition story
|
||||
8. Explain how agents consume stable JSON
|
||||
|
||||
### Self-built test repository
|
||||
|
||||
|
|
@ -118,13 +139,15 @@ Use a small demo repository to show:
|
|||
- docs triage
|
||||
- healthy repo score
|
||||
- risky repo score
|
||||
- full repo report from `shortcuts/workflow/testdata/repo_report.json`
|
||||
|
||||
## 9. Roadmap
|
||||
|
||||
- Phase 1: local workflow prototype, completed
|
||||
- Phase 2: API fetch and normalization, completed
|
||||
- Phase 3: `pr-summary`, completed
|
||||
- Phase 4: `release-notes`, `stale`
|
||||
- Phase 4: `repo-report`, completed
|
||||
- Phase 5: `release-notes`, `stale`
|
||||
|
||||
## 10. PR Plan
|
||||
|
||||
|
|
@ -132,4 +155,5 @@ Use a small demo repository to show:
|
|||
- PR 2: documentation and tests
|
||||
- PR 3: API fetch layer
|
||||
- PR 4: `pr-summary`
|
||||
- PR 5: `release-notes` / `stale`
|
||||
- PR 5: `repo-report`
|
||||
- PR 6: `release-notes` / `stale`
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
# PR Draft: Add workflow agent commands for issue triage, repository health, and PR summaries
|
||||
# PR Draft: Add workflow agent commands for issue triage, repository health, PR summaries, and repo reports
|
||||
|
||||
## Summary
|
||||
|
||||
This PR adds `workflow +triage`, `workflow +health`, and `workflow +pr-summary` with safe read-only analysis modes:
|
||||
This PR adds `workflow +triage`, `workflow +health`, `workflow +pr-summary`,
|
||||
and `workflow +repo-report` with safe read-only analysis modes:
|
||||
|
||||
- local flags
|
||||
- local JSON input
|
||||
|
|
@ -15,6 +16,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
|
||||
- Generate a repository workflow report that aggregates health, issue triage, and PR signals
|
||||
- Give AI Agents stable machine-readable output
|
||||
- Keep the workflow local-first and safe by default
|
||||
- Avoid any dependency on external LLM APIs
|
||||
|
|
@ -24,6 +26,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`
|
||||
- Local and partial remote aggregation for `workflow +repo-report`
|
||||
- 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
|
||||
|
|
@ -44,10 +47,13 @@ It also adds stable `json`, `table`, and `markdown` rendering for Agent consumpt
|
|||
- `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
|
||||
- Repo report tests for aggregation, partial fetch behavior, renderers, local JSON input, and command wiring
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Real API response shapes may still require minor normalization tweaks
|
||||
- Remote `workflow +repo-report` PR aggregation currently uses PR list metadata;
|
||||
detailed file and commit analysis remains available through `workflow +pr-summary --number`
|
||||
- Write operations are intentionally deferred to a later PR
|
||||
- `release-notes` and `stale` are planned next
|
||||
|
||||
|
|
@ -58,4 +64,5 @@ gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access t
|
|||
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
|
||||
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
|
||||
```
|
||||
|
|
|
|||
|
|
@ -2,9 +2,15 @@
|
|||
|
||||
## Background
|
||||
|
||||
`gitlink-cli` already provides low-level and shortcut operations for GitLink repositories, issues, pull requests, releases, CI, organizations, search, and users. The repository also includes `skills/gitlink-workflow/SKILL.md`, which describes AI workflow patterns such as Issue triage, PR review, and Release Notes generation.
|
||||
`gitlink-cli` already provides low-level and shortcut operations for GitLink repositories,
|
||||
issues, pull requests, releases, CI, organizations, search, and users.
|
||||
The repository also includes `skills/gitlink-workflow/SKILL.md`, which describes
|
||||
AI workflow patterns such as Issue triage, PR review, and Release Notes generation.
|
||||
|
||||
The current Go command tree does not include a `workflow` command group. The first competition PR should turn the documented workflow concept into concrete, deterministic CLI commands that can be used by human maintainers and AI Agents without calling an external LLM.
|
||||
The current Go command tree did not include a `workflow` command group before this work.
|
||||
The competition PR turns the documented workflow concept into concrete,
|
||||
deterministic CLI commands that can be used by human maintainers and AI Agents
|
||||
without calling an external LLM.
|
||||
|
||||
## Goals
|
||||
|
||||
|
|
@ -19,6 +25,7 @@ First PR:
|
|||
|
||||
Additional workflow commands:
|
||||
- `workflow +pr-summary`: done
|
||||
- `workflow +repo-report`: done
|
||||
- `workflow +release-notes`: planned
|
||||
- `workflow +stale`: planned
|
||||
|
||||
|
|
@ -26,8 +33,11 @@ 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
|
||||
- Boundary tests: expanded for empty responses, field normalization,
|
||||
unknown tolerance, and read-only error handling
|
||||
- PR summary command: done with local JSON input, read-only fetch, rules, renderers, and tests
|
||||
- Repo report command: done with local JSON input, partial read-only fetch aggregation,
|
||||
scoring, renderers, and tests
|
||||
|
||||
## Current Repository Findings
|
||||
|
||||
|
|
@ -379,6 +389,50 @@ Safety:
|
|||
- no merge
|
||||
- no remote write operation
|
||||
|
||||
### `workflow +repo-report`
|
||||
|
||||
Inputs:
|
||||
- `--owner`
|
||||
- `--repo`
|
||||
- `--from`
|
||||
- `--lang`
|
||||
- `--format`
|
||||
- optional `--issue-limit`
|
||||
- optional `--pr-limit`
|
||||
- optional `--stale-days`
|
||||
- optional `--include-issues`
|
||||
- optional `--include-prs`
|
||||
- optional `--include-health`
|
||||
|
||||
Default format:
|
||||
- `markdown` for maintainer and competition reports when `--format` is omitted
|
||||
|
||||
Data:
|
||||
- repository health input and score
|
||||
- issue triage results aggregated by type, priority, risk, and missing information
|
||||
- PR summary results aggregated by type, risk, and review focus
|
||||
|
||||
Output:
|
||||
- `report_score`
|
||||
- `risk_level`
|
||||
- `health`
|
||||
- `issue_summary`
|
||||
- `pr_summary`
|
||||
- `recommendations`
|
||||
- `reasoning`
|
||||
|
||||
Partial report strategy:
|
||||
- health, issue, and PR sections are fetched independently
|
||||
- if at least one enabled section succeeds, the command returns a partial report
|
||||
- failed sections are recorded in scoring notes or reasoning
|
||||
- PR remote aggregation currently uses PR list metadata only;
|
||||
detailed changed files and commits remain available through `workflow +pr-summary --number`
|
||||
|
||||
Safety:
|
||||
- read-only aggregation only
|
||||
- no comments, labels, closes, approve/reject, or merge operations
|
||||
- no LLM dependency
|
||||
|
||||
### `workflow +release-notes`
|
||||
|
||||
Inputs:
|
||||
|
|
@ -418,6 +472,8 @@ The current fetch layer uses:
|
|||
|
||||
- `triage_fetch.go`
|
||||
- `health_fetch.go`
|
||||
- `pr_fetch.go`
|
||||
- `repo_report_fetch.go`
|
||||
|
||||
Design goals already applied:
|
||||
|
||||
|
|
@ -430,6 +486,7 @@ Planned fetch-layer extension:
|
|||
|
||||
- `triage_fetch.go` and `health_fetch.go` remain the normalization boundary for remote mode.
|
||||
- `pr_fetch.go` now reuses the same stable DTO and message patterns for read-only PR metadata, changed files, and commits.
|
||||
- `repo_report_fetch.go` composes the existing fetch helpers and records partial failures instead of failing the whole report.
|
||||
- Future `release-notes` should reuse the same normalization and renderer patterns.
|
||||
- Unknown or missing fields should stay explicit in JSON output so Agents can decide how to proceed.
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ This phase covers:
|
|||
- Issue triage rules
|
||||
- health scoring rules
|
||||
- PR summary rules
|
||||
- repository report aggregation rules
|
||||
- local command execution
|
||||
- API fetch boundary tests
|
||||
- remote read-only manual verification
|
||||
|
|
@ -45,6 +46,7 @@ Results:
|
|||
- command tests
|
||||
- fetch boundary tests
|
||||
- PR summary rules and fetch tests
|
||||
- repo report aggregation, render, command, and partial fetch tests
|
||||
|
||||
## API Fetch Boundary Tests
|
||||
|
||||
|
|
@ -60,19 +62,55 @@ Results:
|
|||
- PR summary fetch normalizes PR metadata, changed files, commits, authors, branches, and list limits
|
||||
- PR summary tolerates partial files or commits fetch failures while keeping base PR metadata
|
||||
- PR summary base PR error-in-body responses return readable errors
|
||||
- repo report fetch composes health, issue, and PR sections
|
||||
- repo report returns a partial report when at least one enabled section succeeds
|
||||
- repo report returns an error when all enabled fetched sections fail
|
||||
- repo report issue and PR limits are covered
|
||||
|
||||
## Manual Command Examples
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +triage --title "Install failed on Windows" --body "go install failed with error" --format table
|
||||
gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --format json
|
||||
gitlink-cli workflow +triage --title "安装失败,无法登录" --body "运行命令时报错" --lang zh-CN --format markdown
|
||||
gitlink-cli workflow +triage \
|
||||
--title "安装失败,无法登录" \
|
||||
--body "运行命令时报错" \
|
||||
--lang zh-CN \
|
||||
--format markdown
|
||||
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format json
|
||||
gitlink-cli workflow +health --repository Gitlink/gitlink-cli --open-issues 3 --open-prs 1 --has-readme --has-license --has-contributing --agent-readiness-known --agent-readiness-score 9 --format table
|
||||
gitlink-cli workflow +health --repository demo/repo --open-issues 60 --stale-issues 25 --open-prs 12 --stale-prs 6 --recent-activity-known --recent-activity-days 120 --release-known=false --format json
|
||||
gitlink-cli workflow +health --repository Gitlink/gitlink-cli --open-issues 3 --open-prs 1 --has-readme --has-license --has-contributing --lang zh-CN --format markdown
|
||||
gitlink-cli workflow +health \
|
||||
--repository Gitlink/gitlink-cli \
|
||||
--open-issues 3 \
|
||||
--open-prs 1 \
|
||||
--has-readme \
|
||||
--has-license \
|
||||
--has-contributing \
|
||||
--agent-readiness-known \
|
||||
--agent-readiness-score 9 \
|
||||
--format table
|
||||
gitlink-cli workflow +health \
|
||||
--repository demo/repo \
|
||||
--open-issues 60 \
|
||||
--stale-issues 25 \
|
||||
--open-prs 12 \
|
||||
--stale-prs 6 \
|
||||
--recent-activity-known \
|
||||
--recent-activity-days 120 \
|
||||
--release-known=false \
|
||||
--format json
|
||||
gitlink-cli workflow +health \
|
||||
--repository Gitlink/gitlink-cli \
|
||||
--open-issues 3 \
|
||||
--open-prs 1 \
|
||||
--has-readme \
|
||||
--has-license \
|
||||
--has-contributing \
|
||||
--lang zh-CN \
|
||||
--format markdown
|
||||
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
|
||||
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json
|
||||
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
|
||||
```
|
||||
|
||||
## Remote Manual Verification
|
||||
|
|
@ -89,6 +127,9 @@ gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.j
|
|||
- `workflow +triage` still supports local parameters or a local JSON file via `--from`.
|
||||
- `workflow +health` still supports local parameters or a local JSON file via `--from`.
|
||||
- `workflow +pr-summary` supports local JSON input and read-only GitLink fetch mode.
|
||||
- `workflow +repo-report` supports local JSON input and partial read-only GitLink fetch aggregation.
|
||||
- Remote `workflow +repo-report` PR aggregation currently uses PR list metadata only;
|
||||
detailed file and commit analysis remains available through `workflow +pr-summary --number`.
|
||||
- `json/table/markdown` are rendered inside the workflow package, not by the global formatter.
|
||||
- Fetch-layer tests use `httptest` and do not depend on the real remote API.
|
||||
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
|
@ -56,6 +57,27 @@ func RenderPRSummary(result PRSummaryResult, format string, lang string) (string
|
|||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func RenderRepoReport(result RepoReportResult, format string, lang string) (string, error) {
|
||||
var buf bytes.Buffer
|
||||
switch normalizeFormat(format) {
|
||||
case "json":
|
||||
if err := writeJSON(&buf, result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
case "markdown":
|
||||
if err := writeRepoReportMarkdown(&buf, result, lang); err != nil {
|
||||
return "", err
|
||||
}
|
||||
case "table":
|
||||
if err := writeRepoReportTable(&buf, result, lang); err != nil {
|
||||
return "", err
|
||||
}
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported workflow output format %q", format)
|
||||
}
|
||||
return buf.String(), nil
|
||||
}
|
||||
|
||||
func normalizeFormat(format string) string {
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
if format == "" {
|
||||
|
|
@ -132,6 +154,35 @@ func writeHealthTable(w io.Writer, result HealthResult) error {
|
|||
return tw.Flush()
|
||||
}
|
||||
|
||||
func writeRepoReportTable(w io.Writer, result RepoReportResult, lang string) error {
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
healthScore := repoReportText(lang, "not_available")
|
||||
if result.Health != nil {
|
||||
healthScore = fmt.Sprintf("%d", result.Health.HealthScore)
|
||||
}
|
||||
topRecommendation := repoReportText(lang, "not_available")
|
||||
if len(result.Recommendations) > 0 {
|
||||
topRecommendation = truncateTableText(result.Recommendations[0], 96)
|
||||
}
|
||||
if _, err := fmt.Fprintln(tw, "REPOSITORY\tREPORT_SCORE\tRISK\tHEALTH_SCORE\tISSUES\tHIGH_RISK_ISSUES\tPRS\tHIGH_RISK_PRS\tTOP_RECOMMENDATION"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(tw, "%s\t%d\t%s\t%s\t%d\t%d\t%d\t%d\t%s\n",
|
||||
result.Repository,
|
||||
result.ReportScore,
|
||||
result.RiskLevel,
|
||||
healthScore,
|
||||
result.IssueSummary.Total,
|
||||
result.IssueSummary.HighRisk,
|
||||
result.PRSummary.Total,
|
||||
result.PRSummary.HighRisk,
|
||||
topRecommendation,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func writeTriageMarkdown(w io.Writer, report TriageReport) error {
|
||||
if _, err := fmt.Fprintf(w, "# Issue Triage Report\n\nRepository: `%s`\n\n", report.Repository); err != nil {
|
||||
return err
|
||||
|
|
@ -161,6 +212,116 @@ func writeTriageMarkdown(w io.Writer, report TriageReport) error {
|
|||
return nil
|
||||
}
|
||||
|
||||
func writeRepoReportMarkdown(w io.Writer, result RepoReportResult, lang string) error {
|
||||
lang = normalizeLang(lang)
|
||||
if _, err := fmt.Fprintf(w, "# %s\n\n", repoReportText(lang, "title")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "## %s\n\n", repoReportText(lang, "overview")); err != nil {
|
||||
return err
|
||||
}
|
||||
healthScore := repoReportText(lang, "not_available")
|
||||
if result.Health != nil {
|
||||
healthScore = fmt.Sprintf("%d", result.Health.HealthScore)
|
||||
}
|
||||
overview := []string{
|
||||
fmt.Sprintf("- Repository: `%s`", result.Repository),
|
||||
fmt.Sprintf("- Report score: `%d`", result.ReportScore),
|
||||
fmt.Sprintf("- Risk level: `%s`", result.RiskLevel),
|
||||
fmt.Sprintf("- Health score: `%s`", healthScore),
|
||||
fmt.Sprintf("- Issues analyzed: `%d`", result.IssueSummary.Total),
|
||||
fmt.Sprintf("- Pull requests analyzed: `%d`", result.PRSummary.Total),
|
||||
fmt.Sprintf("- Source: `%s`", result.Source),
|
||||
}
|
||||
for _, line := range overview {
|
||||
if _, err := fmt.Fprintln(w, line); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(w, "\n## %s\n\n", repoReportText(lang, "health")); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Health == nil {
|
||||
if _, err := fmt.Fprintf(w, "- %s\n", repoReportText(lang, "not_available")); err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
if _, err := fmt.Fprintf(w, "- Score: `%d`\n- Risk: `%s`\n", result.Health.HealthScore, result.Health.RiskLevel); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := fmt.Fprintf(w, "\n## %s\n\n", repoReportText(lang, "issues")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "- Total: `%d`\n- High risk: `%d`\n- Missing information: `%d`\n", result.IssueSummary.Total, result.IssueSummary.HighRisk, result.IssueSummary.MissingInfo); err != nil {
|
||||
return err
|
||||
}
|
||||
writeCountMapMarkdown(w, "By type", result.IssueSummary.ByType)
|
||||
writeCountMapMarkdown(w, "By priority", result.IssueSummary.ByPriority)
|
||||
|
||||
if _, err := fmt.Fprintf(w, "\n## %s\n\n", repoReportText(lang, "prs")); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "- Total: `%d`\n- High risk: `%d`\n", result.PRSummary.Total, result.PRSummary.HighRisk); err != nil {
|
||||
return err
|
||||
}
|
||||
writeCountMapMarkdown(w, "By type", result.PRSummary.ByType)
|
||||
writeCountMapMarkdown(w, "By risk", result.PRSummary.ByRisk)
|
||||
if len(result.PRSummary.ReviewFocus) > 0 {
|
||||
if _, err := fmt.Fprintln(w, "- Review focus:"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, focus := range result.PRSummary.ReviewFocus {
|
||||
if _, err := fmt.Fprintf(w, " - %s\n", focus); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := writeRepoReportMarkdownList(w, repoReportText(lang, "recommendations"), result.Recommendations, repoReportText(lang, "not_available")); err != nil {
|
||||
return err
|
||||
}
|
||||
return writeRepoReportMarkdownList(w, repoReportText(lang, "reasoning"), result.Reasoning, repoReportText(lang, "not_available"))
|
||||
}
|
||||
|
||||
func writeCountMapMarkdown(w io.Writer, title string, values map[string]int) error {
|
||||
if len(values) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
if _, err := fmt.Fprintf(w, "- %s:\n", title); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, key := range keys {
|
||||
if _, err := fmt.Fprintf(w, " - `%s`: `%d`\n", key, values[key]); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeRepoReportMarkdownList(w io.Writer, title string, values []string, fallback string) error {
|
||||
if _, err := fmt.Fprintf(w, "\n## %s\n\n", title); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(values) == 0 {
|
||||
_, err := fmt.Fprintf(w, "- %s\n", fallback)
|
||||
return err
|
||||
}
|
||||
for _, value := range values {
|
||||
if _, err := fmt.Fprintf(w, "- %s\n", value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writePRSummaryMarkdown(w io.Writer, result PRSummaryResult, lang string) error {
|
||||
lang = normalizeLang(lang)
|
||||
if _, err := fmt.Fprintf(w, "# %s\n\n", message(lang, "pr_summary_title")); err != nil {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,404 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type RepoReportInput struct {
|
||||
Repository string `json:"repository"`
|
||||
Health *HealthInput `json:"health,omitempty"`
|
||||
Issues []IssueInput `json:"issues,omitempty"`
|
||||
PullRequests []PRSummaryInput `json:"pull_requests,omitempty"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type RepoReportResult struct {
|
||||
Repository string `json:"repository"`
|
||||
Health *HealthResult `json:"health,omitempty"`
|
||||
IssueSummary RepoIssueSummary `json:"issue_summary"`
|
||||
PRSummary RepoPRSummary `json:"pr_summary"`
|
||||
Recommendations []string `json:"recommendations"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
ReportScore int `json:"report_score"`
|
||||
Sections []string `json:"sections"`
|
||||
Reasoning []string `json:"reasoning"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type RepoIssueSummary struct {
|
||||
Total int `json:"total"`
|
||||
ByType map[string]int `json:"by_type"`
|
||||
ByPriority map[string]int `json:"by_priority"`
|
||||
HighRisk int `json:"high_risk"`
|
||||
MissingInfo int `json:"missing_info"`
|
||||
}
|
||||
|
||||
type RepoPRSummary struct {
|
||||
Total int `json:"total"`
|
||||
ByType map[string]int `json:"by_type"`
|
||||
ByRisk map[string]int `json:"by_risk"`
|
||||
HighRisk int `json:"high_risk"`
|
||||
ReviewFocus []string `json:"review_focus"`
|
||||
}
|
||||
|
||||
func newRepoReportShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "repo-report",
|
||||
Description: "Generate a read-only repository workflow report",
|
||||
Flags: []common.Flag{
|
||||
{Name: "from", Usage: "Read repository report input from a JSON file"},
|
||||
{Name: "issue-limit", Usage: "Maximum issues to fetch and analyze", Default: "20"},
|
||||
{Name: "pr-limit", Usage: "Maximum pull requests to fetch and summarize", Default: "10"},
|
||||
{Name: "stale-days", Usage: "Days before an issue or PR is considered stale", Default: "30"},
|
||||
{Name: "include-issues", Usage: "Include issue triage summary", Bool: true, Default: "true"},
|
||||
{Name: "include-prs", Usage: "Include pull request summary", Bool: true, Default: "true"},
|
||||
{Name: "include-health", Usage: "Include repository health summary", Bool: true, Default: "true"},
|
||||
{Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN},
|
||||
},
|
||||
Run: runRepoReport,
|
||||
}
|
||||
}
|
||||
|
||||
func runRepoReport(ctx *common.RuntimeContext) error {
|
||||
lang := normalizeLang(ctx.Arg("lang"))
|
||||
input, notes, err := collectRepoReportInput(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := AnalyzeRepoReport(input, lang)
|
||||
for _, note := range notes {
|
||||
if note.Metric == "" && note.Note == "" {
|
||||
continue
|
||||
}
|
||||
result.Reasoning = append(result.Reasoning, fmt.Sprintf("%s: %s", note.Metric, note.Note))
|
||||
}
|
||||
|
||||
format := ctx.Format
|
||||
if strings.TrimSpace(cmdutil.Format) == "" {
|
||||
format = "markdown"
|
||||
}
|
||||
rendered, err := RenderRepoReport(result, format, lang)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprint(os.Stdout, rendered)
|
||||
return err
|
||||
}
|
||||
|
||||
func collectRepoReportInput(ctx *common.RuntimeContext) (RepoReportInput, []ScoringNote, error) {
|
||||
if path := strings.TrimSpace(ctx.Arg("from")); path != "" {
|
||||
input, err := readRepoReportInput(path)
|
||||
if err != nil {
|
||||
return RepoReportInput{}, nil, err
|
||||
}
|
||||
if strings.TrimSpace(input.Source) == "" {
|
||||
input.Source = "local-json"
|
||||
}
|
||||
return input, nil, nil
|
||||
}
|
||||
|
||||
issueLimit, err := parseIntArg(ctx.Arg("issue-limit"), 20, "issue-limit")
|
||||
if err != nil {
|
||||
return RepoReportInput{}, nil, err
|
||||
}
|
||||
prLimit, err := parseIntArg(ctx.Arg("pr-limit"), 10, "pr-limit")
|
||||
if err != nil {
|
||||
return RepoReportInput{}, nil, err
|
||||
}
|
||||
staleDays, err := parseIntArg(ctx.Arg("stale-days"), 30, "stale-days")
|
||||
if err != nil {
|
||||
return RepoReportInput{}, nil, err
|
||||
}
|
||||
|
||||
return FetchRepoReportInput(ctx, RepoReportFetchOptions{
|
||||
IssueLimit: issueLimit,
|
||||
PRLimit: prLimit,
|
||||
StaleDays: staleDays,
|
||||
IncludeIssues: parseBoolArg(ctx.Arg("include-issues")),
|
||||
IncludePRs: parseBoolArg(ctx.Arg("include-prs")),
|
||||
IncludeHealth: parseBoolArg(ctx.Arg("include-health")),
|
||||
})
|
||||
}
|
||||
|
||||
func readRepoReportInput(path string) (RepoReportInput, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return RepoReportInput{}, fmt.Errorf("read repo report input: %w", err)
|
||||
}
|
||||
var input RepoReportInput
|
||||
if err := json.Unmarshal(data, &input); err != nil {
|
||||
return RepoReportInput{}, fmt.Errorf("parse repo report input: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(input.Repository) == "" && input.Health == nil && len(input.Issues) == 0 && len(input.PullRequests) == 0 {
|
||||
return RepoReportInput{}, fmt.Errorf("parse repo report input: expected RepoReportInput root object")
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func AnalyzeRepoReport(input RepoReportInput, lang string) RepoReportResult {
|
||||
lang = normalizeLang(lang)
|
||||
source := strings.TrimSpace(input.Source)
|
||||
if source == "" {
|
||||
source = "local"
|
||||
}
|
||||
repository := strings.TrimSpace(input.Repository)
|
||||
sections := []string{}
|
||||
reasoning := []string{}
|
||||
|
||||
var healthResult *HealthResult
|
||||
baseScore := 70
|
||||
if input.Health != nil {
|
||||
health := *input.Health
|
||||
if repository == "" {
|
||||
repository = health.Repository
|
||||
}
|
||||
scored := ScoreHealth(health, lang)
|
||||
healthResult = &scored
|
||||
baseScore = scored.HealthScore
|
||||
sections = append(sections, "health")
|
||||
reasoning = append(reasoning, fmt.Sprintf("health score: %d", scored.HealthScore))
|
||||
} else {
|
||||
reasoning = append(reasoning, repoReportText(lang, "health_missing"))
|
||||
}
|
||||
|
||||
issueSummary, issueResults := summarizeRepoIssues(input.Issues, lang)
|
||||
if len(input.Issues) > 0 {
|
||||
sections = append(sections, "issues")
|
||||
reasoning = append(reasoning, fmt.Sprintf("issues analyzed: %d", len(input.Issues)))
|
||||
}
|
||||
|
||||
prSummary, prResults := summarizeRepoPRs(input.PullRequests, lang)
|
||||
if len(input.PullRequests) > 0 {
|
||||
sections = append(sections, "pull_requests")
|
||||
reasoning = append(reasoning, fmt.Sprintf("pull requests analyzed: %d", len(input.PullRequests)))
|
||||
}
|
||||
|
||||
reportScore := computeRepoReportScore(baseScore, input.Health != nil, issueSummary, prSummary)
|
||||
risk := riskLevel(reportScore)
|
||||
if hasSecurityP0(issueResults) || hasCriticalPR(prResults) {
|
||||
risk = "critical"
|
||||
reportScore = minInt(reportScore, 39)
|
||||
reasoning = append(reasoning, repoReportText(lang, "critical_signal"))
|
||||
}
|
||||
|
||||
recommendations := buildRepoReportRecommendations(lang, healthResult, issueSummary, prSummary, risk)
|
||||
if repository == "" {
|
||||
repository = "local"
|
||||
}
|
||||
|
||||
return RepoReportResult{
|
||||
Repository: repository,
|
||||
Health: healthResult,
|
||||
IssueSummary: issueSummary,
|
||||
PRSummary: prSummary,
|
||||
Recommendations: recommendations,
|
||||
RiskLevel: risk,
|
||||
ReportScore: reportScore,
|
||||
Sections: sections,
|
||||
Reasoning: uniqueStrings(reasoning),
|
||||
Source: source,
|
||||
}
|
||||
}
|
||||
|
||||
func summarizeRepoIssues(issues []IssueInput, lang string) (RepoIssueSummary, []TriageResult) {
|
||||
summary := RepoIssueSummary{
|
||||
ByType: map[string]int{},
|
||||
ByPriority: map[string]int{},
|
||||
}
|
||||
results := make([]TriageResult, 0, len(issues))
|
||||
for _, issue := range issues {
|
||||
result := AnalyzeIssue(issue, lang)
|
||||
results = append(results, result)
|
||||
summary.Total++
|
||||
summary.ByType[result.DetectedType]++
|
||||
summary.ByPriority[result.Priority]++
|
||||
if result.Priority == PriorityP0 || result.Priority == PriorityP1 || containsString(result.RiskFlags, RiskSecuritySensitive) {
|
||||
summary.HighRisk++
|
||||
}
|
||||
if len(result.MissingInformation) > 0 {
|
||||
summary.MissingInfo++
|
||||
}
|
||||
}
|
||||
return summary, results
|
||||
}
|
||||
|
||||
func summarizeRepoPRs(inputs []PRSummaryInput, lang string) (RepoPRSummary, []PRSummaryResult) {
|
||||
summary := RepoPRSummary{
|
||||
ByType: map[string]int{},
|
||||
ByRisk: map[string]int{},
|
||||
}
|
||||
results := make([]PRSummaryResult, 0, len(inputs))
|
||||
focus := []string{}
|
||||
for _, input := range inputs {
|
||||
result := AnalyzePRSummary(input, lang)
|
||||
results = append(results, result)
|
||||
summary.Total++
|
||||
summary.ByType[result.ChangeType]++
|
||||
summary.ByRisk[result.RiskLevel]++
|
||||
if result.RiskLevel == PRRiskHigh || result.RiskLevel == PRRiskCritical {
|
||||
summary.HighRisk++
|
||||
}
|
||||
focus = append(focus, result.ReviewFocus...)
|
||||
}
|
||||
summary.ReviewFocus = uniqueStrings(focus)
|
||||
sort.Strings(summary.ReviewFocus)
|
||||
return summary, results
|
||||
}
|
||||
|
||||
func computeRepoReportScore(baseScore int, hasHealth bool, issueSummary RepoIssueSummary, prSummary RepoPRSummary) int {
|
||||
score := baseScore
|
||||
if !hasHealth {
|
||||
score = 70
|
||||
}
|
||||
score -= issueSummary.HighRisk * 8
|
||||
score -= issueSummary.MissingInfo * 3
|
||||
score -= prSummary.HighRisk * 8
|
||||
score -= prSummary.ByRisk[PRRiskCritical] * 10
|
||||
if issueSummary.Total == 0 && prSummary.Total == 0 && !hasHealth {
|
||||
score = 50
|
||||
}
|
||||
return clampInt(score, 0, 100)
|
||||
}
|
||||
|
||||
func hasSecurityP0(results []TriageResult) bool {
|
||||
for _, result := range results {
|
||||
if result.Priority == PriorityP0 || result.DetectedType == IssueTypeSecurity || containsString(result.RiskFlags, RiskSecuritySensitive) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasCriticalPR(results []PRSummaryResult) bool {
|
||||
for _, result := range results {
|
||||
if result.RiskLevel == PRRiskCritical {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildRepoReportRecommendations(lang string, health *HealthResult, issueSummary RepoIssueSummary, prSummary RepoPRSummary, risk string) []string {
|
||||
recommendations := []string{}
|
||||
if issueSummary.ByPriority[PriorityP0] > 0 || issueSummary.ByType[IssueTypeSecurity] > 0 {
|
||||
recommendations = append(recommendations, repoReportText(lang, "rec_security_issues"))
|
||||
}
|
||||
if issueSummary.MissingInfo > 0 {
|
||||
recommendations = append(recommendations, repoReportText(lang, "rec_missing_info"))
|
||||
}
|
||||
if prSummary.HighRisk > 0 {
|
||||
recommendations = append(recommendations, repoReportText(lang, "rec_high_risk_prs"))
|
||||
}
|
||||
if health != nil {
|
||||
recommendations = append(recommendations, health.Recommendations...)
|
||||
}
|
||||
if health != nil && health.HealthScore < 65 {
|
||||
recommendations = append(recommendations, repoReportText(lang, "rec_health"))
|
||||
}
|
||||
if risk == "low" && len(recommendations) == 0 {
|
||||
recommendations = append(recommendations, repoReportText(lang, "rec_maintain_report"))
|
||||
}
|
||||
if len(recommendations) == 0 {
|
||||
recommendations = append(recommendations, repoReportText(lang, "rec_review_report"))
|
||||
}
|
||||
recommendations = uniqueStrings(recommendations)
|
||||
if len(recommendations) > 8 {
|
||||
recommendations = recommendations[:8]
|
||||
}
|
||||
return recommendations
|
||||
}
|
||||
|
||||
func repoReportText(lang, key string) string {
|
||||
zh := normalizeLang(lang) == langZH
|
||||
switch key {
|
||||
case "title":
|
||||
if zh {
|
||||
return "仓库工作流报告"
|
||||
}
|
||||
return "Repository Workflow Report"
|
||||
case "overview":
|
||||
if zh {
|
||||
return "总览"
|
||||
}
|
||||
return "Overview"
|
||||
case "health":
|
||||
if zh {
|
||||
return "健康度摘要"
|
||||
}
|
||||
return "Health Summary"
|
||||
case "issues":
|
||||
if zh {
|
||||
return "Issue 分诊摘要"
|
||||
}
|
||||
return "Issue Triage Summary"
|
||||
case "prs":
|
||||
if zh {
|
||||
return "PR 审阅摘要"
|
||||
}
|
||||
return "PR Review Summary"
|
||||
case "recommendations":
|
||||
if zh {
|
||||
return "建议操作"
|
||||
}
|
||||
return "Recommendations"
|
||||
case "reasoning":
|
||||
if zh {
|
||||
return "判断依据"
|
||||
}
|
||||
return "Reasoning"
|
||||
case "not_available":
|
||||
if zh {
|
||||
return "不可用"
|
||||
}
|
||||
return "Not available"
|
||||
case "health_missing":
|
||||
if zh {
|
||||
return "未提供健康度输入。"
|
||||
}
|
||||
return "health input not provided"
|
||||
case "critical_signal":
|
||||
if zh {
|
||||
return "发现安全 P0 Issue 或 critical PR,整体风险上调。"
|
||||
}
|
||||
return "security P0 issue or critical PR raised overall risk"
|
||||
case "rec_security_issues":
|
||||
if zh {
|
||||
return "优先处理安全相关或 P0 Issue。"
|
||||
}
|
||||
return "Prioritize security-related or P0 issues."
|
||||
case "rec_missing_info":
|
||||
if zh {
|
||||
return "要求补充复现步骤、版本、命令输出或日志。"
|
||||
}
|
||||
return "Request missing reproduction steps, version, command output, or logs."
|
||||
case "rec_high_risk_prs":
|
||||
if zh {
|
||||
return "优先审阅 high / critical 风险 PR。"
|
||||
}
|
||||
return "Prioritize high or critical risk pull requests."
|
||||
case "rec_health":
|
||||
if zh {
|
||||
return "根据健康度建议降低仓库治理风险。"
|
||||
}
|
||||
return "Use the health recommendations to reduce repository governance risk."
|
||||
case "rec_maintain_report":
|
||||
if zh {
|
||||
return "保持当前维护节奏,并定期复查仓库工作流报告。"
|
||||
}
|
||||
return "Maintain the current workflow and review the repository report regularly."
|
||||
case "rec_review_report":
|
||||
if zh {
|
||||
return "复查报告中的风险项并安排下一步维护动作。"
|
||||
}
|
||||
return "Review report risks and schedule the next maintenance actions."
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type RepoReportFetchOptions struct {
|
||||
Owner string
|
||||
Repo string
|
||||
IssueLimit int
|
||||
PRLimit int
|
||||
StaleDays int
|
||||
IncludeIssues bool
|
||||
IncludePRs bool
|
||||
IncludeHealth bool
|
||||
}
|
||||
|
||||
func FetchRepoReportInput(ctx *common.RuntimeContext, opts RepoReportFetchOptions) (RepoReportInput, []ScoringNote, error) {
|
||||
owner, repo, err := resolveFetchRepo(ctx, opts.Owner, opts.Repo)
|
||||
if err != nil {
|
||||
return RepoReportInput{}, nil, fmt.Errorf("workflow +repo-report remote mode requires --owner and --repo or a Git remote: %w", err)
|
||||
}
|
||||
if opts.IssueLimit <= 0 {
|
||||
opts.IssueLimit = 20
|
||||
}
|
||||
if opts.PRLimit <= 0 {
|
||||
opts.PRLimit = 10
|
||||
}
|
||||
if opts.StaleDays <= 0 {
|
||||
opts.StaleDays = 30
|
||||
}
|
||||
|
||||
input := RepoReportInput{
|
||||
Repository: fmt.Sprintf("%s/%s", owner, repo),
|
||||
Source: "remote-read-only-fetch",
|
||||
}
|
||||
notes := []ScoringNote{}
|
||||
successes := 0
|
||||
|
||||
if opts.IncludeHealth {
|
||||
health, healthNotes, err := FetchHealthInput(ctx, HealthFetchOptions{
|
||||
Owner: owner,
|
||||
Repo: repo,
|
||||
StaleDays: opts.StaleDays,
|
||||
IncludeCI: true,
|
||||
IncludeRelease: true,
|
||||
IncludeDocs: true,
|
||||
})
|
||||
notes = append(notes, healthNotes...)
|
||||
if err != nil {
|
||||
notes = append(notes, ScoringNote{Metric: "repo_report_health", Note: fmt.Sprintf("health fetch failed: %v", err)})
|
||||
} else {
|
||||
input.Health = &health
|
||||
successes++
|
||||
}
|
||||
}
|
||||
|
||||
if opts.IncludeIssues {
|
||||
issues, err := FetchIssuesForTriage(ctx, TriageFetchOptions{
|
||||
Owner: owner,
|
||||
Repo: repo,
|
||||
State: "open",
|
||||
Limit: opts.IssueLimit,
|
||||
Page: 1,
|
||||
})
|
||||
if err != nil {
|
||||
notes = append(notes, ScoringNote{Metric: "repo_report_issues", Note: fmt.Sprintf("issue fetch failed: %v", err)})
|
||||
} else {
|
||||
input.Issues = issues
|
||||
successes++
|
||||
}
|
||||
}
|
||||
|
||||
if opts.IncludePRs {
|
||||
prs, err := fetchPRListForReport(ctx, owner, repo, opts.PRLimit)
|
||||
if err != nil {
|
||||
notes = append(notes, ScoringNote{Metric: "repo_report_prs", Note: fmt.Sprintf("pull request list fetch failed: %v", err)})
|
||||
} else {
|
||||
input.PullRequests = prs
|
||||
successes++
|
||||
if len(prs) > 0 {
|
||||
notes = append(notes, ScoringNote{
|
||||
Metric: "repo_report_prs",
|
||||
Note: "PR report uses list metadata only; changed files and commits require workflow +pr-summary with a PR number.",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if successes == 0 {
|
||||
if len(notes) == 0 {
|
||||
notes = append(notes, ScoringNote{Metric: "repo_report", Note: "no report sections were enabled or fetched"})
|
||||
}
|
||||
return RepoReportInput{}, uniqueScoringNotes(notes), fmt.Errorf("fetch repo report: all enabled sections failed")
|
||||
}
|
||||
return input, uniqueScoringNotes(notes), nil
|
||||
}
|
||||
|
||||
func fetchPRListForReport(ctx *common.RuntimeContext, owner, repo string, limit int) ([]PRSummaryInput, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Set("state", "open")
|
||||
query.Set("page", "1")
|
||||
query.Set("limit", fmt.Sprintf("%d", limit))
|
||||
|
||||
env, err := ctx.CallAPIWithQuery("GET", workflowRepoPath(owner, repo)+"/pulls", query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := apiList(env.Data)
|
||||
inputs := make([]PRSummaryInput, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
input, ok := normalizePRSummaryItem(item)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
input.Repository = fmt.Sprintf("%s/%s", owner, repo)
|
||||
input.Source = "remote-read-only-fetch:list-metadata"
|
||||
if strings.TrimSpace(input.State) == "" {
|
||||
input.State = "open"
|
||||
}
|
||||
inputs = append(inputs, input)
|
||||
if len(inputs) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return inputs, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchRepoReportInputPartialPRUnavailable(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"updated_at": "2026-05-20T00:00:00Z",
|
||||
"has_readme": true,
|
||||
"has_license": true,
|
||||
"has_contributing": true,
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{
|
||||
{"number": 1, "title": "Install failed", "body": "error on install", "updated_at": "2026-05-20T00:00:00Z"},
|
||||
}})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json":
|
||||
http.Error(w, "pulls unavailable", http.StatusServiceUnavailable)
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"releases": []map[string]interface{}{{"created_at": "2026-05-01T00:00:00Z"}}})
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"builds": []map[string]interface{}{{"status": "success"}}})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
|
||||
IssueLimit: 10,
|
||||
PRLimit: 10,
|
||||
StaleDays: 30,
|
||||
IncludeHealth: true,
|
||||
IncludeIssues: true,
|
||||
IncludePRs: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRepoReportInput returned error: %v", err)
|
||||
}
|
||||
if input.Health == nil || len(input.Issues) != 1 {
|
||||
t.Fatalf("input = %+v, want health and issues", input)
|
||||
}
|
||||
if len(input.PullRequests) != 0 {
|
||||
t.Fatalf("len(PullRequests) = %d, want 0", len(input.PullRequests))
|
||||
}
|
||||
if !hasNote(notes, "repo_report_prs") {
|
||||
t.Fatalf("notes = %+v, want repo_report_prs note", notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRepoReportInputHealthFailureIssuesSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo.json":
|
||||
http.Error(w, "repo unavailable", http.StatusInternalServerError)
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{
|
||||
{"number": 1, "title": "README typo", "body": "docs typo"},
|
||||
}})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{}})
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"releases": []map[string]interface{}{}})
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"builds": []map[string]interface{}{}})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
|
||||
IssueLimit: 10,
|
||||
IncludeHealth: true,
|
||||
IncludeIssues: true,
|
||||
IncludePRs: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRepoReportInput returned error: %v", err)
|
||||
}
|
||||
if input.Health == nil || len(input.Issues) != 1 {
|
||||
t.Fatalf("input = %+v, want degraded health and one issue", input)
|
||||
}
|
||||
if !hasNote(notes, "repository") {
|
||||
t.Fatalf("notes = %+v, want repository note", notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRepoReportInputAllSectionsFail(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unavailable", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, _, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
|
||||
IncludeHealth: false,
|
||||
IncludeIssues: true,
|
||||
IncludePRs: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("FetchRepoReportInput returned nil error when all sections failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRepoReportInputRespectsIssueLimitAndIncludeFlags(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("limit"); got != "1" {
|
||||
t.Fatalf("issue limit = %q, want 1", got)
|
||||
}
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{
|
||||
{"number": 1, "title": "First bug", "body": "error"},
|
||||
{"number": 2, "title": "Second bug", "body": "error"},
|
||||
}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
|
||||
IssueLimit: 1,
|
||||
IncludeHealth: false,
|
||||
IncludeIssues: true,
|
||||
IncludePRs: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRepoReportInput returned error: %v", err)
|
||||
}
|
||||
if len(notes) != 0 {
|
||||
t.Fatalf("notes = %+v, want empty", notes)
|
||||
}
|
||||
if len(input.Issues) != 1 {
|
||||
t.Fatalf("len(Issues) = %d, want 1", len(input.Issues))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRepoReportInputPRListMetadata(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/pulls.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("limit"); got != "1" {
|
||||
t.Fatalf("PR limit = %q, want 1", got)
|
||||
}
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{
|
||||
{"number": 1, "title": "feat: add report", "user": map[string]interface{}{"login": "alice"}},
|
||||
{"number": 2, "title": "docs: update guide"},
|
||||
}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
|
||||
PRLimit: 1,
|
||||
IncludeHealth: false,
|
||||
IncludeIssues: false,
|
||||
IncludePRs: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRepoReportInput returned error: %v", err)
|
||||
}
|
||||
if len(input.PullRequests) != 1 {
|
||||
t.Fatalf("len(PullRequests) = %d, want 1", len(input.PullRequests))
|
||||
}
|
||||
if !hasNote(notes, "repo_report_prs") || !strings.Contains(notes[0].Note, "list metadata") {
|
||||
t.Fatalf("notes = %+v, want list metadata note", notes)
|
||||
}
|
||||
}
|
||||
|
||||
func hasNote(notes []ScoringNote, metric string) bool {
|
||||
for _, note := range notes {
|
||||
if note.Metric == metric {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAnalyzeRepoReportAggregatesHealthIssuesAndPRs(t *testing.T) {
|
||||
input := sampleRepoReportInput()
|
||||
result := AnalyzeRepoReport(input, "en")
|
||||
if result.ReportScore < 0 || result.ReportScore > 100 {
|
||||
t.Fatalf("ReportScore = %d, want 0..100", result.ReportScore)
|
||||
}
|
||||
if result.IssueSummary.Total != 3 {
|
||||
t.Fatalf("IssueSummary.Total = %d, want 3", result.IssueSummary.Total)
|
||||
}
|
||||
if result.IssueSummary.ByType[IssueTypeBug] == 0 {
|
||||
t.Fatalf("IssueSummary.ByType = %+v, want bug count", result.IssueSummary.ByType)
|
||||
}
|
||||
if result.PRSummary.Total != 3 {
|
||||
t.Fatalf("PRSummary.Total = %d, want 3", result.PRSummary.Total)
|
||||
}
|
||||
if result.PRSummary.ByRisk[PRRiskHigh] == 0 {
|
||||
t.Fatalf("PRSummary.ByRisk = %+v, want high risk count", result.PRSummary.ByRisk)
|
||||
}
|
||||
if len(result.Recommendations) == 0 {
|
||||
t.Fatal("Recommendations empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeRepoReportWithSecurityIssueRaisesRisk(t *testing.T) {
|
||||
input := RepoReportInput{
|
||||
Repository: "owner/repo",
|
||||
Issues: []IssueInput{{
|
||||
Number: 1,
|
||||
Title: "Token leaked in logs",
|
||||
Body: "A secret token leaked from command output.",
|
||||
Labels: []string{"security"},
|
||||
}},
|
||||
}
|
||||
result := AnalyzeRepoReport(input, "en")
|
||||
if result.RiskLevel != "critical" {
|
||||
t.Fatalf("RiskLevel = %q, want critical", result.RiskLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeRepoReportPartialInput(t *testing.T) {
|
||||
input := RepoReportInput{
|
||||
Repository: "owner/repo",
|
||||
Health: &HealthInput{
|
||||
Repository: "owner/repo",
|
||||
OpenIssues: 1,
|
||||
OpenPRs: 0,
|
||||
RecentActivityKnown: true,
|
||||
RecentActivityDays: 2,
|
||||
ReleaseKnown: true,
|
||||
HasRecentRelease: true,
|
||||
HasReadme: true,
|
||||
HasLicense: true,
|
||||
HasContributing: true,
|
||||
AgentReadinessKnown: true,
|
||||
AgentReadinessScore: 9,
|
||||
},
|
||||
}
|
||||
result := AnalyzeRepoReport(input, "en")
|
||||
if result.Health == nil {
|
||||
t.Fatal("Health result nil")
|
||||
}
|
||||
if len(result.Recommendations) == 0 {
|
||||
t.Fatal("Recommendations empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeRepoReportChinese(t *testing.T) {
|
||||
result := AnalyzeRepoReport(sampleRepoReportInput(), "zh-CN")
|
||||
if len(result.Recommendations) == 0 {
|
||||
t.Fatal("Recommendations empty")
|
||||
}
|
||||
rendered, err := RenderRepoReport(result, "markdown", "zh-CN")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRepoReport returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(rendered, "仓库工作流报告") {
|
||||
t.Fatalf("markdown output missing Chinese title:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRepoReportJSON(t *testing.T) {
|
||||
rendered, err := RenderRepoReport(AnalyzeRepoReport(sampleRepoReportInput(), "en"), "json", "en")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRepoReport returned error: %v", err)
|
||||
}
|
||||
var result RepoReportResult
|
||||
if err := json.Unmarshal([]byte(rendered), &result); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, rendered)
|
||||
}
|
||||
if result.Repository != "owner/repo" {
|
||||
t.Fatalf("Repository = %q, want owner/repo", result.Repository)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRepoReportMarkdown(t *testing.T) {
|
||||
result := AnalyzeRepoReport(sampleRepoReportInput(), "en")
|
||||
rendered, err := RenderRepoReport(result, "markdown", "en")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRepoReport returned error: %v", err)
|
||||
}
|
||||
for _, want := range []string{"Repository Workflow Report", "Report score", "Recommendations"} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("markdown output missing %q:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRepoReportTable(t *testing.T) {
|
||||
rendered, err := RenderRepoReport(AnalyzeRepoReport(sampleRepoReportInput(), "en"), "table", "en")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRepoReport returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(rendered, "REPORT_SCORE") || !strings.Contains(rendered, "owner/repo") {
|
||||
t.Fatalf("table output = %q, want report score and repository", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRepoReportUnknownFormat(t *testing.T) {
|
||||
_, err := RenderRepoReport(AnalyzeRepoReport(sampleRepoReportInput(), "en"), "xml", "en")
|
||||
if err == nil {
|
||||
t.Fatal("RenderRepoReport returned nil error for unknown format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRepoReportInput(t *testing.T) {
|
||||
input, err := readRepoReportInput("testdata/repo_report.json")
|
||||
if err != nil {
|
||||
t.Fatalf("readRepoReportInput returned error: %v", err)
|
||||
}
|
||||
if input.Repository == "" || len(input.Issues) == 0 || len(input.PullRequests) == 0 {
|
||||
t.Fatalf("input = %+v, want populated fixture", input)
|
||||
}
|
||||
}
|
||||
|
||||
func sampleRepoReportInput() RepoReportInput {
|
||||
return RepoReportInput{
|
||||
Repository: "owner/repo",
|
||||
Health: &HealthInput{
|
||||
Repository: "owner/repo",
|
||||
OpenIssues: 5,
|
||||
OpenPRs: 3,
|
||||
StaleIssues: 1,
|
||||
StalePRs: 1,
|
||||
RecentActivityKnown: true,
|
||||
RecentActivityDays: 2,
|
||||
ReleaseKnown: true,
|
||||
HasRecentRelease: true,
|
||||
HasReadme: true,
|
||||
HasLicense: true,
|
||||
HasContributing: true,
|
||||
AgentReadinessKnown: true,
|
||||
AgentReadinessScore: 9,
|
||||
},
|
||||
Issues: []IssueInput{
|
||||
{Number: 1, Title: "Install failed", Body: "error on install", Labels: []string{"bug"}},
|
||||
{Number: 2, Title: "README typo", Body: "docs typo", Labels: []string{"docs"}},
|
||||
{Number: 3, Title: "Crash on login", Body: "panic", Labels: []string{"bug"}},
|
||||
},
|
||||
PullRequests: []PRSummaryInput{
|
||||
{
|
||||
Repository: "owner/repo",
|
||||
Number: 1,
|
||||
Title: "docs: update guide",
|
||||
ChangedFiles: []PRChangedFile{
|
||||
{Filename: "README.md", Additions: 10, Deletions: 1, Changes: 11},
|
||||
},
|
||||
},
|
||||
{
|
||||
Repository: "owner/repo",
|
||||
Number: 2,
|
||||
Title: "feat: add workflow command",
|
||||
ChangedFiles: []PRChangedFile{
|
||||
{Filename: "shortcuts/workflow/workflow.go", Additions: 40, Deletions: 4, Changes: 44},
|
||||
},
|
||||
},
|
||||
{
|
||||
Repository: "owner/repo",
|
||||
Number: 3,
|
||||
Title: "fix: normalize API client errors",
|
||||
ChangedFiles: []PRChangedFile{
|
||||
{Filename: "internal/client/client.go", Additions: 30, Deletions: 10, Changes: 40},
|
||||
},
|
||||
},
|
||||
},
|
||||
Source: "local-json",
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,134 @@
|
|||
{
|
||||
"repository": "Gitlink/gitlink-cli",
|
||||
"health": {
|
||||
"repository": "Gitlink/gitlink-cli",
|
||||
"open_issues": 8,
|
||||
"open_prs": 3,
|
||||
"stale_issues": 2,
|
||||
"stale_prs": 1,
|
||||
"recent_activity_known": true,
|
||||
"recent_activity_days": 3,
|
||||
"release_known": true,
|
||||
"has_recent_release": true,
|
||||
"ci_known": false,
|
||||
"ci_passing": false,
|
||||
"has_readme": true,
|
||||
"has_license": true,
|
||||
"has_contributing": true,
|
||||
"agent_readiness_known": true,
|
||||
"agent_readiness_score": 9
|
||||
},
|
||||
"issues": [
|
||||
{
|
||||
"number": 1,
|
||||
"title": "Install failed on Windows",
|
||||
"body": "go install failed with error",
|
||||
"state": "open",
|
||||
"author": "alice",
|
||||
"labels": ["bug"]
|
||||
},
|
||||
{
|
||||
"number": 2,
|
||||
"title": "README typo in installation guide",
|
||||
"body": "The docs example has a typo.",
|
||||
"state": "open",
|
||||
"author": "bob",
|
||||
"labels": ["docs"]
|
||||
},
|
||||
{
|
||||
"number": 3,
|
||||
"title": "Command crashes on login",
|
||||
"body": "panic on login",
|
||||
"state": "open",
|
||||
"author": "carol",
|
||||
"labels": ["bug"]
|
||||
}
|
||||
],
|
||||
"pull_requests": [
|
||||
{
|
||||
"repository": "Gitlink/gitlink-cli",
|
||||
"number": 11,
|
||||
"title": "docs: update workflow examples",
|
||||
"author": "dana",
|
||||
"state": "open",
|
||||
"base_branch": "master",
|
||||
"head_branch": "docs/workflow",
|
||||
"changed_files": [
|
||||
{
|
||||
"filename": "docs/workflow-agent-design.md",
|
||||
"status": "modified",
|
||||
"additions": 20,
|
||||
"deletions": 4,
|
||||
"changes": 24
|
||||
}
|
||||
],
|
||||
"commits": [
|
||||
{
|
||||
"sha": "1111111",
|
||||
"message": "docs: update workflow examples",
|
||||
"author": "dana"
|
||||
}
|
||||
],
|
||||
"additions": 20,
|
||||
"deletions": 4,
|
||||
"source": "local-json"
|
||||
},
|
||||
{
|
||||
"repository": "Gitlink/gitlink-cli",
|
||||
"number": 12,
|
||||
"title": "feat: add workflow command",
|
||||
"author": "erin",
|
||||
"state": "open",
|
||||
"base_branch": "master",
|
||||
"head_branch": "feature/workflow",
|
||||
"changed_files": [
|
||||
{
|
||||
"filename": "shortcuts/workflow/workflow.go",
|
||||
"status": "modified",
|
||||
"additions": 70,
|
||||
"deletions": 8,
|
||||
"changes": 78
|
||||
}
|
||||
],
|
||||
"commits": [
|
||||
{
|
||||
"sha": "2222222",
|
||||
"message": "feat: add workflow command",
|
||||
"author": "erin"
|
||||
}
|
||||
],
|
||||
"additions": 70,
|
||||
"deletions": 8,
|
||||
"source": "local-json"
|
||||
},
|
||||
{
|
||||
"repository": "Gitlink/gitlink-cli",
|
||||
"number": 13,
|
||||
"title": "fix: normalize API client errors",
|
||||
"author": "frank",
|
||||
"state": "open",
|
||||
"base_branch": "master",
|
||||
"head_branch": "fix/client-errors",
|
||||
"changed_files": [
|
||||
{
|
||||
"filename": "internal/client/client.go",
|
||||
"status": "modified",
|
||||
"additions": 30,
|
||||
"deletions": 12,
|
||||
"changes": 42
|
||||
}
|
||||
],
|
||||
"commits": [
|
||||
{
|
||||
"sha": "3333333",
|
||||
"message": "fix: normalize API client errors",
|
||||
"author": "frank"
|
||||
}
|
||||
],
|
||||
"additions": 30,
|
||||
"deletions": 12,
|
||||
"source": "local-json"
|
||||
}
|
||||
],
|
||||
"source": "local-json"
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ func Shortcuts() []*common.Shortcut {
|
|||
newTriageShortcut(),
|
||||
newHealthShortcut(),
|
||||
newPRSummaryShortcut(),
|
||||
newRepoReportShortcut(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -28,6 +28,9 @@ func TestShortcutsExposesWorkflowCommands(t *testing.T) {
|
|||
if !names["pr-summary"] {
|
||||
t.Fatal("Shortcuts missing pr-summary")
|
||||
}
|
||||
if !names["repo-report"] {
|
||||
t.Fatal("Shortcuts missing repo-report")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTriageWithSingleIssueArgs(t *testing.T) {
|
||||
|
|
@ -191,6 +194,32 @@ func TestRunHealthRemoteModeUsesFetch(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestCollectRepoReportFromJSONFile(t *testing.T) {
|
||||
ctx := &common.RuntimeContext{
|
||||
Args: map[string]string{
|
||||
"from": filepath.Join("testdata", "repo_report.json"),
|
||||
},
|
||||
}
|
||||
input, notes, err := collectRepoReportInput(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("collectRepoReportInput returned error: %v", err)
|
||||
}
|
||||
if len(notes) != 0 {
|
||||
t.Fatalf("notes = %+v, want empty", notes)
|
||||
}
|
||||
if input.Repository == "" || len(input.Issues) == 0 || len(input.PullRequests) == 0 {
|
||||
t.Fatalf("input = %+v, want populated report fixture", input)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectRepoReportMissingInputs(t *testing.T) {
|
||||
ctx := &common.RuntimeContext{Args: map[string]string{}}
|
||||
_, _, err := collectRepoReportInput(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("collectRepoReportInput returned nil error without --from or owner/repo")
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSONFixture(t *testing.T, path string, data interface{}) {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(data)
|
||||
|
|
|
|||
|
|
@ -119,6 +119,29 @@ Rules:
|
|||
- This command is read-only: it does not comment, approve, reject, merge, label, or close pull requests.
|
||||
- Do not use LLM APIs for this workflow; it is rule-based and explainable.
|
||||
|
||||
## Workflow: Repository Report (Read-only)
|
||||
|
||||
Use `workflow +repo-report` when a maintainer or Agent needs a single repository workflow report
|
||||
that aggregates health, issue triage, and PR review signals.
|
||||
|
||||
```bash
|
||||
# Maintainer report
|
||||
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
|
||||
|
||||
# Agent-readable report
|
||||
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format json
|
||||
|
||||
# Local fixture mode
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Prefer `--format json` when another Agent consumes the output.
|
||||
- Prefer `--format markdown` for maintainer reports, competition materials, and review handoff.
|
||||
- Treat remote mode as read-only aggregation only.
|
||||
- Do not comment, label, close, approve, reject, or merge from this workflow.
|
||||
- PR details in remote report mode may be partial; use `workflow +pr-summary --number <n>` for a focused PR review.
|
||||
|
||||
## 最佳实践
|
||||
|
||||
- 所有工作流命令使用 `--format json` 以便解析输出
|
||||
|
|
|
|||
Loading…
Reference in New Issue