Merge pull request '新增 workflow Shortcut 命令,支持 Issue 分诊、PR 摘要与仓库报告' (#29) from muel/gitlink-cli:workflow-agent-suite into master

This commit is contained in:
wbtiger 2026-05-26 16:28:27 +08:00
commit 42bc83295a
38 changed files with 7151 additions and 23 deletions

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
gitlink-cli.exe

105
README.md
View File

@ -290,6 +290,105 @@ gitlink-cli search +repos -k "machine learning"
gitlink-cli search +users -k "zhangsan"
```
### Workflow Agent Commands
`workflow` provides rule-based repository analysis for maintainers and AI Agents. It currently supports:
- `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:
```bash
# Triage with local parameters
gitlink-cli workflow +triage --title "Install failed on Windows" --body "go install failed with error" --format table
# Triage with JSON output
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
# Triage from a local JSON file
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format json
# Triage by read-only GitLink fetch
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
# 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
# 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
# Health by read-only GitLink fetch
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --format table
# PR review summary by read-only GitLink fetch
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
# PR review summary from a local JSON file
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json
# 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:
- `json` for scripts and AI Agents
- `table` for terminal review
- `markdown` for Issue comments, PR comments, release notes, and competition write-ups
Safety:
- Current workflow commands use local analysis by default and can also read GitLink data in read-only fetch mode.
- They do not modify remote GitLink data.
- They do not depend on LLM APIs.
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
- `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes.
### Raw API
For endpoints not covered by shortcuts, use the Raw API directly:
@ -311,7 +410,7 @@ gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5'
|-----------|-------------|---------|
| `--owner` | Repository owner | `--owner Gitlink` |
| `--repo` | Repository name | `--repo forgeplus` |
| `--format` | Output format (json/table/yaml) | `--format json` |
| `--format` | Output format (json/table/yaml; workflow also supports markdown) | `--format json` |
| `--debug` | Enable debug output | `--debug` |
**Automatic context resolution:** When running inside a git repository, `--owner` and `--repo` are automatically resolved from `git remote origin`.
@ -460,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?

105
docs/pr-draft.md Normal file
View File

@ -0,0 +1,105 @@
# feat(workflow): add agent workflow commands for repository maintenance
## Summary
This PR adds four read-only workflow commands for repository maintenance:
- `workflow +triage`
- `workflow +health`
- `workflow +pr-summary`
- `workflow +repo-report`
The commands provide rule-based, explainable analysis with stable `json`, concise `table`,
and copy-friendly `markdown` output.
## Motivation
Open-source maintainers often spend time on repetitive information organization before
making actual decisions:
- Issue triage cost
- PR review cost
- repository health visibility
- Agent needs stable structured output
This PR adds workflow-level analysis on top of the existing GitLink CLI shortcut architecture
without introducing LLM dependencies or remote write behavior.
## Changes
### `workflow +triage`
- Classifies issues by type
- Scores priority and confidence
- Detects missing bug-report information
- Produces risk flags, recommended actions, suggested comments, and reasoning
### `workflow +health`
- Scores repository health
- Covers issue/PR backlog, activity, release, CI, docs, license, contributing, and Agent readiness signals
- Tolerates unknown metrics without failing the command
### `workflow +pr-summary`
- Summarizes PR metadata, changed files, and commits
- Produces change type, risk level, review focus, test suggestions, merge checklist, and reasoning
- Supports local JSON input and remote read-only PR fetch
### `workflow +repo-report`
- Aggregates health, issue triage, and PR summary signals
- Produces a repository workflow report with score, risk level, recommendations, and reasoning
- Supports partial read-only remote aggregation when optional sections are unavailable
## Safety
- Remote mode is read-only
- No LLM dependency
- No labels/comments/close operations
- No PR approve/reject/merge operations
- No `internal/output` change
- No new third-party dependency
- Test fixtures do not contain secrets or tokens
## Tests
```bash
gofmt -w shortcuts/workflow/*.go shortcuts/register.go
go test ./shortcuts/workflow
go test ./...
```
Coverage includes:
- triage rules
- health scoring
- PR summary rules
- repo report aggregation
- fetch normalization
- partial failure handling
- `json` / `table` / `markdown` rendering
- local `--from` fixtures
- command wiring tests
## Documentation
- `README.md`
- `docs/workflow-agent-design.md`
- `docs/workflow-agent-test-report.md`
- `skills/gitlink-workflow/SKILL.md`
## Known Limitations
- `workflow +release-notes` is not implemented.
- `workflow +stale` is not implemented.
- Real GitLink API shapes may require follow-up normalization.
## Examples
```bash
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format table
gitlink-cli workflow +health --from shortcuts/workflow/testdata/health_good.json --format markdown
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format markdown
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown
```

View File

@ -0,0 +1,502 @@
# GitLink CLI Workflow Agent Design
## 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.
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
First PR:
- Add `gitlink-cli workflow +triage`.
- Add `gitlink-cli workflow +health`.
- Keep write behavior dry-run by default.
- Produce stable JSON for Agents.
- Produce concise table output for terminal users.
- Produce markdown output for reports, PR comments, Issue comments, and competition materials.
- Support `--lang en` and `--lang zh-CN` with a lightweight message helper.
Additional workflow commands:
- `workflow +pr-summary`: done
- `workflow +repo-report`: done
- `workflow +release-notes`: planned
- `workflow +stale`: planned
Current implementation status:
- Rule engine: done
- Local command layer: done
- API fetch layer: done
- Boundary tests: expanded for empty responses, field normalization,
unknown tolerance, and read-only error handling
- PR summary command: done with local JSON input, read-only fetch, rules, renderers, and tests
- Repo report command: done with local JSON input, partial read-only fetch aggregation,
scoring, renderers, and tests
## Current Repository Findings
Command registration:
- `cmd/root.go` registers global flags and calls `shortcuts.RegisterAll(rootCmd)`.
- `shortcuts/register.go` maps command groups to shortcut slices.
- Each group exposes `Shortcuts() []*common.Shortcut`.
- `common.MountShortcut` maps a `Shortcut` into a Cobra command named `+<name>`.
Runtime and API calls:
- `common.NewRuntimeContext` creates `client.Client`, carries owner, repo, format, and command args.
- `ctx.ResolveOwnerRepo()` resolves `--owner` / `--repo` or Git remote context.
- `ctx.CallAPI` and `ctx.CallAPIWithQuery` call `internal/client`.
- `client.Do` appends `.json`, injects auth via transport, parses GitLink error-in-body responses, and returns `output.Envelope`.
Output:
- `internal/output` currently supports `json`, `yaml`, and generic `table`.
- Workflow requires `markdown`; the minimal-risk approach is a workflow-local renderer that prints stable workflow DTOs.
- A later cleanup can promote markdown support into `internal/output` if multiple command groups need it.
- Current workflow commands also expose workflow-local `json`, `table`, and `markdown` rendering without changing the global formatter.
Testing:
- Existing tests use pure unit tests plus `httptest.Server`.
- Shortcut tests instantiate `common.RuntimeContext` manually with a mocked `client.Client`.
- This pattern should be reused for workflow API tests.
## Command Design
### `workflow +triage`
Examples:
```bash
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 30 --dry-run --format json
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 30 --format table
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 30 --lang zh-CN --format markdown
```
Flags:
- `--state`: default `open`
- `--limit`: default `30`
- `--page`: default `1`
- `--dry-run`: default `true`
- `--from`: optional local JSON input
- `--title`, `--body`, `--number`, `--author`, `--url`, `--labels`: optional local single-issue input
- `--lang`: default `en`, allowed `en`, `zh-CN`
Stable JSON item fields:
- `issue_id`
- `number`
- `title`
- `url`
- `author`
- `state`
- `created_at`
- `updated_at`
- `detected_type`
- `priority`
- `confidence`
- `suggested_labels`
- `missing_information`
- `risk_flags`
- `recommended_action`
- `suggested_comment`
- `reasoning`
Rule categories:
- `bug`
- `feature`
- `question`
- `docs`
- `ci`
- `security`
- `performance`
- `refactor`
- `unknown`
Priority:
- `P0`: security incident, secret/token leak, auth bypass, repository unusable
- `P1`: core command unusable, install/login failure, CI/release blocker
- `P2`: normal bug, important feature, missing docs blocking usage
- `P3`: ordinary question, typo, minor improvement
Missing information for bug-like issues:
- reproduction steps
- expected behavior
- actual behavior
- version
- OS / platform
- command output
- logs
### `workflow +health`
Examples:
```bash
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --format json
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --format table
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --lang zh-CN --format markdown
```
Flags:
- `--stale-days`: default `30`
- `--from`: optional local JSON input
- local metric flags such as `--repository`, `--open-issues`, `--open-prs`, `--has-readme`, `--has-license`, and `--agent-readiness-score`
- `--lang`: default `en`
Stable JSON fields:
- `repository`
- `open_issues`
- `open_prs`
- `stale_issues`
- `stale_prs`
- `recent_activity`
- `release_status`
- `ci_status`
- `documentation_status`
- `license_status`
- `contribution_status`
- `agent_readiness_score`
- `health_score`
- `risk_level`
- `recommendations`
- `scoring_notes`
Scoring:
- Issue backlog and response: 20
- PR backlog and merge state: 20
- Recent activity: 15
- Release status: 15
- Documentation completeness: 10
- License and contribution readiness: 10
- Agent readiness: 10
Unknown metric policy:
- Keep field present.
- Set status or score detail to `unknown`.
- Add one entry to `scoring_notes`.
- Either omit the metric from denominator or apply a conservative partial score; the first PR should prefer denominator adjustment to avoid fake precision.
Risk levels:
- `low`: 80-100
- `medium`: 60-79
- `high`: 40-59
- `critical`: 0-39
## Architecture
Proposed files:
```text
shortcuts/workflow/
workflow.go # Shortcuts() and command wiring
types.go # Stable DTOs
triage_rules.go # pure classifier, scoring, missing info detection
triage_fetch.go # GitLink issue fetching and response normalization
triage_render.go # json/table/markdown workflow rendering if needed
health_score.go # pure health scoring
health_fetch.go # repo, issue, PR, release, CI/doc/license probes
health_render.go # markdown/table rendering
messages.go # en and zh-CN strings
*_test.go
```
Registration:
- Add `workflow` import in `shortcuts/register.go`.
- Add `"workflow": workflow.Shortcuts()` to `groups`.
- Add description `"AI agent workflow analysis"`.
No new dependency is needed for this PR.
## Data Normalization
GitLink responses vary by endpoint. Workflow code should not depend on a single raw shape. Add small extraction helpers:
- `stringField(map, keys...)`
- `numberField(map, keys...)`
- `timeField(map, keys...)`
- `sliceField(map, keys...)`
- `extractItems(env, candidateKeys...)`
Candidate issue list keys:
- `issues`
- `data`
- direct array after future client improvements
Candidate issue fields:
- ID: `id`, `issue_id`
- Number: `project_issues_index`, `number`, `index`, `id`
- Title: `subject`, `title`
- Body: `description`, `body`
- Author: `author.login`, `user.login`, `login`
- URL: `html_url`, `url`, `issue_url`
Health activity fields currently tolerated:
- `updated_at`
- `updatedAt`
- `last_updated_at`
- `lastUpdatedAt`
- `last_activity_at`
- `lastActivityAt`
- `merged_at`
- `mergedAt`
- `closed_at`
- `closedAt`
## Safety Strategy
- `+triage` only reads by default.
- `--dry-run` defaults true.
- A future explicit write flag for posting comments must require `--dry-run=false` in a later PR.
- Generated comments are output as data, not posted remotely in the first PR.
- Health checks never mutate remote state.
- If an API probe fails, health continues with `unknown`.
- The implemented prototype is local-first and has no LLM dependency.
- Remote fetch mode remains read-only and does not post comments, labels, merges, or close actions.
- API failures should fall back to `unknown` metrics or a clear fetch error instead of fabricating healthy data.
## Core Pseudocode
### Triage
```go
issues := fetchIssues(owner, repo, state, limit, page)
results := []TriageResult{}
for _, issue := range issues {
text := normalize(issue.Title + "\n" + issue.Body)
scores := scoreKeywords(text, keywordRules)
detectedType := maxScoreType(scores)
priority := scorePriority(text, detectedType)
missing := detectMissingInfo(issue, detectedType)
confidence := confidenceFromScores(scores, missing)
result := TriageResult{
IssueID: issue.ID,
Number: issue.Number,
DetectedType: detectedType,
Priority: priority,
SuggestedLabels: labelsFor(detectedType, priority, riskFlags),
MissingInformation: missing,
RiskFlags: detectRiskFlags(text),
RecommendedAction: actionFor(detectedType, priority, missing, lang),
SuggestedComment: commentFor(missing, lang),
Reasoning: explainTopMatches(scores, priorityRules),
}
results = append(results, result)
}
render(results, format, lang)
```
### Health
```go
signals := collectHealthSignals(owner, repo)
score := NewWeightedScore(100)
score.Add("issues", 20, scoreIssueBacklog(signals.OpenIssues, signals.StaleIssues))
score.Add("prs", 20, scorePRBacklog(signals.OpenPRs, signals.StalePRs))
score.Add("activity", 15, scoreRecentActivity(signals.RecentActivity))
score.Add("release", 15, scoreReleaseStatus(signals.ReleaseStatus))
score.Add("docs", 10, scoreDocStatus(signals.DocumentationStatus))
score.Add("license", 10, scoreLicenseContribution(signals.LicenseStatus, signals.ContributionStatus))
score.Add("agent", 10, scoreAgentReadiness(signals))
result := HealthResult{
HealthScore: score.Percent(),
RiskLevel: riskLevel(score.Percent()),
Recommendations: recommendations(signals, score),
ScoringNotes: score.Notes(),
}
render(result, format, lang)
```
## Output Protocol
JSON:
- Use stable struct tags.
- Include empty arrays as `[]` where useful for Agent consumption.
- Avoid prose outside JSON.
Table:
- Triage columns: `NUMBER`, `TYPE`, `PRIORITY`, `CONFIDENCE`, `MISSING`, `ACTION`
- Health rows: `METRIC`, `STATUS`, `SCORE`, `NOTE`
Markdown:
- Triage: one summary table with type, priority, confidence, action, and missing information.
- Health: repository score, metric table, recommendations, and scoring notes.
- `zh-CN` changes rule messages and recommendation text, not JSON field names.
## Test Plan
Unit tests:
- Issue type classification.
- Priority scoring.
- Missing information detection.
- Risk flag detection.
- Suggested comment generation.
- Health weighted score and risk level.
- Unknown metric denominator adjustment.
- Markdown headings and required sections.
Mock API tests:
- `workflow +triage` fetches issues and normalizes raw response.
- `workflow +health` tolerates failing CI/release/doc probes.
Command tests:
- `--dry-run` defaults to true.
- `--lang zh-CN` accepted.
- invalid `--lang` falls back to `en`.
- `--format markdown` routes to markdown renderer.
## Later Extensions
### `workflow +pr-summary`
Inputs:
- `--number`
- `--from`
- `--lang`
- `--format`
- optional `--include-files`
- optional `--include-commits`
- optional `--max-files`
- optional `--max-commits`
Default format:
- `table` for human review when `--format` is omitted
Data:
- PR details
- changed files
- commits
Output:
- `change_type`
- `risk_level`
- `review_focus`
- `test_suggestions`
- `merge_checklist`
- `reasoning`
Implementation status:
- read-only local JSON mode: done
- read-only GitLink fetch mode: done
- rules and renderers: done
- tests: rules, fetch boundary, render, and command wiring
Safety:
- no comments
- no approve/reject
- no merge
- no remote write operation
### `workflow +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:
- `--from`
- `--to`
- optional `--tag`
- optional `--lang`
Data:
- PR titles
- commit messages
Markdown categories:
- Features
- Bug Fixes
- Documentation
- Tests
- Refactoring
- Chores
- Breaking Changes
### `workflow +stale`
Inputs:
- `--stale-days`
- `--state`
- `--dry-run`
Behavior:
- Identify stale issues and PRs.
- Generate suggested comments or labels.
- Do not mutate remote state by default.
## API Fetch Layer
The current fetch layer uses:
- `triage_fetch.go`
- `health_fetch.go`
- `pr_fetch.go`
- `repo_report_fetch.go`
Design goals already applied:
- tolerate unknown or partial API fields
- map GitLink response shapes into stable workflow DTOs
- continue operating when optional signals fail
- keep remote-write actions disabled until explicitly enabled later
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.
## Implementation Order
1. Pure DTOs and rule engine.
2. Pure health scoring.
3. Workflow renderers.
4. Command registration.
5. API fetch and normalization.
6. Tests.
7. README updates.
8. Competition docs and test report.

View File

@ -0,0 +1,174 @@
# Workflow Agent Test Report
## Scope
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
- `json` / `table` / `markdown` rendering
- language handling
- mock tests do not depend on the real remote API
## Environment
- OS: Windows
- Go version: `go1.26.1 windows/amd64`
- Go path: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\go.exe`
- gofmt path: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\gofmt.exe`
## Test Commands
Executed:
```bash
gofmt -w shortcuts/workflow/*.go shortcuts/register.go
go test ./shortcuts/workflow
go test ./...
```
Results:
- `go test ./shortcuts/workflow` passed.
- `go test ./...` passed.
## Unit Tests
- triage rules tests
- health score tests
- messages tests
- render tests
- command tests
- fetch boundary tests
- PR summary rules and fetch tests
- repo report aggregation, render, command, and partial fetch tests
## API Fetch Boundary Tests
- empty issue responses return a clear error instead of panicking
- missing issue titles still allow body-only issues to be normalized
- label normalization supports string arrays, object arrays, and title/name variants
- author normalization supports string, `user`, and `creator` shapes
- GitLink error-in-body responses return readable errors
- health activity timestamps accept `updated_at`, `updatedAt`, `last_activity_at`, `merged_at`, and `closed_at`
- release responses accept `releases`, `data`, and direct array shapes
- CI unavailability is recorded as `unknown` without failing the whole health run
- stale-days values `0` and negative values fall back to the default `30`
- PR summary fetch normalizes PR metadata, changed files, commits, authors, branches, and list limits
- PR summary tolerates partial files or commits fetch failures while keeping base PR metadata
- PR summary base PR error-in-body responses return readable errors
- 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 --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 +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
- Command: `gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table`
- Result: succeeded, returned five issues in table form.
- Command: `gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --lang zh-CN --format markdown`
- Result: succeeded, returned a markdown health report with score `58` and risk level `high`.
- Remote writes: `No`
## Known Limitations
- Current workflow commands support local analysis and read-only GitLink fetch mode.
- `workflow +triage` still supports local parameters or a local JSON file via `--from`.
- `workflow +health` still supports local parameters or a local JSON file via `--from`.
- `workflow +pr-summary` supports local JSON input and read-only GitLink fetch mode.
- `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.
## Conclusion
The rule-based Agent Workflow prototype, including the read-only fetch layer, is implemented, tested, and locally runnable.
## Final Verification
Final verification should be run before opening the official GitLink PR:
```bash
gofmt -w shortcuts/workflow/*.go shortcuts/register.go
go test ./shortcuts/workflow
go test ./...
```
Expected result:
- `go test ./shortcuts/workflow` passes.
- `go test ./...` passes.
- No remote write operation is performed by workflow commands.
## Competition Demo Commands
Prefer local fixtures for stable demos:
```bash
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format table
gitlink-cli workflow +health --from shortcuts/workflow/testdata/health_good.json --format markdown
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format markdown
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown
```
Read-only remote smoke commands:
```bash
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --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

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

View File

@ -14,34 +14,37 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/search"
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
)
// RegisterAll mounts all shortcut groups onto the root command.
func RegisterAll(root *cobra.Command) {
groups := map[string][]*common.Shortcut{
"repo": repo.Shortcuts(),
"issue": issue.Shortcuts(),
"pr": pr.Shortcuts(),
"release": release.Shortcuts(),
"branch": branch.Shortcuts(),
"org": org.Shortcuts(),
"user": user.Shortcuts(),
"search": search.Shortcuts(),
"ci": ci.Shortcuts(),
"webhook": webhook.Shortcuts(),
"repo": repo.Shortcuts(),
"issue": issue.Shortcuts(),
"pr": pr.Shortcuts(),
"release": release.Shortcuts(),
"branch": branch.Shortcuts(),
"org": org.Shortcuts(),
"user": user.Shortcuts(),
"search": search.Shortcuts(),
"ci": ci.Shortcuts(),
"webhook": webhook.Shortcuts(),
"workflow": workflow.Shortcuts(),
}
descriptions := map[string]string{
"repo": "Repository operations",
"issue": "Issue operations",
"pr": "Pull request operations",
"release": "Release operations",
"branch": "Branch operations",
"org": "Organization operations",
"user": "User operations",
"search": "Search operations",
"ci": "CI/CD operations",
"webhook": "Webhook operations",
"repo": "Repository operations",
"issue": "Issue operations",
"pr": "Pull request operations",
"release": "Release operations",
"branch": "Branch operations",
"org": "Organization operations",
"user": "User operations",
"search": "Search operations",
"ci": "CI/CD operations",
"webhook": "Webhook operations",
"workflow": "AI agent workflow analysis",
}
for name, shortcuts := range groups {

View File

@ -0,0 +1,363 @@
package workflow
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
type TriageFetchOptions struct {
Owner string
Repo string
State string
Limit int
Page int
Labels []string
Since string
}
type HealthFetchOptions struct {
Owner string
Repo string
StaleDays int
IncludeCI bool
IncludeRelease bool
IncludeDocs bool
}
func workflowRepoPath(owner, repo string) string {
return fmt.Sprintf("/v1/%s/%s", strings.TrimSpace(owner), strings.TrimSpace(repo))
}
func resolveFetchRepo(ctx *common.RuntimeContext, owner, repo string) (string, string, error) {
if strings.TrimSpace(owner) != "" && strings.TrimSpace(repo) != "" {
return strings.TrimSpace(owner), strings.TrimSpace(repo), nil
}
if strings.TrimSpace(ctx.Owner) != "" && strings.TrimSpace(ctx.Repo) != "" {
return strings.TrimSpace(ctx.Owner), strings.TrimSpace(ctx.Repo), nil
}
if err := ctx.ResolveOwnerRepo(); err != nil {
return "", "", err
}
if strings.TrimSpace(ctx.Owner) == "" || strings.TrimSpace(ctx.Repo) == "" {
return "", "", fmt.Errorf("repository owner/repo is required; use --owner and --repo or run inside a GitLink repository")
}
return strings.TrimSpace(ctx.Owner), strings.TrimSpace(ctx.Repo), nil
}
func normalizeAPIData(data interface{}) (interface{}, error) {
switch v := data.(type) {
case nil:
return nil, nil
case string:
trimmed := strings.TrimSpace(v)
if trimmed == "" {
return nil, nil
}
var decoded interface{}
if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil {
return v, nil
}
return decoded, nil
case json.RawMessage:
if len(v) == 0 {
return nil, nil
}
var decoded interface{}
if err := json.Unmarshal(v, &decoded); err != nil {
return nil, err
}
return decoded, nil
default:
return v, nil
}
}
func apiObject(data interface{}) map[string]interface{} {
normalized, err := normalizeAPIData(data)
if err != nil {
return nil
}
switch v := normalized.(type) {
case map[string]interface{}:
return v
case []interface{}:
if len(v) == 1 {
if item, ok := v[0].(map[string]interface{}); ok {
return item
}
}
}
return nil
}
func apiList(data interface{}) []interface{} {
normalized, err := normalizeAPIData(data)
if err != nil {
return nil
}
switch v := normalized.(type) {
case []interface{}:
return v
case map[string]interface{}:
for _, key := range []string{"issues", "pulls", "pull_requests", "files", "commits", "releases", "builds", "items", "records", "data"} {
if raw, ok := v[key]; ok {
if items := apiList(raw); len(items) > 0 {
return items
}
}
}
if looksLikeIssueOrRepoItem(v) {
return []interface{}{v}
}
}
return nil
}
func looksLikeIssueOrRepoItem(v map[string]interface{}) bool {
_, hasTitle := v["title"]
_, hasSubject := v["subject"]
_, hasNumber := v["number"]
_, hasID := v["id"]
_, hasIID := v["iid"]
_, hasIssueNumber := v["issue_number"]
_, hasProjectIndex := v["project_issues_index"]
return hasTitle || hasSubject || hasNumber || hasID || hasIID || hasIssueNumber || hasProjectIndex
}
func apiString(v interface{}) string {
switch value := v.(type) {
case string:
return value
case fmt.Stringer:
return value.String()
case float64:
return trimTrailingZero(fmt.Sprintf("%f", value))
case float32:
return trimTrailingZero(fmt.Sprintf("%f", value))
case int:
return strconv.Itoa(value)
case int64:
return strconv.FormatInt(value, 10)
case int32:
return strconv.FormatInt(int64(value), 10)
case uint64:
return strconv.FormatUint(value, 10)
case uint32:
return strconv.FormatUint(uint64(value), 10)
case json.Number:
return value.String()
case bool:
return strconv.FormatBool(value)
default:
return ""
}
}
func trimTrailingZero(value string) string {
value = strings.TrimSuffix(value, "000000")
value = strings.TrimSuffix(value, ".000000")
value = strings.TrimSuffix(value, ".0")
value = strings.TrimSuffix(value, ".")
return value
}
func apiInt(v interface{}) int {
switch value := v.(type) {
case int:
return value
case int8:
return int(value)
case int16:
return int(value)
case int32:
return int(value)
case int64:
return int(value)
case uint:
return int(value)
case uint8:
return int(value)
case uint16:
return int(value)
case uint32:
return int(value)
case uint64:
return int(value)
case float32:
return int(value)
case float64:
return int(value)
case json.Number:
n, _ := value.Int64()
return int(n)
case string:
if value == "" {
return 0
}
if n, err := strconv.Atoi(value); err == nil {
return n
}
if n, err := strconv.ParseFloat(value, 64); err == nil {
return int(n)
}
}
return 0
}
func apiBool(v interface{}) bool {
switch value := v.(type) {
case bool:
return value
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && parsed
case float64:
return value != 0
case int:
return value != 0
case json.Number:
n, err := value.Int64()
return err == nil && n != 0
default:
return false
}
}
func apiTime(v interface{}) time.Time {
switch value := v.(type) {
case time.Time:
return value
case string:
return parseAPIStringTime(value)
case float64:
return parseAPINumericTime(int64(value))
case float32:
return parseAPINumericTime(int64(value))
case int:
return parseAPINumericTime(int64(value))
case int64:
return parseAPINumericTime(value)
case json.Number:
if n, err := value.Int64(); err == nil {
return parseAPINumericTime(n)
}
}
return time.Time{}
}
func parseAPIStringTime(value string) time.Time {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return time.Time{}
}
layouts := []string{
time.RFC3339,
time.RFC3339Nano,
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
"2006-01-02",
}
for _, layout := range layouts {
if parsed, err := time.Parse(layout, trimmed); err == nil {
return parsed
}
}
if n, err := strconv.ParseInt(trimmed, 10, 64); err == nil {
return parseAPINumericTime(n)
}
return time.Time{}
}
func parseAPINumericTime(n int64) time.Time {
if n <= 0 {
return time.Time{}
}
if n > 1_000_000_000_000 {
return time.Unix(0, n*int64(time.Millisecond))
}
return time.Unix(n, 0)
}
func apiStringSlice(v interface{}) []string {
switch value := v.(type) {
case nil:
return nil
case []string:
return append([]string(nil), value...)
case []interface{}:
out := make([]string, 0, len(value))
for _, item := range value {
if s := apiStringValue(item); s != "" {
out = append(out, s)
}
}
return out
case string:
if strings.TrimSpace(value) == "" {
return nil
}
parts := strings.Split(value, ",")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
return nil
}
func apiStringValue(v interface{}) string {
switch value := v.(type) {
case map[string]interface{}:
for _, key := range []string{"name", "title", "login", "label", "text"} {
if s := apiString(value[key]); s != "" {
return s
}
}
return ""
default:
return apiString(v)
}
}
func apiAuthor(v interface{}) string {
switch value := v.(type) {
case map[string]interface{}:
for _, key := range []string{"login", "name", "username", "full_name", "display_name"} {
if s := apiString(value[key]); s != "" {
return s
}
}
return ""
default:
return apiString(v)
}
}
func apiLatestTime(values ...time.Time) time.Time {
var latest time.Time
for _, value := range values {
if value.IsZero() {
continue
}
if latest.IsZero() || value.After(latest) {
latest = value
}
}
return latest
}
func apiAgeInDays(value time.Time) int {
if value.IsZero() {
return -1
}
return int(time.Since(value).Hours() / 24)
}

View File

@ -0,0 +1,301 @@
package workflow
import (
"fmt"
"net/url"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func FetchHealthInput(ctx *common.RuntimeContext, opts HealthFetchOptions) (HealthInput, []ScoringNote, error) {
owner, repo, err := resolveFetchRepo(ctx, opts.Owner, opts.Repo)
if err != nil {
return HealthInput{}, nil, err
}
input := HealthInput{
Repository: fmt.Sprintf("%s/%s", owner, repo),
}
notes := []ScoringNote{}
staleDays := opts.StaleDays
if staleDays <= 0 {
staleDays = 30
}
repoInfo, repoErr := fetchRepoInfo(ctx, owner, repo)
if repoErr != nil {
notes = append(notes, ScoringNote{Metric: "repository", Note: repoErr.Error()})
} else {
applyRepoSignals(&input, repoInfo)
}
if issues, err := fetchAllListItems(ctx, workflowRepoPath(owner, repo)+"/issues", issueListQuery("open"), 100); err != nil {
notes = append(notes, ScoringNote{Metric: "open_issues", Note: fmt.Sprintf("issue probe failed: %v", err)})
} else {
input.OpenIssues = len(issues)
input.StaleIssues = countStaleItems(issues, staleDays)
input.RecentActivityKnown, input.RecentActivityDays, input = updateRecentActivity(input, latestTimeFromItems(issues, input.RecentActivityDays))
}
if prs, err := fetchAllListItems(ctx, workflowRepoPath(owner, repo)+"/pulls", issueListQuery("open"), 100); err != nil {
notes = append(notes, ScoringNote{Metric: "open_prs", Note: fmt.Sprintf("pull request probe failed: %v", err)})
} else {
input.OpenPRs = len(prs)
input.StalePRs = countStaleItems(prs, staleDays)
input.RecentActivityKnown, input.RecentActivityDays, input = updateRecentActivity(input, latestTimeFromItems(prs, input.RecentActivityDays))
}
if opts.IncludeRelease {
if releases, err := fetchAllListItems(ctx, ctx.RepoPath()+"/releases", nil, 100); err != nil {
input.ReleaseKnown = false
notes = append(notes, ScoringNote{Metric: "release_status", Note: fmt.Sprintf("release probe failed: %v", err)})
} else {
input.ReleaseKnown = true
input.HasRecentRelease = len(releases) > 0
input.RecentActivityKnown, input.RecentActivityDays, input = updateRecentActivity(input, latestTimeFromItems(releases, input.RecentActivityDays))
}
}
if opts.IncludeCI {
if builds, err := fetchAllListItems(ctx, ctx.RepoPath()+"/builds", queryWithPageLimit(nil, 1, 20), 20); err != nil {
input.CIKnown = false
notes = append(notes, ScoringNote{Metric: "ci_status", Note: fmt.Sprintf("ci probe failed: %v", err)})
} else {
input.CIKnown = true
input.CIPassing = len(builds) > 0 && buildPassing(builds[0])
}
}
if opts.IncludeDocs {
applyDocSignals(&input, repoInfo, &notes)
}
applyAgentReadinessEstimate(&input)
if !input.RecentActivityKnown {
notes = append(notes, ScoringNote{Metric: "recent_activity", Note: "recent activity unavailable; scored conservatively"})
}
if !input.ReleaseKnown {
notes = append(notes, ScoringNote{Metric: "release_status", Note: "release status unavailable; scored conservatively"})
}
if !input.CIKnown {
notes = append(notes, ScoringNote{Metric: "ci_status", Note: "ci status unavailable; scored conservatively"})
}
return input, uniqueScoringNotes(notes), nil
}
func fetchRepoInfo(ctx *common.RuntimeContext, owner, repo string) (map[string]interface{}, error) {
env, err := ctx.CallAPI("GET", workflowRepoPath(owner, repo), nil)
if err != nil {
return nil, err
}
info := apiObject(env.Data)
if info == nil {
return nil, fmt.Errorf("repository response did not contain an object")
}
return info, nil
}
func applyRepoSignals(input *HealthInput, repoInfo map[string]interface{}) {
if repoInfo == nil {
return
}
if t := apiTime(repoInfo["updated_at"]); !t.IsZero() {
input.RecentActivityKnown = true
input.RecentActivityDays = apiAgeInDays(t)
}
applyDocSignals(input, repoInfo, nil)
}
func applyDocSignals(input *HealthInput, repoInfo map[string]interface{}, notes *[]ScoringNote) {
if repoInfo == nil {
return
}
hasReadme, readmeOK := repoInfo["has_readme"]
hasLicense, licenseOK := repoInfo["has_license"]
hasContributing, contribOK := repoInfo["has_contributing"]
if readmeOK {
input.HasReadme = apiBool(hasReadme)
} else if notes != nil {
*notes = append(*notes, ScoringNote{Metric: "documentation", Note: "README probe unavailable; scored conservatively"})
}
if licenseOK {
input.HasLicense = apiBool(hasLicense)
} else if notes != nil {
*notes = append(*notes, ScoringNote{Metric: "license_status", Note: "license probe unavailable; scored conservatively"})
}
if contribOK {
input.HasContributing = apiBool(hasContributing)
} else if notes != nil {
*notes = append(*notes, ScoringNote{Metric: "contribution_status", Note: "contributing probe unavailable; scored conservatively"})
}
}
func applyAgentReadinessEstimate(input *HealthInput) {
score := 4
if input.HasReadme {
score += 2
}
if input.HasLicense {
score += 2
}
if input.HasContributing {
score += 2
}
if input.RecentActivityKnown {
score++
}
if input.ReleaseKnown {
score++
}
input.AgentReadinessKnown = true
input.AgentReadinessScore = clampInt(score, 0, 10)
}
func countStaleItems(items []map[string]interface{}, staleDays int) int {
if staleDays <= 0 {
staleDays = 30
}
count := 0
for _, item := range items {
if apiAgeInDays(itemActivityTime(item)) >= staleDays {
count++
}
}
return count
}
func itemActivityTime(item map[string]interface{}) time.Time {
if item == nil {
return time.Time{}
}
return apiLatestTime(
apiTime(item["updated_at"]),
apiTime(item["updatedAt"]),
apiTime(item["created_at"]),
apiTime(item["last_updated_at"]),
apiTime(item["lastUpdatedAt"]),
apiTime(item["last_activity_at"]),
apiTime(item["lastActivityAt"]),
apiTime(item["merged_at"]),
apiTime(item["mergedAt"]),
apiTime(item["closed_at"]),
apiTime(item["closedAt"]),
)
}
func latestTimeFromItems(items []map[string]interface{}, currentDays int) time.Time {
latest := time.Time{}
for _, item := range items {
latest = apiLatestTime(latest, itemActivityTime(item))
}
return latest
}
func updateRecentActivity(input HealthInput, latest time.Time) (bool, int, HealthInput) {
if latest.IsZero() {
return input.RecentActivityKnown, input.RecentActivityDays, input
}
days := apiAgeInDays(latest)
if !input.RecentActivityKnown || days < input.RecentActivityDays || input.RecentActivityDays == 0 {
input.RecentActivityKnown = true
input.RecentActivityDays = days
}
return input.RecentActivityKnown, input.RecentActivityDays, input
}
func queryWithPageLimit(base url.Values, page, limit int) url.Values {
if base == nil {
base = url.Values{}
}
if page > 0 {
base.Set("page", fmt.Sprintf("%d", page))
}
if limit > 0 {
base.Set("limit", fmt.Sprintf("%d", limit))
}
return base
}
func issueListQuery(state string) url.Values {
q := url.Values{}
q.Set("state", state)
return q
}
func fetchAllListItems(ctx *common.RuntimeContext, path string, baseQuery url.Values, pageSize int) ([]map[string]interface{}, error) {
if pageSize <= 0 {
pageSize = 100
}
all := []map[string]interface{}{}
for page := 1; ; page++ {
query := cloneValues(baseQuery)
query.Set("page", fmt.Sprintf("%d", page))
query.Set("limit", fmt.Sprintf("%d", pageSize))
env, err := ctx.CallAPIWithQuery("GET", path, query)
if err != nil {
return nil, err
}
items := apiList(env.Data)
pageItems := make([]map[string]interface{}, 0, len(items))
for _, raw := range items {
if item, ok := raw.(map[string]interface{}); ok {
pageItems = append(pageItems, item)
}
}
if len(pageItems) == 0 {
break
}
all = append(all, pageItems...)
if len(pageItems) < pageSize {
break
}
}
return all, nil
}
func cloneValues(values url.Values) url.Values {
if values == nil {
return url.Values{}
}
out := url.Values{}
for key, list := range values {
out[key] = append([]string(nil), list...)
}
return out
}
func buildPassing(item map[string]interface{}) bool {
for _, key := range []string{"status", "state", "result", "conclusion", "status_text"} {
if value := strings.ToLower(strings.TrimSpace(apiString(item[key]))); value != "" {
switch value {
case "success", "passed", "pass", "ok", "done", "succeeded", "build passed":
return true
case "failed", "failure", "error", "canceled", "cancelled", "running", "pending":
return false
}
}
}
if apiBool(item["success"]) {
return true
}
return false
}
func uniqueScoringNotes(notes []ScoringNote) []ScoringNote {
seen := map[string]struct{}{}
out := make([]ScoringNote, 0, len(notes))
for _, note := range notes {
key := note.Metric + "|" + note.Note
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, note)
}
return out
}

View File

@ -0,0 +1,321 @@
package workflow
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestFetchHealthInputCollectsSignals(t *testing.T) {
now := time.Now().UTC()
old := now.AddDate(0, 0, -45)
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{}{
"name": "repo",
"updated_at": now.AddDate(0, 0, -2).Format(time.RFC3339),
"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{}{
{"id": 1, "subject": "fresh issue", "updated_at": now.AddDate(0, 0, -1).Format(time.RFC3339)},
{"id": 2, "subject": "stale issue", "updated_at": old.Format(time.RFC3339)},
}})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json":
writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{
{"id": 3, "title": "stale pr", "updated_at": old.Format(time.RFC3339)},
}})
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json":
writeWorkflowJSON(t, w, map[string]interface{}{"releases": []map[string]interface{}{
{"id": 4, "name": "v1.0.0", "created_at": now.AddDate(0, 0, -3).Format(time.RFC3339)},
}})
case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json":
writeWorkflowJSON(t, w, map[string]interface{}{"builds": []map[string]interface{}{
{"id": 5, "status": "success"},
}})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
input, notes, err := FetchHealthInput(workflowTestContext(server), HealthFetchOptions{
StaleDays: 30,
IncludeCI: true,
IncludeRelease: true,
IncludeDocs: true,
})
if err != nil {
t.Fatalf("FetchHealthInput returned error: %v", err)
}
if len(notes) != 0 {
t.Fatalf("notes = %v, want empty", notes)
}
if input.Repository != "owner/repo" {
t.Fatalf("Repository = %q, want owner/repo", input.Repository)
}
if input.OpenIssues != 2 || input.StaleIssues != 1 {
t.Fatalf("issues = open %d stale %d, want open 2 stale 1", input.OpenIssues, input.StaleIssues)
}
if input.OpenPRs != 1 || input.StalePRs != 1 {
t.Fatalf("prs = open %d stale %d, want open 1 stale 1", input.OpenPRs, input.StalePRs)
}
if !input.ReleaseKnown || !input.HasRecentRelease {
t.Fatalf("release signals = known %v recent %v, want true true", input.ReleaseKnown, input.HasRecentRelease)
}
if !input.CIKnown || !input.CIPassing {
t.Fatalf("ci signals = known %v passing %v, want true true", input.CIKnown, input.CIPassing)
}
if !input.HasReadme || !input.HasLicense || !input.HasContributing {
t.Fatalf("doc signals = readme %v license %v contributing %v, want all true", input.HasReadme, input.HasLicense, input.HasContributing)
}
if !input.RecentActivityKnown || input.RecentActivityDays > 3 {
t.Fatalf("recent activity = known %v days %d, want known and <= 3", input.RecentActivityKnown, input.RecentActivityDays)
}
}
func TestFetchHealthInputToleratesOptionalProbeFailures(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{}{
"name": "repo",
"has_readme": true,
"has_license": true,
})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{}})
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" || r.URL.Path == "/owner/repo/builds.json"):
http.Error(w, "temporary failure", http.StatusInternalServerError)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
input, notes, err := FetchHealthInput(workflowTestContext(server), HealthFetchOptions{
StaleDays: 30,
IncludeCI: true,
IncludeRelease: true,
IncludeDocs: true,
})
if err != nil {
t.Fatalf("FetchHealthInput returned error: %v", err)
}
if input.Repository != "owner/repo" {
t.Fatalf("Repository = %q, want owner/repo", input.Repository)
}
if input.ReleaseKnown {
t.Fatal("ReleaseKnown = true, want false after release probe failure")
}
if input.CIKnown {
t.Fatal("CIKnown = true, want false after CI probe failure")
}
if len(notes) == 0 {
t.Fatal("notes is empty, want scoring notes for failed optional probes")
}
}
func TestFetchHealthInputHandlesMissingRepoActivityAndDocGaps(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{}{
"name": "repo",
"has_readme": true,
"has_license": false,
})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{}})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json":
writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{}})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
input, notes, err := FetchHealthInput(workflowTestContext(server), HealthFetchOptions{
StaleDays: 30,
IncludeCI: false,
IncludeDocs: true,
})
if err != nil {
t.Fatalf("FetchHealthInput returned error: %v", err)
}
if input.RecentActivityKnown {
t.Fatal("RecentActivityKnown = true, want false when updated_at is missing and no activity lists carry timestamps")
}
if len(notes) == 0 {
t.Fatal("notes is empty, want scoring notes for missing repo signals")
}
}
func TestFetchHealthInputUsesAlternativeActivityFieldsAndDefaultStaleDays(t *testing.T) {
now := time.Now().UTC()
fresh := now.AddDate(0, 0, -2)
stale := now.AddDate(0, 0, -40)
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{}{"name": "repo"})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{
{"id": 1, "updatedAt": fresh.Format(time.RFC3339)},
{"id": 2, "last_activity_at": stale.Format(time.RFC3339)},
}})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json":
writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{
{"id": 3, "merged_at": fresh.Format(time.RFC3339)},
{"id": 4, "closed_at": stale.Format(time.RFC3339)},
}})
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{}{
{"id": 5, "status": "success"},
}})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
for _, staleDays := range []int{0, -5} {
t.Run("stale-days", func(t *testing.T) {
input, notes, err := FetchHealthInput(workflowTestContext(server), HealthFetchOptions{
StaleDays: staleDays,
IncludeRelease: true,
IncludeCI: true,
})
if err != nil {
t.Fatalf("FetchHealthInput returned error: %v", err)
}
if input.StaleIssues != 1 {
t.Fatalf("StaleIssues = %d, want 1", input.StaleIssues)
}
if input.StalePRs != 1 {
t.Fatalf("StalePRs = %d, want 1", input.StalePRs)
}
if !input.RecentActivityKnown {
t.Fatal("RecentActivityKnown = false, want true")
}
if input.RecentActivityDays > 3 {
t.Fatalf("RecentActivityDays = %d, want <= 3", input.RecentActivityDays)
}
if len(notes) != 0 {
t.Fatalf("notes = %v, want empty when release and CI probes succeed", notes)
}
})
}
}
func TestFetchHealthInputSupportsReleaseShapeVariants(t *testing.T) {
now := time.Now().UTC()
releasePayloads := []map[string]interface{}{
{"releases": []map[string]interface{}{{"id": 1, "name": "v1.0.0", "created_at": now.AddDate(0, 0, -1).Format(time.RFC3339)}}},
{"data": []map[string]interface{}{{"id": 2, "name": "v1.0.1", "updated_at": now.AddDate(0, 0, -1).Format(time.RFC3339)}}},
}
for i, payload := range releasePayloads {
t.Run("shape", func(t *testing.T) {
payload := payload
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{}{
"name": "repo",
"updated_at": now.Format(time.RFC3339),
"has_readme": true,
"has_license": true,
})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{}})
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, payload)
case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json":
writeWorkflowJSON(t, w, map[string]interface{}{"builds": []map[string]interface{}{
{"id": 3, "status": "success"},
}})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
input, notes, err := FetchHealthInput(workflowTestContext(server), HealthFetchOptions{
StaleDays: 30,
IncludeRelease: true,
IncludeCI: true,
IncludeDocs: false,
})
if err != nil {
t.Fatalf("FetchHealthInput returned error: %v", err)
}
if !input.ReleaseKnown || !input.HasRecentRelease {
t.Fatalf("release signals = known %v recent %v, want true true", input.ReleaseKnown, input.HasRecentRelease)
}
if input.RecentActivityDays > 1 {
t.Fatalf("RecentActivityDays = %d, want <= 1", input.RecentActivityDays)
}
if len(notes) != 0 {
t.Fatalf("notes = %v, want empty for supported release shape %d", notes, i)
}
})
}
}
func TestFetchHealthInputReportsCIUnavailable(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{}{
"name": "repo",
"has_readme": true,
"has_license": true,
})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{}})
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/builds.json":
http.Error(w, "not found", http.StatusNotFound)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
input, notes, err := FetchHealthInput(workflowTestContext(server), HealthFetchOptions{
StaleDays: 30,
IncludeCI: true,
IncludeRelease: false,
IncludeDocs: false,
})
if err != nil {
t.Fatalf("FetchHealthInput returned error: %v", err)
}
if input.CIKnown {
t.Fatal("CIKnown = true, want false for CI probe failure")
}
if len(notes) == 0 {
t.Fatal("notes is empty, want note for unavailable CI")
}
joined := ""
for _, note := range notes {
joined += note.Metric + " " + note.Note + "\n"
}
if !strings.Contains(joined, "ci_status") {
t.Fatalf("notes = %v, want ci_status note", notes)
}
}

View File

@ -0,0 +1,246 @@
package workflow
func ScoreHealth(input HealthInput, lang string) HealthResult {
lang = normalizeLang(lang)
metrics := []HealthMetric{}
notes := []ScoringNote{}
recommendations := []string{}
issueMetric := scoreIssueBacklog(input, lang)
metrics = append(metrics, issueMetric)
if issueMetric.Score < issueMetric.MaxScore {
recommendations = append(recommendations, message(lang, "rec_reduce_issues"))
}
prMetric := scorePRBacklog(input, lang)
metrics = append(metrics, prMetric)
if prMetric.Score < prMetric.MaxScore {
recommendations = append(recommendations, message(lang, "rec_reduce_prs"))
}
recentMetric, recentNote := scoreRecentActivity(input, lang)
metrics = append(metrics, recentMetric)
if recentNote.Note != "" {
notes = append(notes, recentNote)
}
if recentMetric.Score < recentMetric.MaxScore/2 {
recommendations = append(recommendations, message(lang, "rec_restore_activity"))
}
releaseMetric, releaseNote := scoreReleaseStatus(input, lang)
metrics = append(metrics, releaseMetric)
if releaseNote.Note != "" {
notes = append(notes, releaseNote)
}
if releaseMetric.Score < releaseMetric.MaxScore/2 {
recommendations = append(recommendations, message(lang, "rec_release"))
}
docMetric := scoreDocumentation(input, lang)
metrics = append(metrics, docMetric)
if docMetric.Score < docMetric.MaxScore {
recommendations = append(recommendations, message(lang, "rec_docs"))
}
licenseMetric := scoreLicenseAndContributing(input, lang)
metrics = append(metrics, licenseMetric)
if licenseMetric.Score < licenseMetric.MaxScore {
recommendations = append(recommendations, message(lang, "rec_license"))
}
agentMetric, agentNote := scoreAgentReadiness(input, lang)
metrics = append(metrics, agentMetric)
if agentNote.Note != "" {
notes = append(notes, agentNote)
}
if agentMetric.Score < agentMetric.MaxScore/2 {
recommendations = append(recommendations, message(lang, "rec_agent"))
}
ciMetric, ciNote := scoreCIStatus(input, lang)
metrics = append(metrics, ciMetric)
if ciNote.Note != "" {
notes = append(notes, ciNote)
}
total := 0
maxTotal := 0
for _, metric := range metrics {
total += metric.Score
maxTotal += metric.MaxScore
}
healthScore := 0
if maxTotal > 0 {
healthScore = clampInt(total*100/maxTotal, 0, 100)
}
if len(recommendations) == 0 {
recommendations = append(recommendations, message(lang, "rec_maintain"))
}
return HealthResult{
Repository: input.Repository,
HealthScore: healthScore,
RiskLevel: riskLevel(healthScore),
Metrics: metrics,
Recommendations: uniqueStrings(recommendations),
ScoringNotes: notes,
}
}
func scoreIssueBacklog(input HealthInput, lang string) HealthMetric {
score := 20
score -= minInt(input.StaleIssues*3, 14)
if input.OpenIssues > 50 {
score -= 8
} else if input.OpenIssues > 20 {
score -= 5
} else if input.OpenIssues > 10 {
score -= 2
}
score = clampInt(score, 0, 20)
status := "good"
reason := message(lang, "health_issue_backlog_good")
if score < 14 {
status = "attention"
reason = message(lang, "health_issue_backlog_attention")
}
return HealthMetric{Name: "issue_backlog_and_response", Status: status, Score: score, MaxScore: 20, Reason: reason}
}
func scorePRBacklog(input HealthInput, lang string) HealthMetric {
score := 20
score -= minInt(input.StalePRs*5, 15)
if input.OpenPRs > 20 {
score -= 8
} else if input.OpenPRs > 10 {
score -= 5
} else if input.OpenPRs > 5 {
score -= 2
}
score = clampInt(score, 0, 20)
status := "good"
reason := message(lang, "health_pr_backlog_good")
if score < 14 {
status = "attention"
reason = message(lang, "health_pr_backlog_attention")
}
return HealthMetric{Name: "pr_backlog_and_merge_state", Status: status, Score: score, MaxScore: 20, Reason: reason}
}
func scoreRecentActivity(input HealthInput, lang string) (HealthMetric, ScoringNote) {
if !input.RecentActivityKnown {
return HealthMetric{Name: "recent_activity", Status: "unknown", Score: 8, MaxScore: 15, Reason: message(lang, "health_recent_unknown")}, ScoringNote{Metric: "recent_activity", Note: message(lang, "health_recent_unknown")}
}
if input.RecentActivityDays <= 7 {
return HealthMetric{Name: "recent_activity", Status: "good", Score: 15, MaxScore: 15, Reason: "recent activity within 7 days"}, ScoringNote{}
}
if input.RecentActivityDays <= 30 {
return HealthMetric{Name: "recent_activity", Status: "attention", Score: 10, MaxScore: 15, Reason: "recent activity within 30 days"}, ScoringNote{}
}
if input.RecentActivityDays <= 90 {
return HealthMetric{Name: "recent_activity", Status: "attention", Score: 6, MaxScore: 15, Reason: "recent activity older than 30 days"}, ScoringNote{}
}
return HealthMetric{Name: "recent_activity", Status: "risk", Score: 2, MaxScore: 15, Reason: "recent activity older than 90 days"}, ScoringNote{}
}
func scoreReleaseStatus(input HealthInput, lang string) (HealthMetric, ScoringNote) {
if !input.ReleaseKnown {
return HealthMetric{Name: "release_status", Status: "unknown", Score: 8, MaxScore: 15, Reason: message(lang, "health_release_unknown")}, ScoringNote{Metric: "release_status", Note: message(lang, "health_release_unknown")}
}
if input.HasRecentRelease {
return HealthMetric{Name: "release_status", Status: "good", Score: 15, MaxScore: 15, Reason: "recent release found"}, ScoringNote{}
}
return HealthMetric{Name: "release_status", Status: "risk", Score: 4, MaxScore: 15, Reason: "no recent release found"}, ScoringNote{}
}
func scoreDocumentation(input HealthInput, lang string) HealthMetric {
score := 0
if input.HasReadme {
score += 7
}
if input.HasContributing {
score += 3
}
status := "attention"
reason := message(lang, "rec_docs")
if score == 10 {
status = "good"
reason = "README and contribution guidance are present"
}
return HealthMetric{Name: "documentation", Status: status, Score: score, MaxScore: 10, Reason: reason}
}
func scoreLicenseAndContributing(input HealthInput, lang string) HealthMetric {
score := 0
if input.HasLicense {
score += 6
}
if input.HasContributing {
score += 4
}
status := "attention"
reason := message(lang, "rec_license")
if score == 10 {
status = "good"
reason = "LICENSE and CONTRIBUTING are present"
}
return HealthMetric{Name: "license_and_contributing", Status: status, Score: score, MaxScore: 10, Reason: reason}
}
func scoreAgentReadiness(input HealthInput, lang string) (HealthMetric, ScoringNote) {
if !input.AgentReadinessKnown {
return HealthMetric{Name: "agent_readiness", Status: "unknown", Score: 5, MaxScore: 10, Reason: message(lang, "health_agent_unknown")}, ScoringNote{Metric: "agent_readiness", Note: message(lang, "health_agent_unknown")}
}
score := clampInt(input.AgentReadinessScore, 0, 10)
status := "attention"
if score >= 8 {
status = "good"
} else if score < 4 {
status = "risk"
}
return HealthMetric{Name: "agent_readiness", Status: status, Score: score, MaxScore: 10, Reason: "agent readiness score provided"}, ScoringNote{}
}
func scoreCIStatus(input HealthInput, lang string) (HealthMetric, ScoringNote) {
if !input.CIKnown {
return HealthMetric{Name: "ci_status", Status: "unknown", Score: 0, MaxScore: 0, Reason: message(lang, "health_ci_unknown")}, ScoringNote{Metric: "ci_status", Note: message(lang, "health_ci_unknown")}
}
if input.CIPassing {
return HealthMetric{Name: "ci_status", Status: "good", Score: 0, MaxScore: 0, Reason: "CI status is passing"}, ScoringNote{}
}
return HealthMetric{Name: "ci_status", Status: "risk", Score: 0, MaxScore: 0, Reason: "CI status is failing"}, ScoringNote{}
}
func riskLevel(score int) string {
switch {
case score >= 85:
return "low"
case score >= 65:
return "medium"
case score >= 40:
return "high"
default:
return "critical"
}
}
func clampInt(value int, minValue int, maxValue int) int {
if value < minValue {
return minValue
}
if value > maxValue {
return maxValue
}
return value
}
func minInt(a int, b int) int {
if a < b {
return a
}
return b
}

View File

@ -0,0 +1,113 @@
package workflow
import (
"strings"
"testing"
)
func TestScoreHealthLowRisk(t *testing.T) {
result := ScoreHealth(HealthInput{
Repository: "Gitlink/gitlink-cli",
OpenIssues: 4,
OpenPRs: 2,
StaleIssues: 1,
StalePRs: 0,
RecentActivityKnown: true,
RecentActivityDays: 3,
ReleaseKnown: true,
HasRecentRelease: true,
CIKnown: true,
CIPassing: true,
HasReadme: true,
HasLicense: true,
HasContributing: true,
AgentReadinessKnown: true,
AgentReadinessScore: 9,
}, "en")
if result.HealthScore < 85 {
t.Fatalf("HealthScore = %d, want >= 85", result.HealthScore)
}
if result.RiskLevel != "low" {
t.Fatalf("RiskLevel = %q, want low", result.RiskLevel)
}
}
func TestScoreHealthHighRisk(t *testing.T) {
result := ScoreHealth(HealthInput{
Repository: "Gitlink/gitlink-cli",
OpenIssues: 80,
OpenPRs: 25,
StaleIssues: 20,
StalePRs: 10,
RecentActivityKnown: true,
RecentActivityDays: 120,
ReleaseKnown: true,
HasRecentRelease: false,
CIKnown: true,
CIPassing: false,
HasReadme: false,
HasLicense: false,
HasContributing: false,
AgentReadinessKnown: true,
AgentReadinessScore: 2,
}, "en")
if result.HealthScore >= 65 {
t.Fatalf("HealthScore = %d, want < 65", result.HealthScore)
}
if result.RiskLevel != "high" && result.RiskLevel != "critical" {
t.Fatalf("RiskLevel = %q, want high or critical", result.RiskLevel)
}
if len(result.Recommendations) == 0 {
t.Fatal("Recommendations is empty")
}
}
func TestScoreHealthUnknownMetrics(t *testing.T) {
result := ScoreHealth(HealthInput{
Repository: "Gitlink/gitlink-cli",
RecentActivityKnown: false,
ReleaseKnown: false,
CIKnown: false,
HasReadme: true,
HasLicense: true,
HasContributing: false,
AgentReadinessKnown: false,
}, "en")
if len(result.ScoringNotes) == 0 {
t.Fatal("ScoringNotes is empty")
}
if result.HealthScore < 0 || result.HealthScore > 100 {
t.Fatalf("HealthScore = %d, want between 0 and 100", result.HealthScore)
}
}
func TestScoreHealthChinese(t *testing.T) {
result := ScoreHealth(HealthInput{
Repository: "Gitlink/gitlink-cli",
OpenIssues: 40,
OpenPRs: 12,
StaleIssues: 8,
StalePRs: 4,
RecentActivityKnown: true,
RecentActivityDays: 45,
ReleaseKnown: true,
HasRecentRelease: false,
CIKnown: false,
HasReadme: false,
HasLicense: false,
HasContributing: false,
AgentReadinessKnown: true,
AgentReadinessScore: 3,
}, "zh-CN")
if len(result.Recommendations) == 0 {
t.Fatal("Recommendations is empty")
}
joined := strings.Join(result.Recommendations, "")
if !strings.Contains(joined, "建议") && !strings.Contains(joined, "补充") && !strings.Contains(joined, "减少") {
t.Fatalf("Recommendations = %v, want Chinese content", result.Recommendations)
}
}

View File

@ -0,0 +1,102 @@
package workflow
func normalizeLang(lang string) string {
switch lang {
case "", langEN:
return langEN
case langZH:
return langZH
default:
return langEN
}
}
func message(lang string, key string) string {
lang = normalizeLang(lang)
if value, ok := messages[lang][key]; ok {
return value
}
if value, ok := messages[langEN][key]; ok {
return value
}
return key
}
var messages = map[string]map[string]string{
langEN: {
"missing_reproduction_steps": "reproduction_steps",
"missing_expected_behavior": "expected_behavior",
"missing_actual_behavior": "actual_behavior",
"missing_version": "version",
"missing_os_or_platform": "os_or_platform",
"missing_command_output_or_logs": "command_output_or_logs",
"comment_more_info": "Thanks for the report. Please add the missing information so maintainers can reproduce and investigate it: %s.",
"comment_security": "Thanks for the security report. Please avoid sharing secrets publicly and rotate any exposed credentials. Maintainers should verify the sensitive details in a private channel.",
"comment_docs": "Thanks for the documentation report. Please point to the affected document or example if possible.",
"comment_default": "Thanks for the report. Maintainers can use the triage result above to decide the next step.",
"health_issue_backlog_good": "Issue backlog is under control.",
"health_issue_backlog_attention": "Reduce stale or excessive open issues.",
"health_pr_backlog_good": "Pull request backlog is under control.",
"health_pr_backlog_attention": "Review stale or excessive open pull requests.",
"health_recent_unknown": "Recent activity is unknown and was scored conservatively.",
"health_release_unknown": "Release status is unknown and was scored conservatively.",
"health_ci_unknown": "CI status is unknown and is reported without changing the score.",
"health_agent_unknown": "Agent readiness is unknown and was scored conservatively.",
"rec_maintain": "Maintain the current workflow and keep metadata up to date.",
"rec_reduce_issues": "Reduce stale issues and add response labels or next actions.",
"rec_reduce_prs": "Review stale pull requests and clarify merge blockers.",
"rec_restore_activity": "Create recent maintenance activity or document project status.",
"rec_release": "Publish or document a recent release cadence.",
"rec_docs": "Add or improve README and contribution guidance.",
"rec_license": "Add LICENSE and CONTRIBUTING files for contributor clarity.",
"rec_agent": "Improve agent readiness with stable docs, examples, and machine-readable outputs.",
"pr_summary_title": "PR Review Summary",
"pr_summary_overview": "Overview",
"pr_summary_review_focus": "Review Focus",
"pr_summary_test_suggestions": "Test Suggestions",
"pr_summary_merge_checklist": "Merge Checklist",
"pr_summary_reasoning": "Reasoning",
"pr_summary_no_focus": "No specific review focus identified.",
"pr_summary_no_suggestions": "No extra test suggestions.",
"pr_summary_no_checklist": "No extra merge checklist items.",
"pr_summary_no_reasoning": "No additional reasoning.",
},
langZH: {
"missing_reproduction_steps": "复现步骤",
"missing_expected_behavior": "期望行为",
"missing_actual_behavior": "实际行为",
"missing_version": "版本信息",
"missing_os_or_platform": "操作系统或平台",
"missing_command_output_or_logs": "命令输出或日志",
"comment_more_info": "感谢反馈。请补充以下信息,方便维护者复现和定位问题:%s。",
"comment_security": "感谢安全反馈。请不要公开扩散密钥或敏感信息,并尽快轮换可能泄露的凭据。维护者应优先通过私密渠道确认细节。",
"comment_docs": "感谢文档反馈。请尽量说明受影响的文档、示例或章节位置。",
"comment_default": "感谢反馈。维护者可以根据上面的分诊结果安排下一步处理。",
"health_issue_backlog_good": "Issue 积压处于可控状态。",
"health_issue_backlog_attention": "建议减少长期未处理或数量过多的开放 Issue。",
"health_pr_backlog_good": "PR 积压处于可控状态。",
"health_pr_backlog_attention": "建议审查长期未处理或数量过多的开放 PR。",
"health_recent_unknown": "最近活跃度未知,已按保守方式评分。",
"health_release_unknown": "Release 状态未知,已按保守方式评分。",
"health_ci_unknown": "CI 状态未知,仅记录为说明,不影响总分。",
"health_agent_unknown": "Agent 友好度未知,已按保守方式评分。",
"rec_maintain": "保持当前维护节奏,并持续更新仓库元信息。",
"rec_reduce_issues": "减少长期未处理的 Issue并补充响应标签或下一步动作。",
"rec_reduce_prs": "审查长期未处理的 PR并明确合并阻塞点。",
"rec_restore_activity": "恢复近期维护活动,或在文档中说明项目状态。",
"rec_release": "发布近期版本,或在文档中说明发布节奏。",
"rec_docs": "补充或改进 README 与贡献指南。",
"rec_license": "补充 LICENSE 和 CONTRIBUTING降低贡献者理解成本。",
"rec_agent": "通过稳定文档、示例和机器可读输出提升 Agent 友好度。",
"pr_summary_title": "PR 审阅摘要",
"pr_summary_overview": "概览",
"pr_summary_review_focus": "审查重点",
"pr_summary_test_suggestions": "测试建议",
"pr_summary_merge_checklist": "合并检查清单",
"pr_summary_reasoning": "判断依据",
"pr_summary_no_focus": "未识别到明确审查重点。",
"pr_summary_no_suggestions": "暂无额外测试建议。",
"pr_summary_no_checklist": "暂无额外合并检查项。",
"pr_summary_no_reasoning": "暂无额外判断依据。",
},
}

View File

@ -0,0 +1,24 @@
package workflow
import "testing"
func TestNormalizeLang(t *testing.T) {
if got := normalizeLang(""); got != langEN {
t.Fatalf("normalizeLang(\"\") = %q, want %q", got, langEN)
}
if got := normalizeLang(langZH); got != langZH {
t.Fatalf("normalizeLang(%q) = %q, want %q", langZH, got, langZH)
}
if got := normalizeLang("fr"); got != langEN {
t.Fatalf("normalizeLang(\"fr\") = %q, want %q", got, langEN)
}
}
func TestMessageFallback(t *testing.T) {
if got := message("fr", "rec_maintain"); got == "" || got == "rec_maintain" {
t.Fatalf("message fallback = %q, want English message", got)
}
if got := message(langEN, "not_found_key"); got != "not_found_key" {
t.Fatalf("message unknown key = %q, want key", got)
}
}

View File

@ -0,0 +1,333 @@
package workflow
import (
"fmt"
"net/url"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
type PRFetchOptions struct {
Owner string
Repo string
Number int
IncludeFiles bool
IncludeCommits bool
MaxFiles int
MaxCommits int
}
func FetchPRSummaryInput(ctx *common.RuntimeContext, opts PRFetchOptions) (PRSummaryInput, []ScoringNote, error) {
owner, repo, err := resolveFetchRepo(ctx, opts.Owner, opts.Repo)
if err != nil {
return PRSummaryInput{}, nil, err
}
if opts.Number <= 0 {
return PRSummaryInput{}, nil, fmt.Errorf("pull request number is required")
}
if opts.MaxFiles <= 0 {
opts.MaxFiles = 100
}
if opts.MaxCommits <= 0 {
opts.MaxCommits = 100
}
input, err := fetchPRBase(ctx, owner, repo, opts.Number)
if err != nil {
return PRSummaryInput{}, nil, err
}
input.Repository = fmt.Sprintf("%s/%s", owner, repo)
input.Number = opts.Number
input.Source = "remote-read-only-fetch"
notes := []ScoringNote{}
if opts.IncludeFiles {
files, err := fetchPRFiles(ctx, owner, repo, opts.Number, opts.MaxFiles)
if err != nil {
notes = append(notes, ScoringNote{Metric: "pr_files", Note: fmt.Sprintf("changed files probe failed: %v", err)})
} else {
input.ChangedFiles = files
fillPRLineTotals(&input)
}
}
if opts.IncludeCommits {
commits, err := fetchPRCommits(ctx, owner, repo, opts.Number, opts.MaxCommits)
if err != nil {
notes = append(notes, ScoringNote{Metric: "pr_commits", Note: fmt.Sprintf("commits probe failed: %v", err)})
} else {
input.Commits = commits
}
}
return input, notes, nil
}
func fetchPRBase(ctx *common.RuntimeContext, owner, repo string, number int) (PRSummaryInput, error) {
env, err := ctx.CallAPI("GET", prPath(owner, repo, number), nil)
if err != nil {
return PRSummaryInput{}, fmt.Errorf("fetch pull request summary: %w", err)
}
item := prAPIObject(env.Data)
if item == nil {
return PRSummaryInput{}, fmt.Errorf("fetch pull request summary: PR response did not contain an object")
}
input, ok := normalizePRSummaryItem(item)
if !ok {
return PRSummaryInput{}, fmt.Errorf("fetch pull request summary: PR response missing title or number")
}
return input, nil
}
func fetchPRFiles(ctx *common.RuntimeContext, owner, repo string, number, limit int) ([]PRChangedFile, error) {
query := url.Values{}
query.Set("page", "1")
query.Set("limit", fmt.Sprintf("%d", limit))
env, err := ctx.CallAPIWithQuery("GET", prPath(owner, repo, number)+"/files", query)
if err != nil {
return nil, err
}
rawItems := apiList(env.Data)
files := make([]PRChangedFile, 0, len(rawItems))
for _, raw := range rawItems {
file, ok := normalizePRChangedFile(raw)
if !ok {
continue
}
files = append(files, file)
if len(files) >= limit {
break
}
}
return files, nil
}
func fetchPRCommits(ctx *common.RuntimeContext, owner, repo string, number, limit int) ([]PRCommit, error) {
query := url.Values{}
query.Set("page", "1")
query.Set("limit", fmt.Sprintf("%d", limit))
env, err := ctx.CallAPIWithQuery("GET", prPath(owner, repo, number)+"/commits", query)
if err != nil {
return nil, err
}
rawItems := apiList(env.Data)
commits := make([]PRCommit, 0, len(rawItems))
for _, raw := range rawItems {
commit, ok := normalizePRCommit(raw)
if !ok {
continue
}
commits = append(commits, commit)
if len(commits) >= limit {
break
}
}
return commits, nil
}
func prPath(owner, repo string, number int) string {
return fmt.Sprintf("%s/pulls/%d", workflowRepoPath(owner, repo), number)
}
func prAPIObject(data interface{}) map[string]interface{} {
normalized, err := normalizeAPIData(data)
if err != nil {
return nil
}
switch value := normalized.(type) {
case map[string]interface{}:
for _, key := range []string{"pull_request", "data", "pr"} {
if raw, ok := value[key]; ok {
if item := prAPIObject(raw); item != nil {
return item
}
}
}
return value
case []interface{}:
if len(value) == 1 {
if item, ok := value[0].(map[string]interface{}); ok {
return item
}
}
}
return nil
}
func normalizePRSummaryItem(item map[string]interface{}) (PRSummaryInput, bool) {
number := firstPRInt(item, "number", "iid", "pull_request_number")
title := firstPRString(item, "title", "subject")
if number == 0 && strings.TrimSpace(title) == "" {
return PRSummaryInput{}, false
}
body := firstPRString(item, "body", "description", "content")
state := firstPRString(item, "state", "status")
author := firstPRAuthor(item)
base := firstPRBranch(item, "base_branch", "target_branch", "base")
head := firstPRBranch(item, "head_branch", "source_branch", "head")
additions := firstPRInt(item, "additions", "additions_count")
deletions := firstPRInt(item, "deletions", "deletions_count")
return PRSummaryInput{
Number: number,
Title: title,
Author: author,
State: state,
BaseBranch: base,
HeadBranch: head,
Body: body,
Additions: additions,
Deletions: deletions,
}, true
}
func normalizePRChangedFile(raw interface{}) (PRChangedFile, bool) {
item, ok := raw.(map[string]interface{})
if !ok {
return PRChangedFile{}, false
}
filename := firstPRString(item, "filename", "file", "path", "new_path")
if strings.TrimSpace(filename) == "" {
return PRChangedFile{}, false
}
additions := firstPRInt(item, "additions", "additions_count")
deletions := firstPRInt(item, "deletions", "deletions_count")
changes := firstPRInt(item, "changes", "total_changes")
if changes == 0 {
changes = additions + deletions
}
return PRChangedFile{
Filename: filename,
Status: firstPRString(item, "status", "state"),
Additions: additions,
Deletions: deletions,
Changes: changes,
Patch: firstPRString(item, "patch", "diff"),
}, true
}
func normalizePRCommit(raw interface{}) (PRCommit, bool) {
item, ok := raw.(map[string]interface{})
if !ok {
return PRCommit{}, false
}
sha := firstPRString(item, "sha", "id")
message := firstPRString(item, "message", "title")
if strings.TrimSpace(sha) == "" && strings.TrimSpace(message) == "" {
return PRCommit{}, false
}
return PRCommit{
SHA: sha,
Message: firstLine(message),
Author: firstPRCommitAuthor(item),
Date: firstPRTime(item, "date", "committed_at", "created_at"),
}, true
}
func fillPRLineTotals(input *PRSummaryInput) {
if input == nil || len(input.ChangedFiles) == 0 {
return
}
additions := 0
deletions := 0
for _, file := range input.ChangedFiles {
additions += file.Additions
deletions += file.Deletions
}
if input.Additions == 0 {
input.Additions = additions
}
if input.Deletions == 0 {
input.Deletions = deletions
}
}
func firstPRString(item map[string]interface{}, keys ...string) string {
for _, key := range keys {
if value, ok := item[key]; ok {
if s := apiString(value); s != "" {
return s
}
}
}
return ""
}
func firstPRInt(item map[string]interface{}, keys ...string) int {
for _, key := range keys {
if value, ok := item[key]; ok {
if n := apiInt(value); n != 0 {
return n
}
}
}
return 0
}
func firstPRTime(item map[string]interface{}, keys ...string) time.Time {
for _, key := range keys {
if value, ok := item[key]; ok {
if t := apiTime(value); !t.IsZero() {
return t
}
}
}
return time.Time{}
}
func firstPRAuthor(item map[string]interface{}) string {
for _, key := range []string{"author", "user", "creator"} {
if value, ok := item[key]; ok {
if s := apiAuthor(value); s != "" {
return s
}
}
}
return ""
}
func firstPRCommitAuthor(item map[string]interface{}) string {
for _, key := range []string{"author", "committer", "user", "creator"} {
if value, ok := item[key]; ok {
if s := apiAuthor(value); s != "" {
return s
}
}
}
return ""
}
func firstPRBranch(item map[string]interface{}, keys ...string) string {
for _, key := range keys {
if value, ok := item[key]; ok {
if s := prBranchString(value); s != "" {
return s
}
}
}
return ""
}
func prBranchString(value interface{}) string {
switch typed := value.(type) {
case map[string]interface{}:
for _, key := range []string{"ref", "name", "branch", "title"} {
if s := apiString(typed[key]); s != "" {
return s
}
}
return ""
default:
return apiString(value)
}
}
func firstLine(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
lines := strings.Split(value, "\n")
return strings.TrimSpace(lines[0])
}

View File

@ -0,0 +1,216 @@
package workflow
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestFetchPRSummaryInputNormalizesResponseShapes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/13.json":
writeWorkflowJSON(t, w, map[string]interface{}{
"data": map[string]interface{}{
"pull_request_number": 13,
"title": "feat: add workflow PR summary",
"description": "Summarize pull requests without writing remote data.",
"status": "open",
"creator": map[string]interface{}{"name": "carol"},
"target_branch": "master",
"source_branch": "feature/pr-summary",
"additions": 100,
"deletions": 4,
},
})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/13/files.json":
if got := r.URL.Query().Get("limit"); got != "100" {
t.Fatalf("files limit = %q, want 100", got)
}
writeWorkflowJSON(t, w, map[string]interface{}{
"files": []map[string]interface{}{
{"new_path": "shortcuts/workflow/pr_summary.go", "status": "added", "additions": 80, "deletions": 0},
{"filename": "docs/workflow-agent-design.md", "status": "modified", "additions": 20, "deletions": 4},
},
})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/13/commits.json":
writeWorkflowJSON(t, w, []map[string]interface{}{
{"id": "abc123", "title": "feat: add workflow PR summary", "committer": map[string]interface{}{"login": "carol"}, "created_at": "2026-05-20T10:00:00Z"},
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
input, notes, err := FetchPRSummaryInput(workflowTestContext(server), PRFetchOptions{
Number: 13,
IncludeFiles: true,
IncludeCommits: true,
MaxFiles: 100,
MaxCommits: 100,
})
if err != nil {
t.Fatalf("FetchPRSummaryInput returned error: %v", err)
}
if len(notes) != 0 {
t.Fatalf("notes = %v, want empty", notes)
}
if input.Repository != "owner/repo" || input.Number != 13 {
t.Fatalf("input = %+v, want owner/repo #13", input)
}
if input.Author != "carol" || input.BaseBranch != "master" || input.HeadBranch != "feature/pr-summary" {
t.Fatalf("author/branches = %q %q %q, want carol master feature/pr-summary", input.Author, input.BaseBranch, input.HeadBranch)
}
if len(input.ChangedFiles) != 2 {
t.Fatalf("len(ChangedFiles) = %d, want 2", len(input.ChangedFiles))
}
if len(input.Commits) != 1 || input.Commits[0].SHA != "abc123" || input.Commits[0].Author != "carol" {
t.Fatalf("Commits = %+v, want normalized commit", input.Commits)
}
}
func TestFetchPRSummaryInputPartialFilesFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/2.json":
writeWorkflowJSON(t, w, map[string]interface{}{
"pull_request": map[string]interface{}{
"number": 2,
"title": "fix: handle API errors",
"user": map[string]interface{}{"login": "bob"},
},
})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/2/files.json":
http.Error(w, "files unavailable", http.StatusInternalServerError)
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/2/commits.json":
writeWorkflowJSON(t, w, []map[string]interface{}{{"sha": "abc", "message": "fix: handle API errors"}})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
input, notes, err := FetchPRSummaryInput(workflowTestContext(server), PRFetchOptions{
Number: 2,
IncludeFiles: true,
IncludeCommits: true,
MaxFiles: 10,
MaxCommits: 10,
})
if err != nil {
t.Fatalf("FetchPRSummaryInput returned error: %v", err)
}
if input.Title != "fix: handle API errors" {
t.Fatalf("Title = %q, want base PR to remain", input.Title)
}
if len(notes) == 0 || !strings.Contains(notes[0].Metric, "pr_files") {
t.Fatalf("notes = %v, want pr_files note", notes)
}
}
func TestFetchPRSummaryInputPartialCommitsFailure(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/3.json":
writeWorkflowJSON(t, w, map[string]interface{}{
"number": 3,
"title": "docs: update README",
})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/3/files.json":
writeWorkflowJSON(t, w, map[string]interface{}{"files": []map[string]interface{}{{"filename": "README.md"}}})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/3/commits.json":
http.Error(w, "commits unavailable", http.StatusServiceUnavailable)
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
input, notes, err := FetchPRSummaryInput(workflowTestContext(server), PRFetchOptions{
Number: 3,
IncludeFiles: true,
IncludeCommits: true,
MaxFiles: 10,
MaxCommits: 10,
})
if err != nil {
t.Fatalf("FetchPRSummaryInput returned error: %v", err)
}
if len(input.ChangedFiles) != 1 {
t.Fatalf("len(ChangedFiles) = %d, want 1", len(input.ChangedFiles))
}
if len(notes) == 0 || !strings.Contains(notes[0].Metric, "pr_commits") {
t.Fatalf("notes = %v, want pr_commits note", notes)
}
}
func TestFetchPRSummaryInputReportsErrorInBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeWorkflowJSON(t, w, map[string]interface{}{
"status": 403,
"message": "permission denied",
})
}))
defer server.Close()
_, _, err := FetchPRSummaryInput(workflowTestContext(server), PRFetchOptions{Number: 9, IncludeFiles: true})
if err == nil {
t.Fatal("FetchPRSummaryInput returned nil error for error-in-body")
}
if !strings.Contains(err.Error(), "permission denied") {
t.Fatalf("error = %v, want permission denied", err)
}
}
func TestFetchPRSummaryInputRespectsLimits(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/4.json":
writeWorkflowJSON(t, w, map[string]interface{}{
"data": map[string]interface{}{"number": 4, "title": "feat: limit lists"},
})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/4/files.json":
if got := r.URL.Query().Get("limit"); got != "1" {
t.Fatalf("files limit = %q, want 1", got)
}
writeWorkflowJSON(t, w, map[string]interface{}{
"files": []map[string]interface{}{
{"filename": "one.go"},
{"filename": "two.go"},
},
})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/4/commits.json":
if got := r.URL.Query().Get("limit"); got != "1" {
t.Fatalf("commits limit = %q, want 1", got)
}
writeWorkflowJSON(t, w, map[string]interface{}{
"commits": []map[string]interface{}{
{"sha": "one", "message": "feat: first"},
{"sha": "two", "message": "feat: second"},
},
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
input, notes, err := FetchPRSummaryInput(workflowTestContext(server), PRFetchOptions{
Number: 4,
IncludeFiles: true,
IncludeCommits: true,
MaxFiles: 1,
MaxCommits: 1,
})
if err != nil {
t.Fatalf("FetchPRSummaryInput returned error: %v", err)
}
if len(notes) != 0 {
t.Fatalf("notes = %v, want empty", notes)
}
if len(input.ChangedFiles) != 1 || len(input.Commits) != 1 {
t.Fatalf("files/commits lengths = %d/%d, want 1/1", len(input.ChangedFiles), len(input.Commits))
}
}

View File

@ -0,0 +1,676 @@
package workflow
import (
"encoding/json"
"fmt"
"os"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
const (
PRChangeTypeDocs = "docs"
PRChangeTypeTest = "test"
PRChangeTypeFeature = "feature"
PRChangeTypeFix = "fix"
PRChangeTypeRefactor = "refactor"
PRChangeTypeCI = "ci"
PRChangeTypeMixed = "mixed"
PRChangeTypeUnknown = "unknown"
)
const (
PRRiskLow = "low"
PRRiskMedium = "medium"
PRRiskHigh = "high"
PRRiskCritical = "critical"
)
type PRSummaryInput struct {
Repository string `json:"repository"`
Number int `json:"number"`
Title string `json:"title"`
Author string `json:"author"`
State string `json:"state"`
BaseBranch string `json:"base_branch"`
HeadBranch string `json:"head_branch"`
Body string `json:"body,omitempty"`
ChangedFiles []PRChangedFile `json:"changed_files"`
Commits []PRCommit `json:"commits"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
Source string `json:"source"`
}
type PRChangedFile struct {
Filename string `json:"filename"`
Status string `json:"status"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
Changes int `json:"changes"`
Patch string `json:"patch,omitempty"`
}
type PRCommit struct {
SHA string `json:"sha"`
Message string `json:"message"`
Author string `json:"author"`
Date time.Time `json:"date,omitempty"`
}
type PRSummaryResult struct {
Repository string `json:"repository"`
Number int `json:"number"`
Title string `json:"title"`
Author string `json:"author"`
State string `json:"state"`
BaseBranch string `json:"base_branch"`
HeadBranch string `json:"head_branch"`
ChangedFilesCount int `json:"changed_files_count"`
Additions int `json:"additions"`
Deletions int `json:"deletions"`
CommitCount int `json:"commit_count"`
ChangeType string `json:"change_type"`
RiskLevel string `json:"risk_level"`
ReviewFocus []string `json:"review_focus"`
TestSuggestions []string `json:"test_suggestions"`
MergeChecklist []string `json:"merge_checklist"`
Reasoning []string `json:"reasoning"`
Source string `json:"source"`
}
func newPRSummaryShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "pr-summary",
Description: "Generate a read-only pull request review summary",
Flags: []common.Flag{
{Name: "from", Usage: "Read PR summary input from a JSON file"},
{Name: "number", Short: "n", Usage: "Pull request number for remote read-only analysis"},
{Name: "include-commits", Usage: "Fetch commits in remote mode", Bool: true, Default: "true"},
{Name: "include-files", Usage: "Fetch changed files in remote mode", Bool: true, Default: "true"},
{Name: "max-files", Usage: "Maximum changed files to analyze", Default: "100"},
{Name: "max-commits", Usage: "Maximum commits to analyze", Default: "100"},
{Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN},
},
Run: runPRSummary,
}
}
func runPRSummary(ctx *common.RuntimeContext) error {
lang := normalizeLang(ctx.Arg("lang"))
input, notes, err := collectPRSummaryInput(ctx)
if err != nil {
return err
}
result := AnalyzePRSummary(input, lang)
for _, note := range notes {
if note.Metric == "" && note.Note == "" {
continue
}
result.Reasoning = append(result.Reasoning, fmt.Sprintf("%s: %s", note.Metric, note.Note))
}
format := ctx.Format
if strings.TrimSpace(cmdutil.Format) == "" {
format = "table"
}
rendered, err := RenderPRSummary(result, format, lang)
if err != nil {
return err
}
_, err = fmt.Fprint(os.Stdout, rendered)
return err
}
func collectPRSummaryInput(ctx *common.RuntimeContext) (PRSummaryInput, []ScoringNote, error) {
if path := strings.TrimSpace(ctx.Arg("from")); path != "" {
input, err := readPRSummaryInput(path)
if err != nil {
return PRSummaryInput{}, nil, err
}
if strings.TrimSpace(input.Source) == "" {
input.Source = "local-json"
}
return input, nil, nil
}
number, err := parseIntArg(ctx.Arg("number"), 0, "number")
if err != nil {
return PRSummaryInput{}, nil, err
}
if number <= 0 {
return PRSummaryInput{}, nil, fmt.Errorf("workflow +pr-summary requires --from pr_summary.json or --number with --owner and --repo for read-only fetch")
}
maxFiles, err := parseIntArg(ctx.Arg("max-files"), 100, "max-files")
if err != nil {
return PRSummaryInput{}, nil, err
}
maxCommits, err := parseIntArg(ctx.Arg("max-commits"), 100, "max-commits")
if err != nil {
return PRSummaryInput{}, nil, err
}
return FetchPRSummaryInput(ctx, PRFetchOptions{
Number: number,
IncludeFiles: parseBoolArg(ctx.Arg("include-files")),
IncludeCommits: parseBoolArg(ctx.Arg("include-commits")),
MaxFiles: maxFiles,
MaxCommits: maxCommits,
})
}
func readPRSummaryInput(path string) (PRSummaryInput, error) {
data, err := os.ReadFile(path)
if err != nil {
return PRSummaryInput{}, fmt.Errorf("read PR summary input: %w", err)
}
var input PRSummaryInput
if err := json.Unmarshal(data, &input); err != nil {
return PRSummaryInput{}, fmt.Errorf("parse PR summary input: %w", err)
}
if strings.TrimSpace(input.Title) == "" && input.Number == 0 {
return PRSummaryInput{}, fmt.Errorf("parse PR summary input: expected PRSummaryInput root object")
}
return input, nil
}
func AnalyzePRSummary(input PRSummaryInput, lang string) PRSummaryResult {
lang = normalizeLang(lang)
scores, scoreReasons := scorePRChangeTypes(input)
changeType := choosePRChangeType(scores)
riskLevel, riskReasons := scorePRRisk(input, changeType)
reviewFocus := buildPRReviewFocus(input, lang, riskLevel)
testSuggestions := buildPRTestSuggestions(input, lang)
mergeChecklist := buildPRMergeChecklist(input, lang, riskLevel)
reasoning := buildPRReasoning(lang, changeType, riskLevel, scores, scoreReasons, riskReasons)
source := strings.TrimSpace(input.Source)
if source == "" {
source = "local"
}
return PRSummaryResult{
Repository: input.Repository,
Number: input.Number,
Title: input.Title,
Author: input.Author,
State: input.State,
BaseBranch: input.BaseBranch,
HeadBranch: input.HeadBranch,
ChangedFilesCount: len(input.ChangedFiles),
Additions: input.Additions,
Deletions: input.Deletions,
CommitCount: len(input.Commits),
ChangeType: changeType,
RiskLevel: riskLevel,
ReviewFocus: reviewFocus,
TestSuggestions: testSuggestions,
MergeChecklist: mergeChecklist,
Reasoning: reasoning,
Source: source,
}
}
func scorePRChangeTypes(input PRSummaryInput) (map[string]int, []string) {
scores := map[string]int{
PRChangeTypeDocs: 0,
PRChangeTypeTest: 0,
PRChangeTypeFeature: 0,
PRChangeTypeFix: 0,
PRChangeTypeRefactor: 0,
PRChangeTypeCI: 0,
}
reasons := []string{}
for _, file := range input.ChangedFiles {
path := normalizedPath(file.Filename)
switch {
case isDocsPath(path):
scores[PRChangeTypeDocs] += 2
reasons = append(reasons, "file:docs")
case isTestPath(path):
scores[PRChangeTypeTest] += 2
reasons = append(reasons, "file:test")
case isCIPath(path):
scores[PRChangeTypeCI] += 2
reasons = append(reasons, "file:ci")
}
}
text := strings.ToLower(prTextCorpus(input, false))
addKeywordScore(scores, &reasons, text, PRChangeTypeDocs, []string{"doc", "docs", "documentation", "readme", "typo", "example", "guide"})
addKeywordScore(scores, &reasons, text, PRChangeTypeTest, []string{"test", "tests", "coverage"})
addKeywordScore(scores, &reasons, text, PRChangeTypeFeature, []string{"feat", "feature", "add", "support", "implement"})
addKeywordScore(scores, &reasons, text, PRChangeTypeFix, []string{"fix", "bug", "error", "crash", "resolve"})
addKeywordScore(scores, &reasons, text, PRChangeTypeRefactor, []string{"refactor", "cleanup", "simplify", "restructure"})
addKeywordScore(scores, &reasons, text, PRChangeTypeCI, []string{"ci", "workflow", "action", "build", "pipeline"})
return scores, uniqueStrings(reasons)
}
func choosePRChangeType(scores map[string]int) string {
topType := PRChangeTypeUnknown
topScore := 0
secondScore := 0
hits := []string{}
for _, kind := range []string{PRChangeTypeDocs, PRChangeTypeTest, PRChangeTypeFeature, PRChangeTypeFix, PRChangeTypeRefactor, PRChangeTypeCI} {
score := scores[kind]
if score <= 0 {
continue
}
hits = append(hits, kind)
if score > topScore {
secondScore = topScore
topScore = score
topType = kind
} else if score > secondScore {
secondScore = score
}
}
if len(hits) == 0 {
return PRChangeTypeUnknown
}
if len(hits) == 1 {
return topType
}
if len(hits) == 2 && containsString(hits, PRChangeTypeDocs) && containsString(hits, PRChangeTypeTest) {
return topType
}
if topScore >= secondScore+2 {
return topType
}
return PRChangeTypeMixed
}
func addKeywordScore(scores map[string]int, reasons *[]string, text, kind string, keywords []string) {
for _, keyword := range keywords {
if strings.Contains(text, keyword) {
scores[kind]++
*reasons = append(*reasons, "keyword:"+kind+":"+keyword)
}
}
}
func scorePRRisk(input PRSummaryInput, changeType string) (string, []string) {
corpus := strings.ToLower(prTextCorpus(input, true))
reasons := []string{}
if containsAny(corpus, []string{"token", "secret", "credential", "permission", "vulnerability", "auth bypass", "permission bypass", "leak"}) {
return PRRiskCritical, []string{"security-sensitive keyword"}
}
if touchesCodePath(input, []string{"shortcuts/", "cmd/", "internal/client"}) &&
containsAny(corpus, []string{"merge", "approve", "comment", "label", "close", "refuse", "journal"}) {
return PRRiskCritical, []string{"possible remote write operation"}
}
if touchesCodePath(input, []string{"internal/client", "internal/auth", "internal/config", "cmd/"}) {
reasons = append(reasons, "high-risk core path")
}
if touchesExactPath(input, "shortcuts/register.go") {
reasons = append(reasons, "command registration changed")
}
if input.Deletions >= 200 || deletedFiles(input) >= 5 {
reasons = append(reasons, "large deletion")
}
if len(reasons) > 0 {
return PRRiskHigh, reasons
}
if changeType == PRChangeTypeDocs || changeType == PRChangeTypeTest {
if len(input.ChangedFiles) <= 8 && input.Deletions <= 80 {
return PRRiskLow, []string{"docs-or-tests only"}
}
}
if touchesGoCode(input) || touchesCodePath(input, []string{"shortcuts/"}) {
return PRRiskMedium, []string{"go or shortcut code changed"}
}
return PRRiskLow, []string{"low-risk file scope"}
}
func buildPRReviewFocus(input PRSummaryInput, lang, riskLevel string) []string {
focus := []string{}
if touchesCodePath(input, []string{"shortcuts/"}) {
focus = append(focus, prFocusText(lang, "shortcuts"))
}
if touchesExactPath(input, "shortcuts/register.go") {
focus = append(focus, prFocusText(lang, "registration"))
}
if touchesCodePath(input, []string{"internal/client"}) {
focus = append(focus, prFocusText(lang, "client"))
}
if touchesCodePath(input, []string{"internal/output"}) {
focus = append(focus, prFocusText(lang, "output"))
}
if touchesCodePath(input, []string{"internal/auth", "internal/config"}) {
focus = append(focus, prFocusText(lang, "auth"))
}
if touchesDocs(input) {
focus = append(focus, prFocusText(lang, "docs"))
}
if touchesTests(input) {
focus = append(focus, prFocusText(lang, "tests"))
}
if touchesFetchOrAPI(input) {
focus = append(focus, prFocusText(lang, "api"))
}
if riskLevel == PRRiskCritical {
focus = append(focus, prFocusText(lang, "security"))
}
return uniqueStrings(focus)
}
func buildPRTestSuggestions(input PRSummaryInput, lang string) []string {
suggestions := []string{}
if touchesGoCode(input) {
suggestions = append(suggestions, prTestText(lang, "go_all"))
}
if touchesCodePath(input, []string{"shortcuts/workflow"}) {
suggestions = append(suggestions, prTestText(lang, "workflow"))
}
if touchesDocs(input) {
suggestions = append(suggestions, prTestText(lang, "docs"))
}
if touchesFetchOrAPI(input) {
suggestions = append(suggestions, prTestText(lang, "fetch"))
}
if touchesCodePath(input, []string{"render", "internal/output"}) {
suggestions = append(suggestions, prTestText(lang, "render"))
}
if len(suggestions) == 0 {
suggestions = append(suggestions, prTestText(lang, "go_all"))
}
return uniqueStrings(suggestions)
}
func buildPRMergeChecklist(input PRSummaryInput, lang, riskLevel string) []string {
checklist := []string{
prChecklistText(lang, "tests"),
prChecklistText(lang, "readme"),
prChecklistText(lang, "no_write"),
prChecklistText(lang, "json_stable"),
prChecklistText(lang, "errors"),
}
if riskLevel == PRRiskCritical || riskLevel == PRRiskHigh {
checklist = append(checklist, prChecklistText(lang, "credentials"))
}
if touchesFetchOrAPI(input) || riskLevel == PRRiskHigh {
checklist = append(checklist, prChecklistText(lang, "api_fallback"))
}
if touchesExactPath(input, "shortcuts/register.go") {
checklist = append(checklist, prChecklistText(lang, "registration"))
}
if touchesCodePath(input, []string{"internal/output", "render"}) {
checklist = append(checklist, prChecklistText(lang, "contract"))
}
return uniqueStrings(checklist)
}
func buildPRReasoning(lang, changeType, riskLevel string, scores map[string]int, scoreReasons, riskReasons []string) []string {
reasoning := []string{}
if lang == langZH {
reasoning = append(reasoning, fmt.Sprintf("变更类型判定:%s", changeType))
reasoning = append(reasoning, fmt.Sprintf("风险等级判定:%s", riskLevel))
} else {
reasoning = append(reasoning, fmt.Sprintf("change type: %s", changeType))
reasoning = append(reasoning, fmt.Sprintf("risk level: %s", riskLevel))
}
for _, kind := range []string{PRChangeTypeDocs, PRChangeTypeTest, PRChangeTypeFeature, PRChangeTypeFix, PRChangeTypeRefactor, PRChangeTypeCI} {
if scores[kind] > 0 {
if lang == langZH {
reasoning = append(reasoning, fmt.Sprintf("规则得分 %s=%d", kind, scores[kind]))
} else {
reasoning = append(reasoning, fmt.Sprintf("rule score %s=%d", kind, scores[kind]))
}
}
}
for _, reason := range append(scoreReasons, riskReasons...) {
if lang == langZH {
reasoning = append(reasoning, "命中规则:"+reason)
} else {
reasoning = append(reasoning, "matched rule: "+reason)
}
}
return uniqueStrings(reasoning)
}
func prTextCorpus(input PRSummaryInput, includeFiles bool) string {
parts := []string{input.Title, input.Body}
for _, commit := range input.Commits {
parts = append(parts, commit.Message)
}
if includeFiles {
for _, file := range input.ChangedFiles {
parts = append(parts, file.Filename, file.Status, file.Patch)
}
}
return strings.Join(parts, "\n")
}
func prFocusText(lang, key string) string {
zh := lang == langZH
switch key {
case "shortcuts":
if zh {
return "检查 shortcuts 命令兼容性和参数行为。"
}
return "Check shortcut command compatibility and flag behavior."
case "registration":
if zh {
return "确认命令注册和 shortcut 挂载兼容。"
}
return "Confirm command registration and shortcut mounting compatibility."
case "client":
if zh {
return "检查 API 错误处理和响应归一化。"
}
return "Check API error handling and response normalization."
case "output":
if zh {
return "检查输出格式兼容性和稳定性。"
}
return "Check output format compatibility and stability."
case "auth":
if zh {
return "检查凭据处理和安全边界。"
}
return "Check credential handling and security boundaries."
case "docs":
if zh {
return "检查文档示例是否与实现一致。"
}
return "Check that documentation examples match implementation."
case "tests":
if zh {
return "检查测试是否真实覆盖行为和失败路径。"
}
return "Check that tests reflect behavior and failure paths."
case "api":
if zh {
return "检查 fetch/API 失败时的降级和归一化。"
}
return "Check fetch/API failure fallback and normalization."
case "security":
if zh {
return "确认没有凭据泄露或不安全的远端写操作。"
}
return "Confirm no credential leakage or unsafe remote write operation."
default:
return key
}
}
func prTestText(lang, key string) string {
zh := lang == langZH
switch key {
case "go_all":
if zh {
return "运行 `go test ./...`。"
}
return "Run `go test ./...`."
case "workflow":
if zh {
return "运行 `go test ./shortcuts/workflow`。"
}
return "Run `go test ./shortcuts/workflow`."
case "docs":
if zh {
return "手动检查 README 和文档示例命令。"
}
return "Manually check README and documentation examples."
case "fetch":
if zh {
return "运行 httptest mock必要时执行只读远端 smoke。"
}
return "Run httptest mocks and a read-only remote smoke check if needed."
case "render":
if zh {
return "验证 json/table/markdown 输出结构。"
}
return "Verify json/table/markdown output structures."
default:
return key
}
}
func prChecklistText(lang, key string) string {
zh := lang == langZH
switch key {
case "tests":
if zh {
return "测试通过。"
}
return "Tests pass."
case "readme":
if zh {
return "命令行为变化已更新 README。"
}
return "README updated if command behavior changed."
case "no_write":
if zh {
return "未引入远端写操作。"
}
return "No remote write operation introduced."
case "json_stable":
if zh {
return "JSON 输出字段保持稳定。"
}
return "JSON output remains stable."
case "errors":
if zh {
return "错误处理已覆盖。"
}
return "Error handling is covered."
case "credentials":
if zh {
return "确认没有凭据泄露。"
}
return "Confirm no credential leakage."
case "api_fallback":
if zh {
return "验证 API 失败时的降级路径。"
}
return "Verify API failure fallback."
case "registration":
if zh {
return "确认命令注册兼容。"
}
return "Confirm command registration compatibility."
case "contract":
if zh {
return "复核 Agent 消费的输出协议。"
}
return "Review output contract for Agent consumers."
default:
return key
}
}
func normalizedPath(path string) string {
path = strings.ReplaceAll(path, "\\", "/")
return strings.ToLower(strings.TrimSpace(path))
}
func isDocsPath(path string) bool {
return strings.Contains(path, "docs/") ||
strings.Contains(path, "/docs/") ||
strings.Contains(path, "readme") ||
strings.HasSuffix(path, ".md")
}
func isTestPath(path string) bool {
return strings.Contains(path, "test") || strings.HasSuffix(path, "_test.go")
}
func isCIPath(path string) bool {
return strings.Contains(path, ".github") ||
strings.Contains(path, "workflow") ||
strings.Contains(path, "ci") ||
strings.Contains(path, "build")
}
func touchesDocs(input PRSummaryInput) bool {
for _, file := range input.ChangedFiles {
if isDocsPath(normalizedPath(file.Filename)) {
return true
}
}
return false
}
func touchesTests(input PRSummaryInput) bool {
for _, file := range input.ChangedFiles {
if isTestPath(normalizedPath(file.Filename)) {
return true
}
}
return false
}
func touchesGoCode(input PRSummaryInput) bool {
for _, file := range input.ChangedFiles {
if strings.HasSuffix(normalizedPath(file.Filename), ".go") {
return true
}
}
return false
}
func touchesFetchOrAPI(input PRSummaryInput) bool {
return touchesCodePath(input, []string{"fetch", "api", "internal/client"})
}
func touchesCodePath(input PRSummaryInput, fragments []string) bool {
for _, file := range input.ChangedFiles {
path := normalizedPath(file.Filename)
for _, fragment := range fragments {
if strings.Contains(path, strings.ToLower(fragment)) {
return true
}
}
}
return false
}
func touchesExactPath(input PRSummaryInput, target string) bool {
target = normalizedPath(target)
for _, file := range input.ChangedFiles {
if normalizedPath(file.Filename) == target {
return true
}
}
return false
}
func deletedFiles(input PRSummaryInput) int {
count := 0
for _, file := range input.ChangedFiles {
if strings.EqualFold(file.Status, "removed") || strings.EqualFold(file.Status, "deleted") {
count++
}
}
return count
}

View File

@ -0,0 +1,286 @@
package workflow
import (
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestAnalyzePRSummaryDocsOnlyLowRisk(t *testing.T) {
result := AnalyzePRSummary(PRSummaryInput{
Repository: "owner/repo",
Number: 1,
Title: "docs: update README examples",
ChangedFiles: []PRChangedFile{
{Filename: "README.md", Status: "modified", Additions: 12},
{Filename: "docs/workflow-agent-design.md", Status: "modified", Additions: 8},
},
Additions: 20,
}, "en")
if result.ChangeType != PRChangeTypeDocs {
t.Fatalf("ChangeType = %q, want %q", result.ChangeType, PRChangeTypeDocs)
}
if result.RiskLevel != PRRiskLow {
t.Fatalf("RiskLevel = %q, want %q", result.RiskLevel, PRRiskLow)
}
}
func TestAnalyzePRSummaryWorkflowCodeMediumRisk(t *testing.T) {
result := AnalyzePRSummary(PRSummaryInput{
Repository: "owner/repo",
Number: 2,
Title: "feat: add PR summary workflow",
ChangedFiles: []PRChangedFile{
{Filename: "shortcuts/workflow/pr_summary.go", Status: "added", Additions: 80},
},
Additions: 80,
}, "en")
if result.RiskLevel != PRRiskMedium {
t.Fatalf("RiskLevel = %q, want %q", result.RiskLevel, PRRiskMedium)
}
}
func TestAnalyzePRSummaryInternalClientHighRisk(t *testing.T) {
result := AnalyzePRSummary(PRSummaryInput{
Repository: "owner/repo",
Number: 3,
Title: "fix: normalize API errors",
ChangedFiles: []PRChangedFile{
{Filename: "internal/client/client.go", Status: "modified", Additions: 20, Deletions: 4},
},
Additions: 20,
Deletions: 4,
}, "en")
if result.RiskLevel != PRRiskHigh {
t.Fatalf("RiskLevel = %q, want %q", result.RiskLevel, PRRiskHigh)
}
}
func TestAnalyzePRSummaryAuthTokenCriticalRisk(t *testing.T) {
result := AnalyzePRSummary(PRSummaryInput{
Repository: "owner/repo",
Number: 4,
Title: "fix: prevent token permission leak",
Body: "Avoid auth bypass and secret exposure.",
ChangedFiles: []PRChangedFile{
{Filename: "internal/auth/auth.go", Status: "modified", Additions: 20},
},
}, "en")
if result.RiskLevel != PRRiskCritical {
t.Fatalf("RiskLevel = %q, want %q", result.RiskLevel, PRRiskCritical)
}
}
func TestAnalyzePRSummaryMixedFiles(t *testing.T) {
result := AnalyzePRSummary(PRSummaryInput{
Repository: "owner/repo",
Number: 5,
Title: "feat: add workflow command and docs",
ChangedFiles: []PRChangedFile{
{Filename: "shortcuts/workflow/pr_summary.go", Status: "added", Additions: 80},
{Filename: "docs/workflow-agent-design.md", Status: "modified", Additions: 10},
},
}, "en")
if result.ChangeType != PRChangeTypeMixed {
t.Fatalf("ChangeType = %q, want %q", result.ChangeType, PRChangeTypeMixed)
}
}
func TestAnalyzePRSummaryChineseText(t *testing.T) {
result := AnalyzePRSummary(PRSummaryInput{
Repository: "owner/repo",
Number: 6,
Title: "feat: add workflow command",
ChangedFiles: []PRChangedFile{
{Filename: "shortcuts/workflow/pr_summary.go", Status: "added", Additions: 80},
},
}, "zh-CN")
if len(result.ReviewFocus) == 0 || len(result.TestSuggestions) == 0 || len(result.MergeChecklist) == 0 {
t.Fatalf("expected non-empty zh-CN recommendations, got focus=%v suggestions=%v checklist=%v", result.ReviewFocus, result.TestSuggestions, result.MergeChecklist)
}
joined := strings.Join(append(append(result.ReviewFocus, result.TestSuggestions...), result.MergeChecklist...), "")
if !strings.Contains(joined, "检查") && !strings.Contains(joined, "运行") {
t.Fatalf("expected zh-CN text, got %q", joined)
}
}
func TestPRSummaryShortcutFromJSONFile(t *testing.T) {
restoreFormat := setCommandFormatForTest(t, "json")
defer restoreFormat()
ctx := &common.RuntimeContext{
Format: "json",
Args: map[string]string{
"from": "testdata/pr_summary.json",
"lang": "en",
},
}
output := captureStdout(t, func() error {
return findWorkflowShortcut(t, "pr-summary").Run(ctx)
})
var result PRSummaryResult
if err := json.Unmarshal([]byte(output), &result); err != nil {
t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, output)
}
if result.Number != 1 {
t.Fatalf("Number = %d, want 1", result.Number)
}
}
func TestPRSummaryShortcutRemoteFetch(t *testing.T) {
restoreFormat := setCommandFormatForTest(t, "json")
defer restoreFormat()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/5.json":
writeWorkflowJSON(t, w, map[string]interface{}{
"data": map[string]interface{}{
"number": 5,
"title": "feat: add remote summary",
"state": "open",
"user": map[string]interface{}{"login": "alice"},
"base_branch": "master",
"head_branch": "feature/pr-summary",
},
})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/5/files.json":
writeWorkflowJSON(t, w, map[string]interface{}{
"files": []map[string]interface{}{
{"filename": "shortcuts/workflow/pr_summary.go", "status": "added", "additions": 50},
},
})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/5/commits.json":
writeWorkflowJSON(t, w, []map[string]interface{}{
{"sha": "abc123", "message": "feat: add remote summary", "author": map[string]interface{}{"name": "alice"}},
})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: map[string]string{
"number": "5",
"include-files": "true",
"include-commits": "true",
"max-files": "100",
"max-commits": "100",
"lang": "en",
},
}
output := captureStdout(t, func() error {
return findWorkflowShortcut(t, "pr-summary").Run(ctx)
})
var result PRSummaryResult
if err := json.Unmarshal([]byte(output), &result); err != nil {
t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, output)
}
if result.Number != 5 || result.Source != "remote-read-only-fetch" {
t.Fatalf("result = %+v, want number 5 remote source", result)
}
}
func TestPRSummaryShortcutMissingParameters(t *testing.T) {
ctx := &common.RuntimeContext{
Format: "json",
Args: map[string]string{"lang": "en"},
}
err := findWorkflowShortcut(t, "pr-summary").Run(ctx)
if err == nil {
t.Fatal("Run returned nil error for missing parameters")
}
if !strings.Contains(err.Error(), "requires --from") {
t.Fatalf("error = %v, want clear missing input error", err)
}
}
func findWorkflowShortcut(t *testing.T, name string) *common.Shortcut {
t.Helper()
for _, shortcut := range Shortcuts() {
if shortcut.Name == name {
return shortcut
}
}
t.Fatalf("shortcut %q not found", name)
return nil
}
func setCommandFormatForTest(t *testing.T, format string) func() {
t.Helper()
old := cmdutil.Format
cmdutil.Format = format
return func() {
cmdutil.Format = old
}
}
func captureStdout(t *testing.T, fn func() error) string {
t.Helper()
old := os.Stdout
reader, writer, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe returned error: %v", err)
}
os.Stdout = writer
runErr := fn()
closeErr := writer.Close()
os.Stdout = old
if runErr != nil {
t.Fatalf("function returned error: %v", runErr)
}
if closeErr != nil {
t.Fatalf("writer.Close returned error: %v", closeErr)
}
data, err := io.ReadAll(reader)
if err != nil {
t.Fatalf("io.ReadAll returned error: %v", err)
}
return string(data)
}
func TestReadPRSummaryInput(t *testing.T) {
path := filepath.Join(t.TempDir(), "pr_summary.json")
input := PRSummaryInput{Number: 9, Title: "docs: update README", Repository: "owner/repo"}
encoded, err := json.Marshal(input)
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
if err := os.WriteFile(path, encoded, 0600); err != nil {
t.Fatalf("os.WriteFile returned error: %v", err)
}
got, err := readPRSummaryInput(path)
if err != nil {
t.Fatalf("readPRSummaryInput returned error: %v", err)
}
if got.Number != 9 || got.Title != "docs: update README" {
t.Fatalf("got = %+v, want input fields", got)
}
}

View File

@ -0,0 +1,422 @@
package workflow
import (
"bytes"
"encoding/json"
"fmt"
"io"
"sort"
"strings"
"text/tabwriter"
)
func renderTriageReport(w io.Writer, report TriageReport, format string) error {
switch normalizeFormat(format) {
case "json":
return writeJSON(w, report)
case "markdown":
return writeTriageMarkdown(w, report)
case "table":
return writeTriageTable(w, report)
default:
return fmt.Errorf("unsupported workflow output format %q", format)
}
}
func renderHealthResult(w io.Writer, result HealthResult, format string) error {
switch normalizeFormat(format) {
case "json":
return writeJSON(w, result)
case "markdown":
return writeHealthMarkdown(w, result)
case "table":
return writeHealthTable(w, result)
default:
return fmt.Errorf("unsupported workflow output format %q", format)
}
}
func RenderPRSummary(result PRSummaryResult, format string, lang string) (string, error) {
var buf bytes.Buffer
switch normalizeFormat(format) {
case "json":
if err := writeJSON(&buf, result); err != nil {
return "", err
}
case "markdown":
if err := writePRSummaryMarkdown(&buf, result, lang); err != nil {
return "", err
}
case "table":
if err := writePRSummaryTable(&buf, result); err != nil {
return "", err
}
default:
return "", fmt.Errorf("unsupported workflow output format %q", format)
}
return buf.String(), nil
}
func 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 == "" {
return "json"
}
return format
}
func writeJSON(w io.Writer, data interface{}) error {
encoded, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
_, err = fmt.Fprintln(w, string(encoded))
return err
}
func writeTriageTable(w io.Writer, report TriageReport) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(tw, "NUMBER\tTYPE\tPRIORITY\tCONFIDENCE\tMISSING\tACTION"); err != nil {
return err
}
for _, result := range report.Results {
missing := "-"
if len(result.MissingInformation) > 0 {
missing = strings.Join(result.MissingInformation, ",")
}
if _, err := fmt.Fprintf(tw, "%d\t%s\t%s\t%d\t%s\t%s\n",
result.Issue.Number,
result.DetectedType,
result.Priority,
result.Confidence,
missing,
result.RecommendedAction,
); err != nil {
return err
}
}
return tw.Flush()
}
func writePRSummaryTable(w io.Writer, result PRSummaryResult) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(tw, "PR\tTITLE\tTYPE\tRISK\tFILES\tCOMMITS\tSOURCE"); err != nil {
return err
}
if _, err := fmt.Fprintf(tw, "#%d\t%s\t%s\t%s\t%d\t%d\t%s\n",
result.Number,
truncateTableText(result.Title, 72),
result.ChangeType,
result.RiskLevel,
result.ChangedFilesCount,
result.CommitCount,
result.Source,
); err != nil {
return err
}
return tw.Flush()
}
func writeHealthTable(w io.Writer, result HealthResult) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintf(tw, "REPOSITORY\tSCORE\tRISK\n%s\t%d\t%s\n\n", result.Repository, result.HealthScore, result.RiskLevel); err != nil {
return err
}
if _, err := fmt.Fprintln(tw, "METRIC\tSTATUS\tSCORE\tMAX\tREASON"); err != nil {
return err
}
for _, metric := range result.Metrics {
if _, err := fmt.Fprintf(tw, "%s\t%s\t%d\t%d\t%s\n", metric.Name, metric.Status, metric.Score, metric.MaxScore, metric.Reason); err != nil {
return err
}
}
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
}
if _, err := fmt.Fprintln(w, "| Issue | Type | Priority | Confidence | Action | Missing Information |"); err != nil {
return err
}
if _, err := fmt.Fprintln(w, "| --- | --- | --- | ---: | --- | --- |"); err != nil {
return err
}
for _, result := range report.Results {
missing := "-"
if len(result.MissingInformation) > 0 {
missing = strings.Join(result.MissingInformation, ", ")
}
if _, err := fmt.Fprintf(w, "| #%d | %s | %s | %d | %s | %s |\n",
result.Issue.Number,
result.DetectedType,
result.Priority,
result.Confidence,
result.RecommendedAction,
missing,
); err != nil {
return err
}
}
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 {
return err
}
if _, err := fmt.Fprintf(w, "## %s\n\n", message(lang, "pr_summary_overview")); err != nil {
return err
}
lines := []string{
fmt.Sprintf("- Repository: `%s`", result.Repository),
fmt.Sprintf("- PR: `#%d` %s", result.Number, result.Title),
fmt.Sprintf("- Author: `%s`", result.Author),
fmt.Sprintf("- State: `%s`", result.State),
fmt.Sprintf("- Base branch: `%s`", result.BaseBranch),
fmt.Sprintf("- Head branch: `%s`", result.HeadBranch),
fmt.Sprintf("- Change type: `%s`", result.ChangeType),
fmt.Sprintf("- Risk level: `%s`", result.RiskLevel),
fmt.Sprintf("- Changed files: `%d`", result.ChangedFilesCount),
fmt.Sprintf("- Commits: `%d`", result.CommitCount),
fmt.Sprintf("- Source: `%s`", result.Source),
}
for _, line := range lines {
if _, err := fmt.Fprintln(w, line); err != nil {
return err
}
}
if err := writePRSummaryMarkdownList(w, message(lang, "pr_summary_review_focus"), result.ReviewFocus, message(lang, "pr_summary_no_focus")); err != nil {
return err
}
if err := writePRSummaryMarkdownList(w, message(lang, "pr_summary_test_suggestions"), result.TestSuggestions, message(lang, "pr_summary_no_suggestions")); err != nil {
return err
}
if err := writePRSummaryMarkdownList(w, message(lang, "pr_summary_merge_checklist"), result.MergeChecklist, message(lang, "pr_summary_no_checklist")); err != nil {
return err
}
return writePRSummaryMarkdownList(w, message(lang, "pr_summary_reasoning"), result.Reasoning, message(lang, "pr_summary_no_reasoning"))
}
func writePRSummaryMarkdownList(w io.Writer, title string, values []string, fallback string) error {
if _, err := fmt.Fprintf(w, "\n## %s\n\n", title); err != nil {
return err
}
if len(values) == 0 {
_, err := fmt.Fprintf(w, "- %s\n", fallback)
return err
}
for _, value := range values {
if _, err := fmt.Fprintf(w, "- %s\n", value); err != nil {
return err
}
}
return nil
}
func writeHealthMarkdown(w io.Writer, result HealthResult) error {
if _, err := fmt.Fprintf(w, "# Repository Health Report\n\nRepository: `%s`\n\nHealth score: **%d**\n\nRisk level: **%s**\n\n", result.Repository, result.HealthScore, result.RiskLevel); err != nil {
return err
}
if _, err := fmt.Fprintln(w, "| Metric | Status | Score | Max | Reason |"); err != nil {
return err
}
if _, err := fmt.Fprintln(w, "| --- | --- | ---: | ---: | --- |"); err != nil {
return err
}
for _, metric := range result.Metrics {
if _, err := fmt.Fprintf(w, "| %s | %s | %d | %d | %s |\n", metric.Name, metric.Status, metric.Score, metric.MaxScore, metric.Reason); err != nil {
return err
}
}
if len(result.Recommendations) == 0 {
return nil
}
if _, err := fmt.Fprintln(w, "\n## Recommendations"); err != nil {
return err
}
if _, err := fmt.Fprintln(w); err != nil {
return err
}
for _, recommendation := range result.Recommendations {
if _, err := fmt.Fprintf(w, "- %s\n", recommendation); err != nil {
return err
}
}
return nil
}
func truncateTableText(value string, max int) string {
value = strings.Join(strings.Fields(value), " ")
runes := []rune(value)
if max <= 0 || len(runes) <= max {
return value
}
if max <= 3 {
return string(runes[:max])
}
return string(runes[:max-3]) + "..."
}

View File

@ -0,0 +1,84 @@
package workflow
import (
"encoding/json"
"strings"
"testing"
)
func TestRenderPRSummaryJSON(t *testing.T) {
rendered, err := RenderPRSummary(samplePRSummaryResult(), "json", "en")
if err != nil {
t.Fatalf("RenderPRSummary returned error: %v", err)
}
var result PRSummaryResult
if err := json.Unmarshal([]byte(rendered), &result); err != nil {
t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, rendered)
}
if result.Number != 42 {
t.Fatalf("Number = %d, want 42", result.Number)
}
}
func TestRenderPRSummaryMarkdown(t *testing.T) {
result := samplePRSummaryResult()
rendered, err := RenderPRSummary(result, "markdown", "en")
if err != nil {
t.Fatalf("RenderPRSummary returned error: %v", err)
}
for _, want := range []string{result.Title, "Risk level", "Review Focus"} {
if !strings.Contains(rendered, want) {
t.Fatalf("markdown output missing %q:\n%s", want, rendered)
}
}
}
func TestRenderPRSummaryTable(t *testing.T) {
rendered, err := RenderPRSummary(samplePRSummaryResult(), "table", "en")
if err != nil {
t.Fatalf("RenderPRSummary returned error: %v", err)
}
if !strings.Contains(rendered, "#42") || !strings.Contains(rendered, "medium") {
t.Fatalf("table output = %q, want PR number and risk", rendered)
}
}
func TestRenderPRSummaryUnknownFormat(t *testing.T) {
_, err := RenderPRSummary(samplePRSummaryResult(), "xml", "en")
if err == nil {
t.Fatal("RenderPRSummary returned nil error for unknown format")
}
}
func TestRenderPRSummaryChineseMarkdown(t *testing.T) {
rendered, err := RenderPRSummary(samplePRSummaryResult(), "markdown", "zh-CN")
if err != nil {
t.Fatalf("RenderPRSummary returned error: %v", err)
}
if !strings.Contains(rendered, "PR 审阅摘要") {
t.Fatalf("zh-CN markdown output missing Chinese title:\n%s", rendered)
}
}
func samplePRSummaryResult() PRSummaryResult {
return PRSummaryResult{
Repository: "owner/repo",
Number: 42,
Title: "feat: add workflow PR summary",
Author: "alice",
State: "open",
BaseBranch: "master",
HeadBranch: "feature/pr-summary",
ChangedFilesCount: 2,
Additions: 100,
Deletions: 4,
CommitCount: 2,
ChangeType: PRChangeTypeFeature,
RiskLevel: PRRiskMedium,
ReviewFocus: []string{"Check shortcut command compatibility and flag behavior."},
TestSuggestions: []string{"Run `go test ./shortcuts/workflow`."},
MergeChecklist: []string{"Tests pass."},
Reasoning: []string{"change type: feature", "risk level: medium"},
Source: "local-json",
}
}

View File

@ -0,0 +1,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,18 @@
{
"repository": "Gitlink/gitlink-cli",
"open_issues": 3,
"open_prs": 1,
"stale_issues": 0,
"stale_prs": 0,
"recent_activity_known": true,
"recent_activity_days": 3,
"release_known": true,
"has_recent_release": true,
"ci_known": true,
"ci_passing": true,
"has_readme": true,
"has_license": true,
"has_contributing": true,
"agent_readiness_known": true,
"agent_readiness_score": 9
}

View File

@ -0,0 +1,18 @@
{
"repository": "demo/repo",
"open_issues": 60,
"open_prs": 12,
"stale_issues": 25,
"stale_prs": 6,
"recent_activity_known": true,
"recent_activity_days": 120,
"release_known": true,
"has_recent_release": false,
"ci_known": false,
"ci_passing": false,
"has_readme": false,
"has_license": false,
"has_contributing": false,
"agent_readiness_known": true,
"agent_readiness_score": 2
}

View File

@ -0,0 +1,9 @@
{
"number": 12,
"title": "Install failed on Windows",
"body": "go install failed with error. Expected behavior: install succeeds. Actual behavior: the command returns a build failure. Version: v1.0.0. OS: Windows 11. Output: build failed with exit code 1.",
"state": "open",
"author": "alice",
"url": "https://example.com/issues/12",
"labels": ["bug"]
}

View File

@ -0,0 +1,9 @@
{
"number": 21,
"title": "Token leaked in logs",
"body": "The access token appears in command output. This looks like a possible secret leak and auth problem.",
"state": "open",
"author": "bob",
"url": "https://example.com/issues/21",
"labels": ["security", "urgent"]
}

View File

@ -0,0 +1,37 @@
{
"repository": "Gitlink/gitlink-cli",
"number": 1,
"title": "feat: add workflow PR summary",
"author": "alice",
"state": "open",
"base_branch": "master",
"head_branch": "feature/workflow-pr-summary",
"body": "Add a read-only workflow PR summary command for maintainers and agents.",
"changed_files": [
{
"filename": "shortcuts/workflow/pr_summary.go",
"status": "added",
"additions": 120,
"deletions": 0,
"changes": 120
},
{
"filename": "docs/workflow-agent-design.md",
"status": "modified",
"additions": 18,
"deletions": 2,
"changes": 20
}
],
"commits": [
{
"sha": "abc1234",
"message": "feat: add workflow PR summary",
"author": "alice",
"date": "2026-05-20T10:00:00Z"
}
],
"additions": 138,
"deletions": 2,
"source": "local-json"
}

View File

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

@ -0,0 +1,170 @@
package workflow
import (
"fmt"
"net/url"
"strings"
"time"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func FetchIssuesForTriage(ctx *common.RuntimeContext, opts TriageFetchOptions) ([]IssueInput, error) {
owner, repo, err := resolveFetchRepo(ctx, opts.Owner, opts.Repo)
if err != nil {
return nil, err
}
limit := opts.Limit
if limit <= 0 {
limit = 30
}
page := opts.Page
if page <= 0 {
page = 1
}
state := strings.TrimSpace(opts.State)
if state == "" {
state = "open"
}
query := url.Values{}
query.Set("state", state)
query.Set("limit", fmt.Sprintf("%d", limit))
query.Set("page", fmt.Sprintf("%d", page))
if len(opts.Labels) > 0 {
query.Set("labels", strings.Join(opts.Labels, ","))
}
if strings.TrimSpace(opts.Since) != "" {
query.Set("since", strings.TrimSpace(opts.Since))
}
env, err := ctx.CallAPIWithQuery("GET", workflowRepoPath(owner, repo)+"/issues", query)
if err != nil {
return nil, fmt.Errorf("fetch issues for triage: %w", err)
}
items := apiList(env.Data)
issues := make([]IssueInput, 0, len(items))
for _, raw := range items {
issue, ok := normalizeIssueItem(raw)
if !ok {
continue
}
issues = append(issues, issue)
if len(issues) >= limit {
break
}
}
if len(issues) == 0 {
return nil, fmt.Errorf("fetch issues for triage: no issues found in API response")
}
return issues, nil
}
func normalizeIssueItem(raw interface{}) (IssueInput, bool) {
item, ok := raw.(map[string]interface{})
if !ok {
return IssueInput{}, false
}
title := firstIssueString(item, "title", "subject")
body := firstIssueString(item, "body", "description", "content")
if strings.TrimSpace(title) == "" && strings.TrimSpace(body) == "" {
return IssueInput{}, false
}
number := firstIssueInt(item, "number", "iid", "issue_number", "project_issues_index", "id")
id := firstIssueString(item, "id")
if id == "" {
id = fmt.Sprintf("%d", number)
}
state := firstIssueString(item, "state", "status")
author := firstIssueString(item, "author", "user", "creator")
urlValue := firstIssueString(item, "html_url", "url", "web_url")
labels := firstIssueLabels(item["labels"])
createdAt := firstIssueTime(item, "created_at", "created")
updatedAt := firstIssueTime(item, "updated_at", "updated", "last_updated_at")
comments := firstIssueInt(item, "comments_count", "comments")
return IssueInput{
ID: id,
Number: number,
Title: title,
Body: body,
State: state,
Author: apiAuthor(authorValue(item, author)),
URL: urlValue,
Labels: labels,
CreatedAt: createdAt,
UpdatedAt: updatedAt,
CommentsCount: comments,
}, true
}
func authorValue(item map[string]interface{}, fallback string) interface{} {
if raw, ok := item["author"]; ok {
return raw
}
if raw, ok := item["user"]; ok {
return raw
}
if raw, ok := item["creator"]; ok {
return raw
}
return fallback
}
func firstIssueString(item map[string]interface{}, keys ...string) string {
for _, key := range keys {
if value, ok := item[key]; ok {
if s := apiString(value); s != "" {
return s
}
}
}
return ""
}
func firstIssueInt(item map[string]interface{}, keys ...string) int {
for _, key := range keys {
if value, ok := item[key]; ok {
if n := apiInt(value); n != 0 {
return n
}
}
}
return 0
}
func firstIssueTime(item map[string]interface{}, keys ...string) time.Time {
for _, key := range keys {
if value, ok := item[key]; ok {
if t := apiTime(value); !t.IsZero() {
return t
}
}
}
return time.Time{}
}
func firstIssueLabels(value interface{}) []string {
switch labels := value.(type) {
case []interface{}:
out := make([]string, 0, len(labels))
for _, label := range labels {
if s := apiStringValue(label); s != "" {
out = append(out, s)
}
}
return out
case []string:
return append([]string(nil), labels...)
case string:
return apiStringSlice(labels)
default:
return nil
}
}

View File

@ -0,0 +1,257 @@
package workflow
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestFetchIssuesForTriageNormalizesAPIResponse(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("state"); got != "open" {
t.Fatalf("state query = %q, want open", got)
}
if got := r.URL.Query().Get("limit"); got != "30" {
t.Fatalf("limit query = %q, want 30", got)
}
writeWorkflowJSON(t, w, map[string]interface{}{
"issues": []map[string]interface{}{
{
"id": 12345,
"project_issues_index": 7,
"subject": "Crash on login",
"description": "panic error when running login",
"status": "open",
"author": map[string]interface{}{"login": "alice"},
"labels": []map[string]interface{}{{"name": "bug"}, {"name": "login"}},
"created_at": "2026-05-01T10:00:00Z",
"updated_at": "2026-05-02T10:00:00Z",
"comments_count": 2,
"html_url": "https://www.gitlink.org.cn/owner/repo/issues/7",
},
},
})
}))
defer server.Close()
ctx := workflowTestContext(server)
issues, err := FetchIssuesForTriage(ctx, TriageFetchOptions{State: "open", Limit: 30, Page: 1})
if err != nil {
t.Fatalf("FetchIssuesForTriage returned error: %v", err)
}
if len(issues) != 1 {
t.Fatalf("len(issues) = %d, want 1", len(issues))
}
issue := issues[0]
if issue.ID != "12345" {
t.Fatalf("ID = %q, want 12345", issue.ID)
}
if issue.Number != 7 {
t.Fatalf("Number = %d, want 7", issue.Number)
}
if issue.Title != "Crash on login" {
t.Fatalf("Title = %q, want Crash on login", issue.Title)
}
if issue.Author != "alice" {
t.Fatalf("Author = %q, want alice", issue.Author)
}
if len(issue.Labels) != 2 || issue.Labels[0] != "bug" || issue.Labels[1] != "login" {
t.Fatalf("Labels = %v, want [bug login]", issue.Labels)
}
if issue.CreatedAt.IsZero() || issue.UpdatedAt.IsZero() {
t.Fatalf("expected parsed timestamps, got created=%v updated=%v", issue.CreatedAt, issue.UpdatedAt)
}
}
func TestFetchIssuesForTriageSupportsDataString(t *testing.T) {
payload, err := json.Marshal([]map[string]interface{}{
{
"number": 3,
"title": "README typo",
"body": "documentation example typo",
"state": "open",
},
})
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
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)
}
writeWorkflowJSON(t, w, map[string]interface{}{"data": string(payload)})
}))
defer server.Close()
issues, err := FetchIssuesForTriage(workflowTestContext(server), TriageFetchOptions{Limit: 10})
if err != nil {
t.Fatalf("FetchIssuesForTriage returned error: %v", err)
}
if len(issues) != 1 {
t.Fatalf("len(issues) = %d, want 1", len(issues))
}
if issues[0].Number != 3 || issues[0].Title != "README typo" {
t.Fatalf("issue = %+v, want number 3 title README typo", issues[0])
}
}
func TestFetchIssuesForTriageEmptyResponseReturnsError(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{}})
}))
defer server.Close()
_, err := FetchIssuesForTriage(workflowTestContext(server), TriageFetchOptions{Limit: 10})
if err == nil {
t.Fatal("FetchIssuesForTriage returned nil error for empty response")
}
if !strings.Contains(err.Error(), "no issues found") {
t.Fatalf("error = %v, want empty-response message", err)
}
}
func TestFetchIssuesForTriageNormalizesLabelAndAuthorShapes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.URL.Query().Get("page"); got != "1" {
t.Fatalf("page query = %q, want 1", got)
}
if got := r.URL.Query().Get("limit"); got != "3" {
t.Fatalf("limit query = %q, want 3", got)
}
writeWorkflowJSON(t, w, map[string]interface{}{
"issues": []map[string]interface{}{
{
"number": 1,
"body": "panic on install",
"labels": []string{"bug", "help wanted"},
"author": "alice",
},
{
"number": 2,
"title": "token leak",
"labels": []map[string]interface{}{{"name": "bug"}, {"name": "security"}},
"user": map[string]interface{}{"login": "bob"},
},
{
"number": 3,
"title": "README typo",
"labels": []map[string]interface{}{{"title": "docs"}},
"creator": map[string]interface{}{"name": "carol"},
},
},
})
}))
defer server.Close()
issues, err := FetchIssuesForTriage(workflowTestContext(server), TriageFetchOptions{State: "open", Limit: 3, Page: 1})
if err != nil {
t.Fatalf("FetchIssuesForTriage returned error: %v", err)
}
if len(issues) != 3 {
t.Fatalf("len(issues) = %d, want 3", len(issues))
}
if issues[0].Title != "" || issues[0].Body != "panic on install" {
t.Fatalf("issue[0] = %+v, want body-only item with empty title", issues[0])
}
if got := strings.Join(issues[0].Labels, ","); got != "bug,help wanted" {
t.Fatalf("issue[0].Labels = %q, want bug,help wanted", got)
}
if issues[0].Author != "alice" {
t.Fatalf("issue[0].Author = %q, want alice", issues[0].Author)
}
if got := strings.Join(issues[1].Labels, ","); got != "bug,security" {
t.Fatalf("issue[1].Labels = %q, want bug,security", got)
}
if issues[1].Author != "bob" {
t.Fatalf("issue[1].Author = %q, want bob", issues[1].Author)
}
if got := strings.Join(issues[2].Labels, ","); got != "docs" {
t.Fatalf("issue[2].Labels = %q, want docs", got)
}
if issues[2].Author != "carol" {
t.Fatalf("issue[2].Author = %q, want carol", issues[2].Author)
}
}
func TestFetchIssuesForTriageRespectsLimitAndReportsRequestErrors(t *testing.T) {
requestCount := 0
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestCount++
if got := r.URL.Query().Get("page"); got != "2" {
t.Fatalf("page query = %q, want 2", got)
}
if got := r.URL.Query().Get("limit"); got != "1" {
t.Fatalf("limit query = %q, want 1", got)
}
writeWorkflowJSON(t, w, map[string]interface{}{
"issues": []map[string]interface{}{
{"number": 1, "title": "first"},
{"number": 2, "title": "second"},
},
})
}))
defer server.Close()
issues, err := FetchIssuesForTriage(workflowTestContext(server), TriageFetchOptions{State: "open", Limit: 1, Page: 2})
if err != nil {
t.Fatalf("FetchIssuesForTriage returned error: %v", err)
}
if requestCount != 1 {
t.Fatalf("requestCount = %d, want 1", requestCount)
}
if len(issues) != 1 {
t.Fatalf("len(issues) = %d, want 1", len(issues))
}
if issues[0].Number != 1 {
t.Fatalf("issues[0].Number = %d, want 1", issues[0].Number)
}
}
func TestFetchIssuesForTriageReportsGitLinkErrorInBody(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
writeWorkflowJSON(t, w, map[string]interface{}{
"status": 403,
"message": "permission denied",
})
}))
defer server.Close()
_, err := FetchIssuesForTriage(workflowTestContext(server), TriageFetchOptions{Limit: 10})
if err == nil {
t.Fatal("FetchIssuesForTriage returned nil error for error-in-body response")
}
if !strings.Contains(err.Error(), "permission denied") {
t.Fatalf("error = %v, want permission denied", err)
}
}
func workflowTestContext(server *httptest.Server) *common.RuntimeContext {
return &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: map[string]string{},
}
}
func writeWorkflowJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}

View File

@ -0,0 +1,306 @@
package workflow
import (
"fmt"
"sort"
"strings"
)
type keywordRule struct {
issueType string
keywords []string
weight int
}
var triageKeywordRules = []keywordRule{
{IssueTypeBug, []string{"crash", "error", "panic", "exception", "fail", "failed", "failure", "broken", "cannot", "bug", "报错", "崩溃", "异常", "失败", "无法", "不能", "问题"}, 10},
{IssueTypeFeature, []string{"feature", "request", "support", "add", "implement", "enhancement", "功能", "支持", "增加", "建议", "增强"}, 8},
{IssueTypeQuestion, []string{"how", "why", "help", "usage", "question", "如何", "怎么", "为什么", "请问", "求助"}, 7},
{IssueTypeDocs, []string{"doc", "docs", "documentation", "readme", "typo", "example", "guide", "文档", "说明", "错别字", "示例", "教程"}, 8},
{IssueTypeCI, []string{"ci", "build", "workflow", "action", "test failed", "pipeline", "构建", "测试失败", "流水线"}, 10},
{IssueTypeSecurity, []string{"token", "leak", "leaked", "secret", "auth", "permission", "vulnerability", "cve", "泄露", "密钥", "权限", "漏洞", "安全"}, 12},
{IssueTypePerformance, []string{"slow", "timeout", "latency", "memory", "cpu", "performance", "慢", "超时", "性能", "内存"}, 8},
{IssueTypeRefactor, []string{"refactor", "cleanup", "simplify", "restructure", "重构", "清理", "简化", "结构调整"}, 6},
}
var typeTieOrder = []string{
IssueTypeSecurity,
IssueTypeBug,
IssueTypeCI,
IssueTypePerformance,
IssueTypeFeature,
IssueTypeDocs,
IssueTypeQuestion,
IssueTypeRefactor,
}
func AnalyzeIssue(input IssueInput, lang string) TriageResult {
lang = normalizeLang(lang)
text := normalizeIssueText(input)
scores, matchedRules, reasoning := scoreIssueTypes(text, input.Labels)
detectedType := chooseDetectedType(scores)
riskFlags := detectRiskFlags(text, detectedType)
priority, priorityReason := determinePriority(text, detectedType)
if priorityReason != "" {
matchedRules = append(matchedRules, priorityReason)
reasoning = append(reasoning, priorityReason)
}
missingInformation := detectMissingInformation(text, detectedType, lang)
if len(missingInformation) > 0 {
riskFlags = append(riskFlags, RiskInsufficientInfo)
matchedRules = append(matchedRules, "missing information detected")
reasoning = append(reasoning, "missing information detected")
}
confidence := calculateConfidence(detectedType, scores, matchedRules, input.Labels)
return TriageResult{
Issue: IssueRef{
ID: input.ID,
Number: input.Number,
Title: input.Title,
URL: input.URL,
Author: input.Author,
State: input.State,
},
DetectedType: detectedType,
Priority: priority,
Confidence: confidence,
SuggestedLabels: suggestedLabels(detectedType, priority, riskFlags),
MissingInformation: missingInformation,
RiskFlags: uniqueStrings(riskFlags),
RecommendedAction: recommendedAction(detectedType, priority, riskFlags, missingInformation),
SuggestedComment: suggestedComment(lang, detectedType, missingInformation),
Reasoning: uniqueStrings(reasoning),
MatchedRules: uniqueStrings(matchedRules),
}
}
func normalizeIssueText(input IssueInput) string {
parts := []string{input.Title, input.Body}
parts = append(parts, input.Labels...)
return strings.ToLower(strings.Join(parts, "\n"))
}
func scoreIssueTypes(text string, labels []string) (map[string]int, []string, []string) {
scores := make(map[string]int)
matchedRules := []string{}
reasoning := []string{}
for _, rule := range triageKeywordRules {
for _, keyword := range rule.keywords {
if strings.Contains(text, strings.ToLower(keyword)) {
scores[rule.issueType] += rule.weight
ruleText := "matched keyword: " + keyword
matchedRules = append(matchedRules, ruleText)
reasoning = append(reasoning, fmt.Sprintf("%s -> %s", ruleText, rule.issueType))
}
}
}
for _, label := range labels {
normalized := strings.ToLower(strings.TrimSpace(label))
for _, issueType := range typeTieOrder {
if normalized == issueType || strings.Contains(normalized, issueType) {
scores[issueType] += 14
ruleText := "label matched: " + normalized
matchedRules = append(matchedRules, ruleText)
reasoning = append(reasoning, fmt.Sprintf("%s -> %s", ruleText, issueType))
}
}
}
return scores, matchedRules, reasoning
}
func chooseDetectedType(scores map[string]int) string {
bestType := IssueTypeUnknown
bestScore := 0
for _, issueType := range typeTieOrder {
score := scores[issueType]
if score > bestScore {
bestType = issueType
bestScore = score
}
}
return bestType
}
func determinePriority(text string, detectedType string) (string, string) {
if detectedType == IssueTypeSecurity || containsAny(text, []string{"token leak", "secret leak", "leaked token", "leaked secret", "auth bypass", "permission bypass", "vulnerability", "cve", "密钥泄露", "漏洞", "认证绕过", "权限绕过"}) {
return PriorityP0, "priority rule: security sensitive token leak"
}
if containsAny(text, []string{"cannot login", "install failed", "installation failed", "core command unavailable", "command unavailable", "login failed", "crash", "panic", "无法登录", "安装失败", "核心命令不可用", "崩溃"}) {
return PriorityP1, "priority rule: core blocker"
}
if detectedType == IssueTypeBug || detectedType == IssueTypeCI || detectedType == IssueTypePerformance {
return PriorityP2, "priority rule: normal bug or operational failure"
}
if detectedType == IssueTypeFeature {
return PriorityP2, "priority rule: feature request"
}
return PriorityP3, "priority rule: low risk request"
}
func calculateConfidence(detectedType string, scores map[string]int, matchedRules []string, labels []string) int {
if detectedType == IssueTypeUnknown {
return 20
}
confidence := 35 + scores[detectedType]
if len(matchedRules) > 1 {
confidence += minInt(len(matchedRules)*3, 18)
}
if len(labels) > 0 {
confidence += 8
}
if detectedType == IssueTypeSecurity || detectedType == IssueTypeBug || detectedType == IssueTypeCI {
confidence += 10
}
return clampInt(confidence, 0, 100)
}
func detectMissingInformation(text string, detectedType string, lang string) []string {
if detectedType != IssueTypeBug {
return nil
}
checks := []struct {
key string
present []string
messageK string
}{
{"reproduction_steps", []string{"reproduction", "reproduce", "steps", "复现", "步骤"}, "missing_reproduction_steps"},
{"expected_behavior", []string{"expected", "expect", "期望", "预期"}, "missing_expected_behavior"},
{"actual_behavior", []string{"actual", "实际"}, "missing_actual_behavior"},
{"version", []string{"version", "版本"}, "missing_version"},
{"os_or_platform", []string{"os", "platform", "windows", "linux", "macos", "darwin", "系统", "平台"}, "missing_os_or_platform"},
{"command_output_or_logs", []string{"output", "log", "trace", "stdout", "stderr", "输出", "日志"}, "missing_command_output_or_logs"},
}
missing := []string{}
for _, check := range checks {
if !containsAny(text, check.present) {
if normalizeLang(lang) == langZH {
missing = append(missing, message(lang, check.messageK))
} else {
missing = append(missing, check.key)
}
}
}
return missing
}
func detectRiskFlags(text string, detectedType string) []string {
flags := []string{}
if detectedType == IssueTypeSecurity || containsAny(text, []string{"vulnerability", "cve", "漏洞", "安全", "auth bypass", "permission bypass", "认证绕过", "权限绕过"}) {
flags = append(flags, RiskSecuritySensitive)
}
if containsAny(text, []string{"token leak", "secret leak", "leaked token", "leaked secret", "token leaked", "secret leaked", "密钥泄露", "泄露"}) {
flags = append(flags, RiskPossibleSecretLeak)
}
if containsAny(text, []string{"install failed", "installation failed", "安装失败"}) {
flags = append(flags, RiskInstallationBlocker)
}
if containsAny(text, []string{"cannot login", "login failed", "auth failed", "无法登录", "登录失败"}) {
flags = append(flags, RiskAuthenticationBlocker)
}
if detectedType == IssueTypeCI || containsAny(text, []string{"test failed", "pipeline failed", "build failed", "测试失败", "构建失败"}) {
flags = append(flags, RiskCIBlocker)
}
return flags
}
func suggestedLabels(detectedType string, priority string, riskFlags []string) []string {
labels := []string{}
if detectedType != IssueTypeUnknown {
labels = append(labels, detectedType)
}
labels = append(labels, strings.ToLower(priority))
for _, flag := range riskFlags {
switch flag {
case RiskSecuritySensitive, RiskPossibleSecretLeak:
labels = append(labels, "security")
case RiskCIBlocker:
labels = append(labels, "ci")
}
}
return uniqueStrings(labels)
}
func recommendedAction(detectedType string, priority string, riskFlags []string, missingInformation []string) string {
if containsString(riskFlags, RiskSecuritySensitive) || containsString(riskFlags, RiskPossibleSecretLeak) || detectedType == IssueTypeSecurity {
return ActionReviewSecurity
}
if priority == PriorityP0 {
return ActionPrioritizeImmediate
}
if len(missingInformation) > 0 {
return ActionRequestMoreInfo
}
switch detectedType {
case IssueTypeQuestion:
return ActionConvertToDiscussion
case IssueTypeDocs:
return ActionUpdateDocs
case IssueTypeCI:
return ActionInvestigateCI
default:
return ActionScheduleFix
}
}
func suggestedComment(lang string, detectedType string, missingInformation []string) string {
lang = normalizeLang(lang)
if detectedType == IssueTypeSecurity {
return message(lang, "comment_security")
}
if len(missingInformation) > 0 {
return fmt.Sprintf(message(lang, "comment_more_info"), strings.Join(missingInformation, ", "))
}
if detectedType == IssueTypeDocs {
return message(lang, "comment_docs")
}
return message(lang, "comment_default")
}
func containsAny(text string, keywords []string) bool {
for _, keyword := range keywords {
if strings.Contains(text, strings.ToLower(keyword)) {
return true
}
}
return false
}
func containsString(values []string, needle string) bool {
for _, value := range values {
if value == needle {
return true
}
}
return false
}
func uniqueStrings(values []string) []string {
seen := map[string]struct{}{}
unique := []string{}
for _, value := range values {
if value == "" {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
unique = append(unique, value)
}
return unique
}
func sortedStrings(values []string) []string {
copied := append([]string(nil), values...)
sort.Strings(copied)
return copied
}

View File

@ -0,0 +1,92 @@
package workflow
import "testing"
func TestAnalyzeIssueDetectsSecurityP0(t *testing.T) {
result := AnalyzeIssue(IssueInput{
Title: "Token leaked in command output",
Body: "A secret token leaked and may allow permission escalation.",
Labels: []string{"security"},
}, "en")
if result.DetectedType != IssueTypeSecurity {
t.Fatalf("DetectedType = %q, want %q", result.DetectedType, IssueTypeSecurity)
}
if result.Priority != PriorityP0 {
t.Fatalf("Priority = %q, want %q", result.Priority, PriorityP0)
}
if !containsString(result.RiskFlags, RiskPossibleSecretLeak) && !containsString(result.RiskFlags, RiskSecuritySensitive) {
t.Fatalf("RiskFlags = %v, want security or secret leak flag", result.RiskFlags)
}
if result.Confidence < 70 {
t.Fatalf("Confidence = %d, want >= 70", result.Confidence)
}
}
func TestAnalyzeIssueDetectsBugAndMissingInfo(t *testing.T) {
result := AnalyzeIssue(IssueInput{
Title: "CLI crash with error",
Body: "It crashes.",
}, "en")
if result.DetectedType != IssueTypeBug {
t.Fatalf("DetectedType = %q, want %q", result.DetectedType, IssueTypeBug)
}
if result.Priority != PriorityP1 && result.Priority != PriorityP2 {
t.Fatalf("Priority = %q, want P1 or P2", result.Priority)
}
if len(result.MissingInformation) == 0 {
t.Fatal("MissingInformation is empty, want bug info requirements")
}
if !containsString(result.RiskFlags, RiskInsufficientInfo) {
t.Fatalf("RiskFlags = %v, want %q", result.RiskFlags, RiskInsufficientInfo)
}
}
func TestAnalyzeIssueDetectsDocs(t *testing.T) {
result := AnalyzeIssue(IssueInput{
Title: "README typo in documentation example",
Body: "The docs guide has a typo.",
}, "en")
if result.DetectedType != IssueTypeDocs {
t.Fatalf("DetectedType = %q, want %q", result.DetectedType, IssueTypeDocs)
}
if result.Priority != PriorityP3 {
t.Fatalf("Priority = %q, want %q", result.Priority, PriorityP3)
}
if result.RecommendedAction != ActionUpdateDocs {
t.Fatalf("RecommendedAction = %q, want %q", result.RecommendedAction, ActionUpdateDocs)
}
}
func TestAnalyzeIssueChinese(t *testing.T) {
result := AnalyzeIssue(IssueInput{
Title: "安装失败并且报错,无法登录",
Body: "执行登录命令后失败。",
}, "zh-CN")
if result.DetectedType != IssueTypeBug {
t.Fatalf("DetectedType = %q, want %q", result.DetectedType, IssueTypeBug)
}
if result.Priority != PriorityP1 {
t.Fatalf("Priority = %q, want %q", result.Priority, PriorityP1)
}
if result.SuggestedComment == "" {
t.Fatal("SuggestedComment is empty")
}
}
func TestAnalyzeIssueUnknownLowConfidence(t *testing.T) {
result := AnalyzeIssue(IssueInput{
Title: "General repository note",
Body: "This is a neutral note without clear maintenance signal.",
}, "en")
if result.DetectedType != IssueTypeUnknown {
t.Fatalf("DetectedType = %q, want %q", result.DetectedType, IssueTypeUnknown)
}
if result.Confidence > 40 {
t.Fatalf("Confidence = %d, want <= 40", result.Confidence)
}
}

124
shortcuts/workflow/types.go Normal file
View File

@ -0,0 +1,124 @@
package workflow
import "time"
const (
langEN = "en"
langZH = "zh-CN"
)
const (
IssueTypeBug = "bug"
IssueTypeFeature = "feature"
IssueTypeQuestion = "question"
IssueTypeDocs = "docs"
IssueTypeCI = "ci"
IssueTypeSecurity = "security"
IssueTypePerformance = "performance"
IssueTypeRefactor = "refactor"
IssueTypeUnknown = "unknown"
)
const (
PriorityP0 = "P0"
PriorityP1 = "P1"
PriorityP2 = "P2"
PriorityP3 = "P3"
)
const (
RiskSecuritySensitive = "security_sensitive"
RiskPossibleSecretLeak = "possible_secret_leak"
RiskInstallationBlocker = "installation_blocker"
RiskAuthenticationBlocker = "authentication_blocker"
RiskCIBlocker = "ci_blocker"
RiskInsufficientInfo = "insufficient_information"
)
const (
ActionRequestMoreInfo = "request_more_info"
ActionPrioritizeImmediate = "prioritize_immediately"
ActionScheduleFix = "schedule_fix"
ActionConvertToDiscussion = "convert_to_discussion"
ActionUpdateDocs = "update_docs"
ActionInvestigateCI = "investigate_ci"
ActionReviewSecurity = "review_security"
)
type IssueInput struct {
ID string `json:"id"`
Number int `json:"number"`
Title string `json:"title"`
Body string `json:"body"`
State string `json:"state"`
Author string `json:"author"`
URL string `json:"url"`
Labels []string `json:"labels"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
CommentsCount int `json:"comments_count"`
}
type IssueRef struct {
ID string `json:"id"`
Number int `json:"number"`
Title string `json:"title"`
URL string `json:"url"`
Author string `json:"author"`
State string `json:"state"`
}
type TriageResult struct {
Issue IssueRef `json:"issue"`
DetectedType string `json:"detected_type"`
Priority string `json:"priority"`
Confidence int `json:"confidence"`
SuggestedLabels []string `json:"suggested_labels"`
MissingInformation []string `json:"missing_information"`
RiskFlags []string `json:"risk_flags"`
RecommendedAction string `json:"recommended_action"`
SuggestedComment string `json:"suggested_comment"`
Reasoning []string `json:"reasoning"`
MatchedRules []string `json:"matched_rules"`
}
type HealthInput struct {
Repository string `json:"repository"`
OpenIssues int `json:"open_issues"`
OpenPRs int `json:"open_prs"`
StaleIssues int `json:"stale_issues"`
StalePRs int `json:"stale_prs"`
RecentActivityKnown bool `json:"recent_activity_known"`
RecentActivityDays int `json:"recent_activity_days"`
ReleaseKnown bool `json:"release_known"`
HasRecentRelease bool `json:"has_recent_release"`
CIKnown bool `json:"ci_known"`
CIPassing bool `json:"ci_passing"`
HasReadme bool `json:"has_readme"`
HasLicense bool `json:"has_license"`
HasContributing bool `json:"has_contributing"`
AgentReadinessKnown bool `json:"agent_readiness_known"`
AgentReadinessScore int `json:"agent_readiness_score"`
}
type HealthResult struct {
Repository string `json:"repository"`
HealthScore int `json:"health_score"`
RiskLevel string `json:"risk_level"`
Metrics []HealthMetric `json:"metrics"`
Recommendations []string `json:"recommendations"`
ScoringNotes []ScoringNote `json:"scoring_notes"`
}
type HealthMetric struct {
Name string `json:"name"`
Status string `json:"status"`
Score int `json:"score"`
MaxScore int `json:"max_score"`
Reason string `json:"reason"`
}
type ScoringNote struct {
Metric string `json:"metric"`
Note string `json:"note"`
}

View File

@ -0,0 +1,386 @@
package workflow
import (
"encoding/json"
"fmt"
"os"
"strconv"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
type TriageReport struct {
Repository string `json:"repository"`
State string `json:"state"`
Limit int `json:"limit"`
DryRun bool `json:"dry_run"`
Language string `json:"language"`
Results []TriageResult `json:"results"`
}
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
newTriageShortcut(),
newHealthShortcut(),
newPRSummaryShortcut(),
newRepoReportShortcut(),
}
}
func newTriageShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "triage",
Description: "Analyze issues with local workflow triage rules",
Flags: []common.Flag{
{Name: "from", Usage: "Read issue inputs from a JSON file. Supports a single issue, an array, or an object with an issues field"},
{Name: "title", Short: "t", Usage: "Issue title for single-issue local analysis"},
{Name: "body", Short: "b", Usage: "Issue body for single-issue local analysis"},
{Name: "number", Short: "n", Usage: "Issue number for single-issue local analysis"},
{Name: "author", Usage: "Issue author for single-issue local analysis"},
{Name: "url", Usage: "Issue URL for single-issue local analysis"},
{Name: "labels", Usage: "Comma-separated labels for single-issue local analysis"},
{Name: "state", Short: "s", Usage: "Filter or assign issue state", Default: "open"},
{Name: "page", Short: "p", Usage: "API page number for remote triage", Default: "1"},
{Name: "limit", Short: "l", Usage: "Maximum issues to analyze", Default: "30"},
{Name: "since", Usage: "Optional remote issue filter for updated time"},
{Name: "dry-run", Usage: "Preview workflow recommendations without remote writes", Bool: true, Default: "true"},
{Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN},
},
Run: runTriage,
}
}
func newHealthShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "health",
Description: "Score repository health with local workflow rules",
Flags: []common.Flag{
{Name: "from", Usage: "Read health input from a JSON file"},
{Name: "repository", Usage: "Repository name, for example owner/repo"},
{Name: "open-issues", Usage: "Open issue count", Default: "0"},
{Name: "open-prs", Usage: "Open pull request count", Default: "0"},
{Name: "stale-issues", Usage: "Stale issue count", Default: "0"},
{Name: "stale-prs", Usage: "Stale pull request count", Default: "0"},
{Name: "recent-activity-known", Usage: "Whether recent activity is known", Bool: true, Default: "false"},
{Name: "recent-activity-days", Usage: "Days since recent activity", Default: "0"},
{Name: "release-known", Usage: "Whether release status is known", Bool: true, Default: "false"},
{Name: "has-recent-release", Usage: "Whether a recent release exists", Bool: true, Default: "false"},
{Name: "ci-known", Usage: "Whether CI status is known", Bool: true, Default: "false"},
{Name: "ci-passing", Usage: "Whether CI is passing", Bool: true, Default: "false"},
{Name: "has-readme", Usage: "Whether README exists", Bool: true, Default: "false"},
{Name: "has-license", Usage: "Whether LICENSE exists", Bool: true, Default: "false"},
{Name: "has-contributing", Usage: "Whether CONTRIBUTING exists", Bool: true, Default: "false"},
{Name: "agent-readiness-known", Usage: "Whether agent readiness score is known", Bool: true, Default: "false"},
{Name: "agent-readiness-score", Usage: "Agent readiness score from 0 to 10", Default: "0"},
{Name: "stale-days", Usage: "Days before an issue or PR is considered stale", Default: "30"},
{Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN},
},
Run: runHealth,
}
}
func runTriage(ctx *common.RuntimeContext) error {
lang := normalizeLang(ctx.Arg("lang"))
limit, err := parseIntArg(ctx.Arg("limit"), 30, "limit")
if err != nil {
return err
}
state := ctx.Arg("state")
if state == "" {
state = "open"
}
if hasLocalTriageInput(ctx) {
issues, err := collectIssuesFromArgs(ctx)
if err != nil {
return err
}
filtered := filterIssueInputs(issues, state, limit)
results := make([]TriageResult, 0, len(filtered))
for _, issue := range filtered {
results = append(results, AnalyzeIssue(issue, lang))
}
report := TriageReport{
Repository: repositoryFromContext(ctx, ""),
State: state,
Limit: limit,
DryRun: parseBoolArg(ctx.Arg("dry-run")),
Language: lang,
Results: results,
}
return renderTriageReport(os.Stdout, report, ctx.Format)
}
issues, err := FetchIssuesForTriage(ctx, TriageFetchOptions{
State: state,
Limit: limit,
Page: mustParseInt(ctx.Arg("page"), 1),
Labels: parseCSV(ctx.Arg("labels")),
Since: ctx.Arg("since"),
})
if err != nil {
return fmt.Errorf("%w\nhint: use --title or --from issues.json for local rule analysis", err)
}
results := make([]TriageResult, 0, len(issues))
for _, issue := range issues {
results = append(results, AnalyzeIssue(issue, lang))
}
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("workflow +triage remote mode requires --owner and --repo or a Git remote: %w", err)
}
report := TriageReport{
Repository: repositoryFromContext(ctx, ""),
State: state,
Limit: limit,
DryRun: parseBoolArg(ctx.Arg("dry-run")),
Language: lang,
Results: results,
}
return renderTriageReport(os.Stdout, report, ctx.Format)
}
func runHealth(ctx *common.RuntimeContext) error {
lang := normalizeLang(ctx.Arg("lang"))
if hasLocalHealthInput(ctx) {
input, err := collectHealthFromArgs(ctx)
if err != nil {
return err
}
input.Repository = repositoryFromContext(ctx, input.Repository)
result := ScoreHealth(input, lang)
return renderHealthResult(os.Stdout, result, ctx.Format)
}
if err := ctx.ResolveOwnerRepo(); err != nil {
return fmt.Errorf("workflow +health remote mode requires --owner and --repo or a Git remote: %w", err)
}
input, notes, err := FetchHealthInput(ctx, HealthFetchOptions{
StaleDays: mustParseInt(ctx.Arg("stale-days"), 30),
IncludeCI: true,
IncludeRelease: true,
IncludeDocs: true,
})
if err != nil {
return err
}
result := ScoreHealth(input, lang)
result.ScoringNotes = append(notes, result.ScoringNotes...)
return renderHealthResult(os.Stdout, result, ctx.Format)
}
func collectIssuesFromArgs(ctx *common.RuntimeContext) ([]IssueInput, error) {
if path := ctx.Arg("from"); path != "" {
return readIssueInputs(path)
}
if strings.TrimSpace(ctx.Arg("title")) == "" {
return nil, fmt.Errorf("workflow +triage currently requires --from issues.json or --title for local rule analysis")
}
number, err := parseIntArg(ctx.Arg("number"), 0, "number")
if err != nil {
return nil, err
}
state := ctx.Arg("state")
if state == "" {
state = "open"
}
return []IssueInput{{
Number: number,
Title: ctx.Arg("title"),
Body: ctx.Arg("body"),
State: state,
Author: ctx.Arg("author"),
URL: ctx.Arg("url"),
Labels: parseCSV(ctx.Arg("labels")),
}}, nil
}
func hasLocalTriageInput(ctx *common.RuntimeContext) bool {
if strings.TrimSpace(ctx.Arg("from")) != "" || strings.TrimSpace(ctx.Arg("title")) != "" {
return true
}
// When user passes triage-specific flags without --title or --from,
// treat it as local input so argument validation kicks in early.
for _, name := range []string{"body", "number", "author", "url", "labels"} {
if strings.TrimSpace(ctx.Arg(name)) != "" {
return true
}
}
return false
}
func hasLocalHealthInput(ctx *common.RuntimeContext) bool {
if strings.TrimSpace(ctx.Arg("from")) != "" || strings.TrimSpace(ctx.Arg("repository")) != "" {
return true
}
// When user passes any health-flag value (even invalid ones like
// --open-issues abc), stay in local mode so parseIntArg validates them.
for _, name := range []string{"open-issues", "stale-issues", "open-prs", "stale-prs", "recent-activity-known", "recent-activity-days", "release-known", "has-recent-release", "ci-known", "ci-passing", "has-readme", "has-license", "has-contributing", "agent-readiness-known", "agent-readiness-score"} {
if strings.TrimSpace(ctx.Arg(name)) != "" {
return true
}
}
return false
}
func collectHealthFromArgs(ctx *common.RuntimeContext) (HealthInput, error) {
if path := ctx.Arg("from"); path != "" {
input, err := readHealthInput(path)
if err != nil {
return HealthInput{}, err
}
return input, nil
}
openIssues, err := parseIntArg(ctx.Arg("open-issues"), 0, "open-issues")
if err != nil {
return HealthInput{}, err
}
openPRs, err := parseIntArg(ctx.Arg("open-prs"), 0, "open-prs")
if err != nil {
return HealthInput{}, err
}
staleIssues, err := parseIntArg(ctx.Arg("stale-issues"), 0, "stale-issues")
if err != nil {
return HealthInput{}, err
}
stalePRs, err := parseIntArg(ctx.Arg("stale-prs"), 0, "stale-prs")
if err != nil {
return HealthInput{}, err
}
recentActivityDays, err := parseIntArg(ctx.Arg("recent-activity-days"), 0, "recent-activity-days")
if err != nil {
return HealthInput{}, err
}
agentReadinessScore, err := parseIntArg(ctx.Arg("agent-readiness-score"), 0, "agent-readiness-score")
if err != nil {
return HealthInput{}, err
}
return HealthInput{
Repository: ctx.Arg("repository"),
OpenIssues: openIssues,
OpenPRs: openPRs,
StaleIssues: staleIssues,
StalePRs: stalePRs,
RecentActivityKnown: parseBoolArg(ctx.Arg("recent-activity-known")),
RecentActivityDays: recentActivityDays,
ReleaseKnown: parseBoolArg(ctx.Arg("release-known")),
HasRecentRelease: parseBoolArg(ctx.Arg("has-recent-release")),
CIKnown: parseBoolArg(ctx.Arg("ci-known")),
CIPassing: parseBoolArg(ctx.Arg("ci-passing")),
HasReadme: parseBoolArg(ctx.Arg("has-readme")),
HasLicense: parseBoolArg(ctx.Arg("has-license")),
HasContributing: parseBoolArg(ctx.Arg("has-contributing")),
AgentReadinessKnown: parseBoolArg(ctx.Arg("agent-readiness-known")),
AgentReadinessScore: agentReadinessScore,
}, nil
}
func readIssueInputs(path string) ([]IssueInput, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read issue inputs: %w", err)
}
var issues []IssueInput
if err := json.Unmarshal(data, &issues); err == nil {
return issues, nil
}
var wrapper struct {
Issues []IssueInput `json:"issues"`
}
if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.Issues != nil {
return wrapper.Issues, nil
}
var issue IssueInput
if err := json.Unmarshal(data, &issue); err == nil && issue.Title != "" {
return []IssueInput{issue}, nil
}
return nil, fmt.Errorf("parse issue inputs: expected a single issue, an array, or an object with an issues field")
}
func readHealthInput(path string) (HealthInput, error) {
data, err := os.ReadFile(path)
if err != nil {
return HealthInput{}, fmt.Errorf("read health input: %w", err)
}
var input HealthInput
if err := json.Unmarshal(data, &input); err != nil {
return HealthInput{}, fmt.Errorf("parse health input: %w", err)
}
return input, nil
}
func filterIssueInputs(issues []IssueInput, state string, limit int) []IssueInput {
filtered := make([]IssueInput, 0, len(issues))
for _, issue := range issues {
if state != "" && state != "all" && issue.State != "" && !strings.EqualFold(issue.State, state) {
continue
}
filtered = append(filtered, issue)
if limit > 0 && len(filtered) >= limit {
break
}
}
return filtered
}
func repositoryFromContext(ctx *common.RuntimeContext, fallback string) string {
if ctx.Owner != "" && ctx.Repo != "" {
return ctx.Owner + "/" + ctx.Repo
}
if fallback != "" {
return fallback
}
return "local"
}
func parseCSV(value string) []string {
if strings.TrimSpace(value) == "" {
return nil
}
parts := strings.Split(value, ",")
result := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part != "" {
result = append(result, part)
}
}
return result
}
func parseBoolArg(value string) bool {
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
return err == nil && parsed
}
func parseIntArg(value string, defaultValue int, name string) (int, error) {
value = strings.TrimSpace(value)
if value == "" {
return defaultValue, nil
}
parsed, err := strconv.Atoi(value)
if err != nil {
return 0, fmt.Errorf("invalid --%s %q: must be an integer", name, value)
}
if parsed < 0 {
return 0, fmt.Errorf("invalid --%s %q: must be non-negative", name, value)
}
return parsed, nil
}
func mustParseInt(value string, defaultValue int) int {
parsed, err := parseIntArg(value, defaultValue, "value")
if err != nil {
return defaultValue
}
return parsed
}

View File

@ -0,0 +1,232 @@
package workflow
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestShortcutsExposesWorkflowCommands(t *testing.T) {
shortcuts := Shortcuts()
names := map[string]bool{}
for _, shortcut := range shortcuts {
names[shortcut.Name] = true
}
if !names["triage"] {
t.Fatal("Shortcuts missing triage")
}
if !names["health"] {
t.Fatal("Shortcuts missing health")
}
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) {
ctx := &common.RuntimeContext{
Format: "json",
Args: map[string]string{
"title": "Token leaked in output",
"body": "A secret token leaked from logs.",
"number": "7",
"state": "open",
"limit": "30",
"dry-run": "true",
"lang": "en",
},
}
issues, err := collectIssuesFromArgs(ctx)
if err != nil {
t.Fatalf("collectIssuesFromArgs returned error: %v", err)
}
if len(issues) != 1 {
t.Fatalf("len(issues) = %d, want 1", len(issues))
}
result := AnalyzeIssue(issues[0], ctx.Arg("lang"))
if result.DetectedType != IssueTypeSecurity {
t.Fatalf("DetectedType = %q, want %q", result.DetectedType, IssueTypeSecurity)
}
}
func TestReadIssueInputsFromJSONFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "issues.json")
data := []IssueInput{{
Number: 1,
Title: "README typo",
State: "open",
}}
writeJSONFixture(t, path, map[string]interface{}{"issues": data})
issues, err := readIssueInputs(path)
if err != nil {
t.Fatalf("readIssueInputs returned error: %v", err)
}
if len(issues) != 1 {
t.Fatalf("len(issues) = %d, want 1", len(issues))
}
if issues[0].Title != "README typo" {
t.Fatalf("Title = %q, want README typo", issues[0].Title)
}
}
func TestRenderHealthMarkdown(t *testing.T) {
result := ScoreHealth(HealthInput{
Repository: "owner/repo",
OpenIssues: 1,
OpenPRs: 1,
RecentActivityKnown: true,
RecentActivityDays: 1,
ReleaseKnown: true,
HasRecentRelease: true,
HasReadme: true,
HasLicense: true,
HasContributing: true,
AgentReadinessKnown: true,
AgentReadinessScore: 9,
}, "en")
var buf bytes.Buffer
if err := renderHealthResult(&buf, result, "markdown"); err != nil {
t.Fatalf("renderHealthResult returned error: %v", err)
}
if !bytes.Contains(buf.Bytes(), []byte("# Repository Health Report")) {
t.Fatalf("markdown output missing title: %s", buf.String())
}
}
func TestRunTriageRemoteModeUsesFetch(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)
}
writeWorkflowJSON(t, w, map[string]interface{}{
"issues": []map[string]interface{}{
{
"number": 7,
"title": "Token leaked in logs",
"body": "The access token appears in command output.",
"state": "open",
"author": map[string]interface{}{"login": "bob"},
"labels": []map[string]interface{}{{"name": "security"}},
"created_at": "2026-05-01T00:00:00Z",
"updated_at": "2026-05-02T00:00:00Z",
"html_url": "https://example.com/issues/7",
"comments": 1,
},
},
})
}))
defer server.Close()
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: map[string]string{
"limit": "10",
"state": "open",
"dry-run": "true",
"lang": "en",
},
}
if err := runTriage(ctx); err != nil {
t.Fatalf("runTriage returned error: %v", err)
}
}
func TestRunHealthRemoteModeUsesFetch(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-19T00: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{}{}})
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()
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: map[string]string{
"lang": "en",
},
}
if err := runHealth(ctx); err != nil {
t.Fatalf("runHealth returned error: %v", err)
}
}
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)
if err != nil {
t.Fatalf("json.Marshal returned error: %v", err)
}
if err := os.WriteFile(path, encoded, 0600); err != nil {
t.Fatalf("write fixture returned error: %v", err)
}
}

View File

@ -101,6 +101,49 @@ gitlink-cli pr +list --state merged --format json
gitlink-cli api GET /:owner/:repo/activity --format json
```
## Workflow: PR Summary (Read-only)
Use `workflow +pr-summary` when a maintainer or Agent needs a structured PR review summary, review focus, test suggestions, or a markdown report that can be copied into a PR discussion.
```bash
# Read-only GitLink fetch mode
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
# Local JSON input mode for Agent pipelines
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json
```
Rules:
- Prefer `--format json` when another Agent consumes the output.
- Prefer `--format markdown` when a human maintainer needs a report.
- This command is read-only: it does not comment, approve, reject, merge, label, or close pull requests.
- Do not use LLM APIs for this workflow; it is rule-based and explainable.
## Workflow: Repo 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.
适用于需要生成仓库治理报告、比赛材料、维护者汇总或 Agent 综合分析的场景。
```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 json
```
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 perform remote write operations.
- 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` 以便解析输出