Add workflow repo-report command

This commit is contained in:
whzy 2026-05-21 22:17:02 +08:00 committed by wbtiger
parent 7730347253
commit a904f48099
15 changed files with 1515 additions and 28 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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