Merge pull request '新增 workflow Shortcut 命令,支持 Issue 分诊、PR 摘要与仓库报告' (#29) from muel/gitlink-cli:workflow-agent-suite into master
This commit is contained in:
commit
42bc83295a
|
|
@ -0,0 +1,2 @@
|
|||
|
||||
gitlink-cli.exe
|
||||
105
README.md
105
README.md
|
|
@ -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?
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -1 +0,0 @@
|
|||
PR Test 2026年 4月 7日 星期二 11时45分56秒 CST
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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, ¬es)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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": "暂无额外判断依据。",
|
||||
},
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,333 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type PRFetchOptions struct {
|
||||
Owner string
|
||||
Repo string
|
||||
Number int
|
||||
IncludeFiles bool
|
||||
IncludeCommits bool
|
||||
MaxFiles int
|
||||
MaxCommits int
|
||||
}
|
||||
|
||||
func FetchPRSummaryInput(ctx *common.RuntimeContext, opts PRFetchOptions) (PRSummaryInput, []ScoringNote, error) {
|
||||
owner, repo, err := resolveFetchRepo(ctx, opts.Owner, opts.Repo)
|
||||
if err != nil {
|
||||
return PRSummaryInput{}, nil, err
|
||||
}
|
||||
if opts.Number <= 0 {
|
||||
return PRSummaryInput{}, nil, fmt.Errorf("pull request number is required")
|
||||
}
|
||||
if opts.MaxFiles <= 0 {
|
||||
opts.MaxFiles = 100
|
||||
}
|
||||
if opts.MaxCommits <= 0 {
|
||||
opts.MaxCommits = 100
|
||||
}
|
||||
|
||||
input, err := fetchPRBase(ctx, owner, repo, opts.Number)
|
||||
if err != nil {
|
||||
return PRSummaryInput{}, nil, err
|
||||
}
|
||||
input.Repository = fmt.Sprintf("%s/%s", owner, repo)
|
||||
input.Number = opts.Number
|
||||
input.Source = "remote-read-only-fetch"
|
||||
|
||||
notes := []ScoringNote{}
|
||||
if opts.IncludeFiles {
|
||||
files, err := fetchPRFiles(ctx, owner, repo, opts.Number, opts.MaxFiles)
|
||||
if err != nil {
|
||||
notes = append(notes, ScoringNote{Metric: "pr_files", Note: fmt.Sprintf("changed files probe failed: %v", err)})
|
||||
} else {
|
||||
input.ChangedFiles = files
|
||||
fillPRLineTotals(&input)
|
||||
}
|
||||
}
|
||||
if opts.IncludeCommits {
|
||||
commits, err := fetchPRCommits(ctx, owner, repo, opts.Number, opts.MaxCommits)
|
||||
if err != nil {
|
||||
notes = append(notes, ScoringNote{Metric: "pr_commits", Note: fmt.Sprintf("commits probe failed: %v", err)})
|
||||
} else {
|
||||
input.Commits = commits
|
||||
}
|
||||
}
|
||||
|
||||
return input, notes, nil
|
||||
}
|
||||
|
||||
func fetchPRBase(ctx *common.RuntimeContext, owner, repo string, number int) (PRSummaryInput, error) {
|
||||
env, err := ctx.CallAPI("GET", prPath(owner, repo, number), nil)
|
||||
if err != nil {
|
||||
return PRSummaryInput{}, fmt.Errorf("fetch pull request summary: %w", err)
|
||||
}
|
||||
item := prAPIObject(env.Data)
|
||||
if item == nil {
|
||||
return PRSummaryInput{}, fmt.Errorf("fetch pull request summary: PR response did not contain an object")
|
||||
}
|
||||
input, ok := normalizePRSummaryItem(item)
|
||||
if !ok {
|
||||
return PRSummaryInput{}, fmt.Errorf("fetch pull request summary: PR response missing title or number")
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func fetchPRFiles(ctx *common.RuntimeContext, owner, repo string, number, limit int) ([]PRChangedFile, error) {
|
||||
query := url.Values{}
|
||||
query.Set("page", "1")
|
||||
query.Set("limit", fmt.Sprintf("%d", limit))
|
||||
env, err := ctx.CallAPIWithQuery("GET", prPath(owner, repo, number)+"/files", query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawItems := apiList(env.Data)
|
||||
files := make([]PRChangedFile, 0, len(rawItems))
|
||||
for _, raw := range rawItems {
|
||||
file, ok := normalizePRChangedFile(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
files = append(files, file)
|
||||
if len(files) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return files, nil
|
||||
}
|
||||
|
||||
func fetchPRCommits(ctx *common.RuntimeContext, owner, repo string, number, limit int) ([]PRCommit, error) {
|
||||
query := url.Values{}
|
||||
query.Set("page", "1")
|
||||
query.Set("limit", fmt.Sprintf("%d", limit))
|
||||
env, err := ctx.CallAPIWithQuery("GET", prPath(owner, repo, number)+"/commits", query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rawItems := apiList(env.Data)
|
||||
commits := make([]PRCommit, 0, len(rawItems))
|
||||
for _, raw := range rawItems {
|
||||
commit, ok := normalizePRCommit(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
commits = append(commits, commit)
|
||||
if len(commits) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return commits, nil
|
||||
}
|
||||
|
||||
func prPath(owner, repo string, number int) string {
|
||||
return fmt.Sprintf("%s/pulls/%d", workflowRepoPath(owner, repo), number)
|
||||
}
|
||||
|
||||
func prAPIObject(data interface{}) map[string]interface{} {
|
||||
normalized, err := normalizeAPIData(data)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
switch value := normalized.(type) {
|
||||
case map[string]interface{}:
|
||||
for _, key := range []string{"pull_request", "data", "pr"} {
|
||||
if raw, ok := value[key]; ok {
|
||||
if item := prAPIObject(raw); item != nil {
|
||||
return item
|
||||
}
|
||||
}
|
||||
}
|
||||
return value
|
||||
case []interface{}:
|
||||
if len(value) == 1 {
|
||||
if item, ok := value[0].(map[string]interface{}); ok {
|
||||
return item
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizePRSummaryItem(item map[string]interface{}) (PRSummaryInput, bool) {
|
||||
number := firstPRInt(item, "number", "iid", "pull_request_number")
|
||||
title := firstPRString(item, "title", "subject")
|
||||
if number == 0 && strings.TrimSpace(title) == "" {
|
||||
return PRSummaryInput{}, false
|
||||
}
|
||||
body := firstPRString(item, "body", "description", "content")
|
||||
state := firstPRString(item, "state", "status")
|
||||
author := firstPRAuthor(item)
|
||||
base := firstPRBranch(item, "base_branch", "target_branch", "base")
|
||||
head := firstPRBranch(item, "head_branch", "source_branch", "head")
|
||||
additions := firstPRInt(item, "additions", "additions_count")
|
||||
deletions := firstPRInt(item, "deletions", "deletions_count")
|
||||
|
||||
return PRSummaryInput{
|
||||
Number: number,
|
||||
Title: title,
|
||||
Author: author,
|
||||
State: state,
|
||||
BaseBranch: base,
|
||||
HeadBranch: head,
|
||||
Body: body,
|
||||
Additions: additions,
|
||||
Deletions: deletions,
|
||||
}, true
|
||||
}
|
||||
|
||||
func normalizePRChangedFile(raw interface{}) (PRChangedFile, bool) {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return PRChangedFile{}, false
|
||||
}
|
||||
filename := firstPRString(item, "filename", "file", "path", "new_path")
|
||||
if strings.TrimSpace(filename) == "" {
|
||||
return PRChangedFile{}, false
|
||||
}
|
||||
additions := firstPRInt(item, "additions", "additions_count")
|
||||
deletions := firstPRInt(item, "deletions", "deletions_count")
|
||||
changes := firstPRInt(item, "changes", "total_changes")
|
||||
if changes == 0 {
|
||||
changes = additions + deletions
|
||||
}
|
||||
return PRChangedFile{
|
||||
Filename: filename,
|
||||
Status: firstPRString(item, "status", "state"),
|
||||
Additions: additions,
|
||||
Deletions: deletions,
|
||||
Changes: changes,
|
||||
Patch: firstPRString(item, "patch", "diff"),
|
||||
}, true
|
||||
}
|
||||
|
||||
func normalizePRCommit(raw interface{}) (PRCommit, bool) {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return PRCommit{}, false
|
||||
}
|
||||
sha := firstPRString(item, "sha", "id")
|
||||
message := firstPRString(item, "message", "title")
|
||||
if strings.TrimSpace(sha) == "" && strings.TrimSpace(message) == "" {
|
||||
return PRCommit{}, false
|
||||
}
|
||||
return PRCommit{
|
||||
SHA: sha,
|
||||
Message: firstLine(message),
|
||||
Author: firstPRCommitAuthor(item),
|
||||
Date: firstPRTime(item, "date", "committed_at", "created_at"),
|
||||
}, true
|
||||
}
|
||||
|
||||
func fillPRLineTotals(input *PRSummaryInput) {
|
||||
if input == nil || len(input.ChangedFiles) == 0 {
|
||||
return
|
||||
}
|
||||
additions := 0
|
||||
deletions := 0
|
||||
for _, file := range input.ChangedFiles {
|
||||
additions += file.Additions
|
||||
deletions += file.Deletions
|
||||
}
|
||||
if input.Additions == 0 {
|
||||
input.Additions = additions
|
||||
}
|
||||
if input.Deletions == 0 {
|
||||
input.Deletions = deletions
|
||||
}
|
||||
}
|
||||
|
||||
func firstPRString(item map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := item[key]; ok {
|
||||
if s := apiString(value); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstPRInt(item map[string]interface{}, keys ...string) int {
|
||||
for _, key := range keys {
|
||||
if value, ok := item[key]; ok {
|
||||
if n := apiInt(value); n != 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstPRTime(item map[string]interface{}, keys ...string) time.Time {
|
||||
for _, key := range keys {
|
||||
if value, ok := item[key]; ok {
|
||||
if t := apiTime(value); !t.IsZero() {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func firstPRAuthor(item map[string]interface{}) string {
|
||||
for _, key := range []string{"author", "user", "creator"} {
|
||||
if value, ok := item[key]; ok {
|
||||
if s := apiAuthor(value); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstPRCommitAuthor(item map[string]interface{}) string {
|
||||
for _, key := range []string{"author", "committer", "user", "creator"} {
|
||||
if value, ok := item[key]; ok {
|
||||
if s := apiAuthor(value); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstPRBranch(item map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := item[key]; ok {
|
||||
if s := prBranchString(value); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func prBranchString(value interface{}) string {
|
||||
switch typed := value.(type) {
|
||||
case map[string]interface{}:
|
||||
for _, key := range []string{"ref", "name", "branch", "title"} {
|
||||
if s := apiString(typed[key]); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
default:
|
||||
return apiString(value)
|
||||
}
|
||||
}
|
||||
|
||||
func firstLine(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
lines := strings.Split(value, "\n")
|
||||
return strings.TrimSpace(lines[0])
|
||||
}
|
||||
|
|
@ -0,0 +1,216 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchPRSummaryInputNormalizesResponseShapes(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/13.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"pull_request_number": 13,
|
||||
"title": "feat: add workflow PR summary",
|
||||
"description": "Summarize pull requests without writing remote data.",
|
||||
"status": "open",
|
||||
"creator": map[string]interface{}{"name": "carol"},
|
||||
"target_branch": "master",
|
||||
"source_branch": "feature/pr-summary",
|
||||
"additions": 100,
|
||||
"deletions": 4,
|
||||
},
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/13/files.json":
|
||||
if got := r.URL.Query().Get("limit"); got != "100" {
|
||||
t.Fatalf("files limit = %q, want 100", got)
|
||||
}
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"files": []map[string]interface{}{
|
||||
{"new_path": "shortcuts/workflow/pr_summary.go", "status": "added", "additions": 80, "deletions": 0},
|
||||
{"filename": "docs/workflow-agent-design.md", "status": "modified", "additions": 20, "deletions": 4},
|
||||
},
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/13/commits.json":
|
||||
writeWorkflowJSON(t, w, []map[string]interface{}{
|
||||
{"id": "abc123", "title": "feat: add workflow PR summary", "committer": map[string]interface{}{"login": "carol"}, "created_at": "2026-05-20T10:00:00Z"},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input, notes, err := FetchPRSummaryInput(workflowTestContext(server), PRFetchOptions{
|
||||
Number: 13,
|
||||
IncludeFiles: true,
|
||||
IncludeCommits: true,
|
||||
MaxFiles: 100,
|
||||
MaxCommits: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchPRSummaryInput returned error: %v", err)
|
||||
}
|
||||
if len(notes) != 0 {
|
||||
t.Fatalf("notes = %v, want empty", notes)
|
||||
}
|
||||
if input.Repository != "owner/repo" || input.Number != 13 {
|
||||
t.Fatalf("input = %+v, want owner/repo #13", input)
|
||||
}
|
||||
if input.Author != "carol" || input.BaseBranch != "master" || input.HeadBranch != "feature/pr-summary" {
|
||||
t.Fatalf("author/branches = %q %q %q, want carol master feature/pr-summary", input.Author, input.BaseBranch, input.HeadBranch)
|
||||
}
|
||||
if len(input.ChangedFiles) != 2 {
|
||||
t.Fatalf("len(ChangedFiles) = %d, want 2", len(input.ChangedFiles))
|
||||
}
|
||||
if len(input.Commits) != 1 || input.Commits[0].SHA != "abc123" || input.Commits[0].Author != "carol" {
|
||||
t.Fatalf("Commits = %+v, want normalized commit", input.Commits)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchPRSummaryInputPartialFilesFailure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/2.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"pull_request": map[string]interface{}{
|
||||
"number": 2,
|
||||
"title": "fix: handle API errors",
|
||||
"user": map[string]interface{}{"login": "bob"},
|
||||
},
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/2/files.json":
|
||||
http.Error(w, "files unavailable", http.StatusInternalServerError)
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/2/commits.json":
|
||||
writeWorkflowJSON(t, w, []map[string]interface{}{{"sha": "abc", "message": "fix: handle API errors"}})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input, notes, err := FetchPRSummaryInput(workflowTestContext(server), PRFetchOptions{
|
||||
Number: 2,
|
||||
IncludeFiles: true,
|
||||
IncludeCommits: true,
|
||||
MaxFiles: 10,
|
||||
MaxCommits: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchPRSummaryInput returned error: %v", err)
|
||||
}
|
||||
if input.Title != "fix: handle API errors" {
|
||||
t.Fatalf("Title = %q, want base PR to remain", input.Title)
|
||||
}
|
||||
if len(notes) == 0 || !strings.Contains(notes[0].Metric, "pr_files") {
|
||||
t.Fatalf("notes = %v, want pr_files note", notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchPRSummaryInputPartialCommitsFailure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/3.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"number": 3,
|
||||
"title": "docs: update README",
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/3/files.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"files": []map[string]interface{}{{"filename": "README.md"}}})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/3/commits.json":
|
||||
http.Error(w, "commits unavailable", http.StatusServiceUnavailable)
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input, notes, err := FetchPRSummaryInput(workflowTestContext(server), PRFetchOptions{
|
||||
Number: 3,
|
||||
IncludeFiles: true,
|
||||
IncludeCommits: true,
|
||||
MaxFiles: 10,
|
||||
MaxCommits: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchPRSummaryInput returned error: %v", err)
|
||||
}
|
||||
if len(input.ChangedFiles) != 1 {
|
||||
t.Fatalf("len(ChangedFiles) = %d, want 1", len(input.ChangedFiles))
|
||||
}
|
||||
if len(notes) == 0 || !strings.Contains(notes[0].Metric, "pr_commits") {
|
||||
t.Fatalf("notes = %v, want pr_commits note", notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchPRSummaryInputReportsErrorInBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"status": 403,
|
||||
"message": "permission denied",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, _, err := FetchPRSummaryInput(workflowTestContext(server), PRFetchOptions{Number: 9, IncludeFiles: true})
|
||||
if err == nil {
|
||||
t.Fatal("FetchPRSummaryInput returned nil error for error-in-body")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "permission denied") {
|
||||
t.Fatalf("error = %v, want permission denied", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchPRSummaryInputRespectsLimits(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/4.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"data": map[string]interface{}{"number": 4, "title": "feat: limit lists"},
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/4/files.json":
|
||||
if got := r.URL.Query().Get("limit"); got != "1" {
|
||||
t.Fatalf("files limit = %q, want 1", got)
|
||||
}
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"files": []map[string]interface{}{
|
||||
{"filename": "one.go"},
|
||||
{"filename": "two.go"},
|
||||
},
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/4/commits.json":
|
||||
if got := r.URL.Query().Get("limit"); got != "1" {
|
||||
t.Fatalf("commits limit = %q, want 1", got)
|
||||
}
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"commits": []map[string]interface{}{
|
||||
{"sha": "one", "message": "feat: first"},
|
||||
{"sha": "two", "message": "feat: second"},
|
||||
},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input, notes, err := FetchPRSummaryInput(workflowTestContext(server), PRFetchOptions{
|
||||
Number: 4,
|
||||
IncludeFiles: true,
|
||||
IncludeCommits: true,
|
||||
MaxFiles: 1,
|
||||
MaxCommits: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchPRSummaryInput returned error: %v", err)
|
||||
}
|
||||
if len(notes) != 0 {
|
||||
t.Fatalf("notes = %v, want empty", notes)
|
||||
}
|
||||
if len(input.ChangedFiles) != 1 || len(input.Commits) != 1 {
|
||||
t.Fatalf("files/commits lengths = %d/%d, want 1/1", len(input.ChangedFiles), len(input.Commits))
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,676 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
PRChangeTypeDocs = "docs"
|
||||
PRChangeTypeTest = "test"
|
||||
PRChangeTypeFeature = "feature"
|
||||
PRChangeTypeFix = "fix"
|
||||
PRChangeTypeRefactor = "refactor"
|
||||
PRChangeTypeCI = "ci"
|
||||
PRChangeTypeMixed = "mixed"
|
||||
PRChangeTypeUnknown = "unknown"
|
||||
)
|
||||
|
||||
const (
|
||||
PRRiskLow = "low"
|
||||
PRRiskMedium = "medium"
|
||||
PRRiskHigh = "high"
|
||||
PRRiskCritical = "critical"
|
||||
)
|
||||
|
||||
type PRSummaryInput struct {
|
||||
Repository string `json:"repository"`
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
State string `json:"state"`
|
||||
BaseBranch string `json:"base_branch"`
|
||||
HeadBranch string `json:"head_branch"`
|
||||
Body string `json:"body,omitempty"`
|
||||
ChangedFiles []PRChangedFile `json:"changed_files"`
|
||||
Commits []PRCommit `json:"commits"`
|
||||
Additions int `json:"additions"`
|
||||
Deletions int `json:"deletions"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type PRChangedFile struct {
|
||||
Filename string `json:"filename"`
|
||||
Status string `json:"status"`
|
||||
Additions int `json:"additions"`
|
||||
Deletions int `json:"deletions"`
|
||||
Changes int `json:"changes"`
|
||||
Patch string `json:"patch,omitempty"`
|
||||
}
|
||||
|
||||
type PRCommit struct {
|
||||
SHA string `json:"sha"`
|
||||
Message string `json:"message"`
|
||||
Author string `json:"author"`
|
||||
Date time.Time `json:"date,omitempty"`
|
||||
}
|
||||
|
||||
type PRSummaryResult struct {
|
||||
Repository string `json:"repository"`
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
State string `json:"state"`
|
||||
BaseBranch string `json:"base_branch"`
|
||||
HeadBranch string `json:"head_branch"`
|
||||
ChangedFilesCount int `json:"changed_files_count"`
|
||||
Additions int `json:"additions"`
|
||||
Deletions int `json:"deletions"`
|
||||
CommitCount int `json:"commit_count"`
|
||||
ChangeType string `json:"change_type"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
ReviewFocus []string `json:"review_focus"`
|
||||
TestSuggestions []string `json:"test_suggestions"`
|
||||
MergeChecklist []string `json:"merge_checklist"`
|
||||
Reasoning []string `json:"reasoning"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
func newPRSummaryShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "pr-summary",
|
||||
Description: "Generate a read-only pull request review summary",
|
||||
Flags: []common.Flag{
|
||||
{Name: "from", Usage: "Read PR summary input from a JSON file"},
|
||||
{Name: "number", Short: "n", Usage: "Pull request number for remote read-only analysis"},
|
||||
{Name: "include-commits", Usage: "Fetch commits in remote mode", Bool: true, Default: "true"},
|
||||
{Name: "include-files", Usage: "Fetch changed files in remote mode", Bool: true, Default: "true"},
|
||||
{Name: "max-files", Usage: "Maximum changed files to analyze", Default: "100"},
|
||||
{Name: "max-commits", Usage: "Maximum commits to analyze", Default: "100"},
|
||||
{Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN},
|
||||
},
|
||||
Run: runPRSummary,
|
||||
}
|
||||
}
|
||||
|
||||
func runPRSummary(ctx *common.RuntimeContext) error {
|
||||
lang := normalizeLang(ctx.Arg("lang"))
|
||||
|
||||
input, notes, err := collectPRSummaryInput(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
result := AnalyzePRSummary(input, lang)
|
||||
for _, note := range notes {
|
||||
if note.Metric == "" && note.Note == "" {
|
||||
continue
|
||||
}
|
||||
result.Reasoning = append(result.Reasoning, fmt.Sprintf("%s: %s", note.Metric, note.Note))
|
||||
}
|
||||
|
||||
format := ctx.Format
|
||||
if strings.TrimSpace(cmdutil.Format) == "" {
|
||||
format = "table"
|
||||
}
|
||||
rendered, err := RenderPRSummary(result, format, lang)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprint(os.Stdout, rendered)
|
||||
return err
|
||||
}
|
||||
|
||||
func collectPRSummaryInput(ctx *common.RuntimeContext) (PRSummaryInput, []ScoringNote, error) {
|
||||
if path := strings.TrimSpace(ctx.Arg("from")); path != "" {
|
||||
input, err := readPRSummaryInput(path)
|
||||
if err != nil {
|
||||
return PRSummaryInput{}, nil, err
|
||||
}
|
||||
if strings.TrimSpace(input.Source) == "" {
|
||||
input.Source = "local-json"
|
||||
}
|
||||
return input, nil, nil
|
||||
}
|
||||
|
||||
number, err := parseIntArg(ctx.Arg("number"), 0, "number")
|
||||
if err != nil {
|
||||
return PRSummaryInput{}, nil, err
|
||||
}
|
||||
if number <= 0 {
|
||||
return PRSummaryInput{}, nil, fmt.Errorf("workflow +pr-summary requires --from pr_summary.json or --number with --owner and --repo for read-only fetch")
|
||||
}
|
||||
maxFiles, err := parseIntArg(ctx.Arg("max-files"), 100, "max-files")
|
||||
if err != nil {
|
||||
return PRSummaryInput{}, nil, err
|
||||
}
|
||||
maxCommits, err := parseIntArg(ctx.Arg("max-commits"), 100, "max-commits")
|
||||
if err != nil {
|
||||
return PRSummaryInput{}, nil, err
|
||||
}
|
||||
|
||||
return FetchPRSummaryInput(ctx, PRFetchOptions{
|
||||
Number: number,
|
||||
IncludeFiles: parseBoolArg(ctx.Arg("include-files")),
|
||||
IncludeCommits: parseBoolArg(ctx.Arg("include-commits")),
|
||||
MaxFiles: maxFiles,
|
||||
MaxCommits: maxCommits,
|
||||
})
|
||||
}
|
||||
|
||||
func readPRSummaryInput(path string) (PRSummaryInput, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return PRSummaryInput{}, fmt.Errorf("read PR summary input: %w", err)
|
||||
}
|
||||
var input PRSummaryInput
|
||||
if err := json.Unmarshal(data, &input); err != nil {
|
||||
return PRSummaryInput{}, fmt.Errorf("parse PR summary input: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(input.Title) == "" && input.Number == 0 {
|
||||
return PRSummaryInput{}, fmt.Errorf("parse PR summary input: expected PRSummaryInput root object")
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func AnalyzePRSummary(input PRSummaryInput, lang string) PRSummaryResult {
|
||||
lang = normalizeLang(lang)
|
||||
scores, scoreReasons := scorePRChangeTypes(input)
|
||||
changeType := choosePRChangeType(scores)
|
||||
riskLevel, riskReasons := scorePRRisk(input, changeType)
|
||||
reviewFocus := buildPRReviewFocus(input, lang, riskLevel)
|
||||
testSuggestions := buildPRTestSuggestions(input, lang)
|
||||
mergeChecklist := buildPRMergeChecklist(input, lang, riskLevel)
|
||||
reasoning := buildPRReasoning(lang, changeType, riskLevel, scores, scoreReasons, riskReasons)
|
||||
|
||||
source := strings.TrimSpace(input.Source)
|
||||
if source == "" {
|
||||
source = "local"
|
||||
}
|
||||
|
||||
return PRSummaryResult{
|
||||
Repository: input.Repository,
|
||||
Number: input.Number,
|
||||
Title: input.Title,
|
||||
Author: input.Author,
|
||||
State: input.State,
|
||||
BaseBranch: input.BaseBranch,
|
||||
HeadBranch: input.HeadBranch,
|
||||
ChangedFilesCount: len(input.ChangedFiles),
|
||||
Additions: input.Additions,
|
||||
Deletions: input.Deletions,
|
||||
CommitCount: len(input.Commits),
|
||||
ChangeType: changeType,
|
||||
RiskLevel: riskLevel,
|
||||
ReviewFocus: reviewFocus,
|
||||
TestSuggestions: testSuggestions,
|
||||
MergeChecklist: mergeChecklist,
|
||||
Reasoning: reasoning,
|
||||
Source: source,
|
||||
}
|
||||
}
|
||||
|
||||
func scorePRChangeTypes(input PRSummaryInput) (map[string]int, []string) {
|
||||
scores := map[string]int{
|
||||
PRChangeTypeDocs: 0,
|
||||
PRChangeTypeTest: 0,
|
||||
PRChangeTypeFeature: 0,
|
||||
PRChangeTypeFix: 0,
|
||||
PRChangeTypeRefactor: 0,
|
||||
PRChangeTypeCI: 0,
|
||||
}
|
||||
reasons := []string{}
|
||||
|
||||
for _, file := range input.ChangedFiles {
|
||||
path := normalizedPath(file.Filename)
|
||||
switch {
|
||||
case isDocsPath(path):
|
||||
scores[PRChangeTypeDocs] += 2
|
||||
reasons = append(reasons, "file:docs")
|
||||
case isTestPath(path):
|
||||
scores[PRChangeTypeTest] += 2
|
||||
reasons = append(reasons, "file:test")
|
||||
case isCIPath(path):
|
||||
scores[PRChangeTypeCI] += 2
|
||||
reasons = append(reasons, "file:ci")
|
||||
}
|
||||
}
|
||||
|
||||
text := strings.ToLower(prTextCorpus(input, false))
|
||||
addKeywordScore(scores, &reasons, text, PRChangeTypeDocs, []string{"doc", "docs", "documentation", "readme", "typo", "example", "guide"})
|
||||
addKeywordScore(scores, &reasons, text, PRChangeTypeTest, []string{"test", "tests", "coverage"})
|
||||
addKeywordScore(scores, &reasons, text, PRChangeTypeFeature, []string{"feat", "feature", "add", "support", "implement"})
|
||||
addKeywordScore(scores, &reasons, text, PRChangeTypeFix, []string{"fix", "bug", "error", "crash", "resolve"})
|
||||
addKeywordScore(scores, &reasons, text, PRChangeTypeRefactor, []string{"refactor", "cleanup", "simplify", "restructure"})
|
||||
addKeywordScore(scores, &reasons, text, PRChangeTypeCI, []string{"ci", "workflow", "action", "build", "pipeline"})
|
||||
|
||||
return scores, uniqueStrings(reasons)
|
||||
}
|
||||
|
||||
func choosePRChangeType(scores map[string]int) string {
|
||||
topType := PRChangeTypeUnknown
|
||||
topScore := 0
|
||||
secondScore := 0
|
||||
hits := []string{}
|
||||
for _, kind := range []string{PRChangeTypeDocs, PRChangeTypeTest, PRChangeTypeFeature, PRChangeTypeFix, PRChangeTypeRefactor, PRChangeTypeCI} {
|
||||
score := scores[kind]
|
||||
if score <= 0 {
|
||||
continue
|
||||
}
|
||||
hits = append(hits, kind)
|
||||
if score > topScore {
|
||||
secondScore = topScore
|
||||
topScore = score
|
||||
topType = kind
|
||||
} else if score > secondScore {
|
||||
secondScore = score
|
||||
}
|
||||
}
|
||||
if len(hits) == 0 {
|
||||
return PRChangeTypeUnknown
|
||||
}
|
||||
if len(hits) == 1 {
|
||||
return topType
|
||||
}
|
||||
if len(hits) == 2 && containsString(hits, PRChangeTypeDocs) && containsString(hits, PRChangeTypeTest) {
|
||||
return topType
|
||||
}
|
||||
if topScore >= secondScore+2 {
|
||||
return topType
|
||||
}
|
||||
return PRChangeTypeMixed
|
||||
}
|
||||
|
||||
func addKeywordScore(scores map[string]int, reasons *[]string, text, kind string, keywords []string) {
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(text, keyword) {
|
||||
scores[kind]++
|
||||
*reasons = append(*reasons, "keyword:"+kind+":"+keyword)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func scorePRRisk(input PRSummaryInput, changeType string) (string, []string) {
|
||||
corpus := strings.ToLower(prTextCorpus(input, true))
|
||||
reasons := []string{}
|
||||
if containsAny(corpus, []string{"token", "secret", "credential", "permission", "vulnerability", "auth bypass", "permission bypass", "leak"}) {
|
||||
return PRRiskCritical, []string{"security-sensitive keyword"}
|
||||
}
|
||||
if touchesCodePath(input, []string{"shortcuts/", "cmd/", "internal/client"}) &&
|
||||
containsAny(corpus, []string{"merge", "approve", "comment", "label", "close", "refuse", "journal"}) {
|
||||
return PRRiskCritical, []string{"possible remote write operation"}
|
||||
}
|
||||
|
||||
if touchesCodePath(input, []string{"internal/client", "internal/auth", "internal/config", "cmd/"}) {
|
||||
reasons = append(reasons, "high-risk core path")
|
||||
}
|
||||
if touchesExactPath(input, "shortcuts/register.go") {
|
||||
reasons = append(reasons, "command registration changed")
|
||||
}
|
||||
if input.Deletions >= 200 || deletedFiles(input) >= 5 {
|
||||
reasons = append(reasons, "large deletion")
|
||||
}
|
||||
if len(reasons) > 0 {
|
||||
return PRRiskHigh, reasons
|
||||
}
|
||||
|
||||
if changeType == PRChangeTypeDocs || changeType == PRChangeTypeTest {
|
||||
if len(input.ChangedFiles) <= 8 && input.Deletions <= 80 {
|
||||
return PRRiskLow, []string{"docs-or-tests only"}
|
||||
}
|
||||
}
|
||||
if touchesGoCode(input) || touchesCodePath(input, []string{"shortcuts/"}) {
|
||||
return PRRiskMedium, []string{"go or shortcut code changed"}
|
||||
}
|
||||
return PRRiskLow, []string{"low-risk file scope"}
|
||||
}
|
||||
|
||||
func buildPRReviewFocus(input PRSummaryInput, lang, riskLevel string) []string {
|
||||
focus := []string{}
|
||||
if touchesCodePath(input, []string{"shortcuts/"}) {
|
||||
focus = append(focus, prFocusText(lang, "shortcuts"))
|
||||
}
|
||||
if touchesExactPath(input, "shortcuts/register.go") {
|
||||
focus = append(focus, prFocusText(lang, "registration"))
|
||||
}
|
||||
if touchesCodePath(input, []string{"internal/client"}) {
|
||||
focus = append(focus, prFocusText(lang, "client"))
|
||||
}
|
||||
if touchesCodePath(input, []string{"internal/output"}) {
|
||||
focus = append(focus, prFocusText(lang, "output"))
|
||||
}
|
||||
if touchesCodePath(input, []string{"internal/auth", "internal/config"}) {
|
||||
focus = append(focus, prFocusText(lang, "auth"))
|
||||
}
|
||||
if touchesDocs(input) {
|
||||
focus = append(focus, prFocusText(lang, "docs"))
|
||||
}
|
||||
if touchesTests(input) {
|
||||
focus = append(focus, prFocusText(lang, "tests"))
|
||||
}
|
||||
if touchesFetchOrAPI(input) {
|
||||
focus = append(focus, prFocusText(lang, "api"))
|
||||
}
|
||||
if riskLevel == PRRiskCritical {
|
||||
focus = append(focus, prFocusText(lang, "security"))
|
||||
}
|
||||
return uniqueStrings(focus)
|
||||
}
|
||||
|
||||
func buildPRTestSuggestions(input PRSummaryInput, lang string) []string {
|
||||
suggestions := []string{}
|
||||
if touchesGoCode(input) {
|
||||
suggestions = append(suggestions, prTestText(lang, "go_all"))
|
||||
}
|
||||
if touchesCodePath(input, []string{"shortcuts/workflow"}) {
|
||||
suggestions = append(suggestions, prTestText(lang, "workflow"))
|
||||
}
|
||||
if touchesDocs(input) {
|
||||
suggestions = append(suggestions, prTestText(lang, "docs"))
|
||||
}
|
||||
if touchesFetchOrAPI(input) {
|
||||
suggestions = append(suggestions, prTestText(lang, "fetch"))
|
||||
}
|
||||
if touchesCodePath(input, []string{"render", "internal/output"}) {
|
||||
suggestions = append(suggestions, prTestText(lang, "render"))
|
||||
}
|
||||
if len(suggestions) == 0 {
|
||||
suggestions = append(suggestions, prTestText(lang, "go_all"))
|
||||
}
|
||||
return uniqueStrings(suggestions)
|
||||
}
|
||||
|
||||
func buildPRMergeChecklist(input PRSummaryInput, lang, riskLevel string) []string {
|
||||
checklist := []string{
|
||||
prChecklistText(lang, "tests"),
|
||||
prChecklistText(lang, "readme"),
|
||||
prChecklistText(lang, "no_write"),
|
||||
prChecklistText(lang, "json_stable"),
|
||||
prChecklistText(lang, "errors"),
|
||||
}
|
||||
if riskLevel == PRRiskCritical || riskLevel == PRRiskHigh {
|
||||
checklist = append(checklist, prChecklistText(lang, "credentials"))
|
||||
}
|
||||
if touchesFetchOrAPI(input) || riskLevel == PRRiskHigh {
|
||||
checklist = append(checklist, prChecklistText(lang, "api_fallback"))
|
||||
}
|
||||
if touchesExactPath(input, "shortcuts/register.go") {
|
||||
checklist = append(checklist, prChecklistText(lang, "registration"))
|
||||
}
|
||||
if touchesCodePath(input, []string{"internal/output", "render"}) {
|
||||
checklist = append(checklist, prChecklistText(lang, "contract"))
|
||||
}
|
||||
return uniqueStrings(checklist)
|
||||
}
|
||||
|
||||
func buildPRReasoning(lang, changeType, riskLevel string, scores map[string]int, scoreReasons, riskReasons []string) []string {
|
||||
reasoning := []string{}
|
||||
if lang == langZH {
|
||||
reasoning = append(reasoning, fmt.Sprintf("变更类型判定:%s", changeType))
|
||||
reasoning = append(reasoning, fmt.Sprintf("风险等级判定:%s", riskLevel))
|
||||
} else {
|
||||
reasoning = append(reasoning, fmt.Sprintf("change type: %s", changeType))
|
||||
reasoning = append(reasoning, fmt.Sprintf("risk level: %s", riskLevel))
|
||||
}
|
||||
for _, kind := range []string{PRChangeTypeDocs, PRChangeTypeTest, PRChangeTypeFeature, PRChangeTypeFix, PRChangeTypeRefactor, PRChangeTypeCI} {
|
||||
if scores[kind] > 0 {
|
||||
if lang == langZH {
|
||||
reasoning = append(reasoning, fmt.Sprintf("规则得分 %s=%d", kind, scores[kind]))
|
||||
} else {
|
||||
reasoning = append(reasoning, fmt.Sprintf("rule score %s=%d", kind, scores[kind]))
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, reason := range append(scoreReasons, riskReasons...) {
|
||||
if lang == langZH {
|
||||
reasoning = append(reasoning, "命中规则:"+reason)
|
||||
} else {
|
||||
reasoning = append(reasoning, "matched rule: "+reason)
|
||||
}
|
||||
}
|
||||
return uniqueStrings(reasoning)
|
||||
}
|
||||
|
||||
func prTextCorpus(input PRSummaryInput, includeFiles bool) string {
|
||||
parts := []string{input.Title, input.Body}
|
||||
for _, commit := range input.Commits {
|
||||
parts = append(parts, commit.Message)
|
||||
}
|
||||
if includeFiles {
|
||||
for _, file := range input.ChangedFiles {
|
||||
parts = append(parts, file.Filename, file.Status, file.Patch)
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n")
|
||||
}
|
||||
|
||||
func prFocusText(lang, key string) string {
|
||||
zh := lang == langZH
|
||||
switch key {
|
||||
case "shortcuts":
|
||||
if zh {
|
||||
return "检查 shortcuts 命令兼容性和参数行为。"
|
||||
}
|
||||
return "Check shortcut command compatibility and flag behavior."
|
||||
case "registration":
|
||||
if zh {
|
||||
return "确认命令注册和 shortcut 挂载兼容。"
|
||||
}
|
||||
return "Confirm command registration and shortcut mounting compatibility."
|
||||
case "client":
|
||||
if zh {
|
||||
return "检查 API 错误处理和响应归一化。"
|
||||
}
|
||||
return "Check API error handling and response normalization."
|
||||
case "output":
|
||||
if zh {
|
||||
return "检查输出格式兼容性和稳定性。"
|
||||
}
|
||||
return "Check output format compatibility and stability."
|
||||
case "auth":
|
||||
if zh {
|
||||
return "检查凭据处理和安全边界。"
|
||||
}
|
||||
return "Check credential handling and security boundaries."
|
||||
case "docs":
|
||||
if zh {
|
||||
return "检查文档示例是否与实现一致。"
|
||||
}
|
||||
return "Check that documentation examples match implementation."
|
||||
case "tests":
|
||||
if zh {
|
||||
return "检查测试是否真实覆盖行为和失败路径。"
|
||||
}
|
||||
return "Check that tests reflect behavior and failure paths."
|
||||
case "api":
|
||||
if zh {
|
||||
return "检查 fetch/API 失败时的降级和归一化。"
|
||||
}
|
||||
return "Check fetch/API failure fallback and normalization."
|
||||
case "security":
|
||||
if zh {
|
||||
return "确认没有凭据泄露或不安全的远端写操作。"
|
||||
}
|
||||
return "Confirm no credential leakage or unsafe remote write operation."
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
func prTestText(lang, key string) string {
|
||||
zh := lang == langZH
|
||||
switch key {
|
||||
case "go_all":
|
||||
if zh {
|
||||
return "运行 `go test ./...`。"
|
||||
}
|
||||
return "Run `go test ./...`."
|
||||
case "workflow":
|
||||
if zh {
|
||||
return "运行 `go test ./shortcuts/workflow`。"
|
||||
}
|
||||
return "Run `go test ./shortcuts/workflow`."
|
||||
case "docs":
|
||||
if zh {
|
||||
return "手动检查 README 和文档示例命令。"
|
||||
}
|
||||
return "Manually check README and documentation examples."
|
||||
case "fetch":
|
||||
if zh {
|
||||
return "运行 httptest mock,必要时执行只读远端 smoke。"
|
||||
}
|
||||
return "Run httptest mocks and a read-only remote smoke check if needed."
|
||||
case "render":
|
||||
if zh {
|
||||
return "验证 json/table/markdown 输出结构。"
|
||||
}
|
||||
return "Verify json/table/markdown output structures."
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
func prChecklistText(lang, key string) string {
|
||||
zh := lang == langZH
|
||||
switch key {
|
||||
case "tests":
|
||||
if zh {
|
||||
return "测试通过。"
|
||||
}
|
||||
return "Tests pass."
|
||||
case "readme":
|
||||
if zh {
|
||||
return "命令行为变化已更新 README。"
|
||||
}
|
||||
return "README updated if command behavior changed."
|
||||
case "no_write":
|
||||
if zh {
|
||||
return "未引入远端写操作。"
|
||||
}
|
||||
return "No remote write operation introduced."
|
||||
case "json_stable":
|
||||
if zh {
|
||||
return "JSON 输出字段保持稳定。"
|
||||
}
|
||||
return "JSON output remains stable."
|
||||
case "errors":
|
||||
if zh {
|
||||
return "错误处理已覆盖。"
|
||||
}
|
||||
return "Error handling is covered."
|
||||
case "credentials":
|
||||
if zh {
|
||||
return "确认没有凭据泄露。"
|
||||
}
|
||||
return "Confirm no credential leakage."
|
||||
case "api_fallback":
|
||||
if zh {
|
||||
return "验证 API 失败时的降级路径。"
|
||||
}
|
||||
return "Verify API failure fallback."
|
||||
case "registration":
|
||||
if zh {
|
||||
return "确认命令注册兼容。"
|
||||
}
|
||||
return "Confirm command registration compatibility."
|
||||
case "contract":
|
||||
if zh {
|
||||
return "复核 Agent 消费的输出协议。"
|
||||
}
|
||||
return "Review output contract for Agent consumers."
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
||||
func normalizedPath(path string) string {
|
||||
path = strings.ReplaceAll(path, "\\", "/")
|
||||
return strings.ToLower(strings.TrimSpace(path))
|
||||
}
|
||||
|
||||
func isDocsPath(path string) bool {
|
||||
return strings.Contains(path, "docs/") ||
|
||||
strings.Contains(path, "/docs/") ||
|
||||
strings.Contains(path, "readme") ||
|
||||
strings.HasSuffix(path, ".md")
|
||||
}
|
||||
|
||||
func isTestPath(path string) bool {
|
||||
return strings.Contains(path, "test") || strings.HasSuffix(path, "_test.go")
|
||||
}
|
||||
|
||||
func isCIPath(path string) bool {
|
||||
return strings.Contains(path, ".github") ||
|
||||
strings.Contains(path, "workflow") ||
|
||||
strings.Contains(path, "ci") ||
|
||||
strings.Contains(path, "build")
|
||||
}
|
||||
|
||||
func touchesDocs(input PRSummaryInput) bool {
|
||||
for _, file := range input.ChangedFiles {
|
||||
if isDocsPath(normalizedPath(file.Filename)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func touchesTests(input PRSummaryInput) bool {
|
||||
for _, file := range input.ChangedFiles {
|
||||
if isTestPath(normalizedPath(file.Filename)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func touchesGoCode(input PRSummaryInput) bool {
|
||||
for _, file := range input.ChangedFiles {
|
||||
if strings.HasSuffix(normalizedPath(file.Filename), ".go") {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func touchesFetchOrAPI(input PRSummaryInput) bool {
|
||||
return touchesCodePath(input, []string{"fetch", "api", "internal/client"})
|
||||
}
|
||||
|
||||
func touchesCodePath(input PRSummaryInput, fragments []string) bool {
|
||||
for _, file := range input.ChangedFiles {
|
||||
path := normalizedPath(file.Filename)
|
||||
for _, fragment := range fragments {
|
||||
if strings.Contains(path, strings.ToLower(fragment)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func touchesExactPath(input PRSummaryInput, target string) bool {
|
||||
target = normalizedPath(target)
|
||||
for _, file := range input.ChangedFiles {
|
||||
if normalizedPath(file.Filename) == target {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func deletedFiles(input PRSummaryInput) int {
|
||||
count := 0
|
||||
for _, file := range input.ChangedFiles {
|
||||
if strings.EqualFold(file.Status, "removed") || strings.EqualFold(file.Status, "deleted") {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
|
@ -0,0 +1,286 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestAnalyzePRSummaryDocsOnlyLowRisk(t *testing.T) {
|
||||
result := AnalyzePRSummary(PRSummaryInput{
|
||||
Repository: "owner/repo",
|
||||
Number: 1,
|
||||
Title: "docs: update README examples",
|
||||
ChangedFiles: []PRChangedFile{
|
||||
{Filename: "README.md", Status: "modified", Additions: 12},
|
||||
{Filename: "docs/workflow-agent-design.md", Status: "modified", Additions: 8},
|
||||
},
|
||||
Additions: 20,
|
||||
}, "en")
|
||||
|
||||
if result.ChangeType != PRChangeTypeDocs {
|
||||
t.Fatalf("ChangeType = %q, want %q", result.ChangeType, PRChangeTypeDocs)
|
||||
}
|
||||
if result.RiskLevel != PRRiskLow {
|
||||
t.Fatalf("RiskLevel = %q, want %q", result.RiskLevel, PRRiskLow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzePRSummaryWorkflowCodeMediumRisk(t *testing.T) {
|
||||
result := AnalyzePRSummary(PRSummaryInput{
|
||||
Repository: "owner/repo",
|
||||
Number: 2,
|
||||
Title: "feat: add PR summary workflow",
|
||||
ChangedFiles: []PRChangedFile{
|
||||
{Filename: "shortcuts/workflow/pr_summary.go", Status: "added", Additions: 80},
|
||||
},
|
||||
Additions: 80,
|
||||
}, "en")
|
||||
|
||||
if result.RiskLevel != PRRiskMedium {
|
||||
t.Fatalf("RiskLevel = %q, want %q", result.RiskLevel, PRRiskMedium)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzePRSummaryInternalClientHighRisk(t *testing.T) {
|
||||
result := AnalyzePRSummary(PRSummaryInput{
|
||||
Repository: "owner/repo",
|
||||
Number: 3,
|
||||
Title: "fix: normalize API errors",
|
||||
ChangedFiles: []PRChangedFile{
|
||||
{Filename: "internal/client/client.go", Status: "modified", Additions: 20, Deletions: 4},
|
||||
},
|
||||
Additions: 20,
|
||||
Deletions: 4,
|
||||
}, "en")
|
||||
|
||||
if result.RiskLevel != PRRiskHigh {
|
||||
t.Fatalf("RiskLevel = %q, want %q", result.RiskLevel, PRRiskHigh)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzePRSummaryAuthTokenCriticalRisk(t *testing.T) {
|
||||
result := AnalyzePRSummary(PRSummaryInput{
|
||||
Repository: "owner/repo",
|
||||
Number: 4,
|
||||
Title: "fix: prevent token permission leak",
|
||||
Body: "Avoid auth bypass and secret exposure.",
|
||||
ChangedFiles: []PRChangedFile{
|
||||
{Filename: "internal/auth/auth.go", Status: "modified", Additions: 20},
|
||||
},
|
||||
}, "en")
|
||||
|
||||
if result.RiskLevel != PRRiskCritical {
|
||||
t.Fatalf("RiskLevel = %q, want %q", result.RiskLevel, PRRiskCritical)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzePRSummaryMixedFiles(t *testing.T) {
|
||||
result := AnalyzePRSummary(PRSummaryInput{
|
||||
Repository: "owner/repo",
|
||||
Number: 5,
|
||||
Title: "feat: add workflow command and docs",
|
||||
ChangedFiles: []PRChangedFile{
|
||||
{Filename: "shortcuts/workflow/pr_summary.go", Status: "added", Additions: 80},
|
||||
{Filename: "docs/workflow-agent-design.md", Status: "modified", Additions: 10},
|
||||
},
|
||||
}, "en")
|
||||
|
||||
if result.ChangeType != PRChangeTypeMixed {
|
||||
t.Fatalf("ChangeType = %q, want %q", result.ChangeType, PRChangeTypeMixed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzePRSummaryChineseText(t *testing.T) {
|
||||
result := AnalyzePRSummary(PRSummaryInput{
|
||||
Repository: "owner/repo",
|
||||
Number: 6,
|
||||
Title: "feat: add workflow command",
|
||||
ChangedFiles: []PRChangedFile{
|
||||
{Filename: "shortcuts/workflow/pr_summary.go", Status: "added", Additions: 80},
|
||||
},
|
||||
}, "zh-CN")
|
||||
|
||||
if len(result.ReviewFocus) == 0 || len(result.TestSuggestions) == 0 || len(result.MergeChecklist) == 0 {
|
||||
t.Fatalf("expected non-empty zh-CN recommendations, got focus=%v suggestions=%v checklist=%v", result.ReviewFocus, result.TestSuggestions, result.MergeChecklist)
|
||||
}
|
||||
joined := strings.Join(append(append(result.ReviewFocus, result.TestSuggestions...), result.MergeChecklist...), "")
|
||||
if !strings.Contains(joined, "检查") && !strings.Contains(joined, "运行") {
|
||||
t.Fatalf("expected zh-CN text, got %q", joined)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRSummaryShortcutFromJSONFile(t *testing.T) {
|
||||
restoreFormat := setCommandFormatForTest(t, "json")
|
||||
defer restoreFormat()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Format: "json",
|
||||
Args: map[string]string{
|
||||
"from": "testdata/pr_summary.json",
|
||||
"lang": "en",
|
||||
},
|
||||
}
|
||||
|
||||
output := captureStdout(t, func() error {
|
||||
return findWorkflowShortcut(t, "pr-summary").Run(ctx)
|
||||
})
|
||||
var result PRSummaryResult
|
||||
if err := json.Unmarshal([]byte(output), &result); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, output)
|
||||
}
|
||||
if result.Number != 1 {
|
||||
t.Fatalf("Number = %d, want 1", result.Number)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRSummaryShortcutRemoteFetch(t *testing.T) {
|
||||
restoreFormat := setCommandFormatForTest(t, "json")
|
||||
defer restoreFormat()
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/5.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"data": map[string]interface{}{
|
||||
"number": 5,
|
||||
"title": "feat: add remote summary",
|
||||
"state": "open",
|
||||
"user": map[string]interface{}{"login": "alice"},
|
||||
"base_branch": "master",
|
||||
"head_branch": "feature/pr-summary",
|
||||
},
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/5/files.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"files": []map[string]interface{}{
|
||||
{"filename": "shortcuts/workflow/pr_summary.go", "status": "added", "additions": 50},
|
||||
},
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/5/commits.json":
|
||||
writeWorkflowJSON(t, w, []map[string]interface{}{
|
||||
{"sha": "abc123", "message": "feat: add remote summary", "author": map[string]interface{}{"name": "alice"}},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: map[string]string{
|
||||
"number": "5",
|
||||
"include-files": "true",
|
||||
"include-commits": "true",
|
||||
"max-files": "100",
|
||||
"max-commits": "100",
|
||||
"lang": "en",
|
||||
},
|
||||
}
|
||||
|
||||
output := captureStdout(t, func() error {
|
||||
return findWorkflowShortcut(t, "pr-summary").Run(ctx)
|
||||
})
|
||||
var result PRSummaryResult
|
||||
if err := json.Unmarshal([]byte(output), &result); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, output)
|
||||
}
|
||||
if result.Number != 5 || result.Source != "remote-read-only-fetch" {
|
||||
t.Fatalf("result = %+v, want number 5 remote source", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPRSummaryShortcutMissingParameters(t *testing.T) {
|
||||
ctx := &common.RuntimeContext{
|
||||
Format: "json",
|
||||
Args: map[string]string{"lang": "en"},
|
||||
}
|
||||
|
||||
err := findWorkflowShortcut(t, "pr-summary").Run(ctx)
|
||||
if err == nil {
|
||||
t.Fatal("Run returned nil error for missing parameters")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "requires --from") {
|
||||
t.Fatalf("error = %v, want clear missing input error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func findWorkflowShortcut(t *testing.T, name string) *common.Shortcut {
|
||||
t.Helper()
|
||||
for _, shortcut := range Shortcuts() {
|
||||
if shortcut.Name == name {
|
||||
return shortcut
|
||||
}
|
||||
}
|
||||
t.Fatalf("shortcut %q not found", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func setCommandFormatForTest(t *testing.T, format string) func() {
|
||||
t.Helper()
|
||||
old := cmdutil.Format
|
||||
cmdutil.Format = format
|
||||
return func() {
|
||||
cmdutil.Format = old
|
||||
}
|
||||
}
|
||||
|
||||
func captureStdout(t *testing.T, fn func() error) string {
|
||||
t.Helper()
|
||||
old := os.Stdout
|
||||
reader, writer, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatalf("os.Pipe returned error: %v", err)
|
||||
}
|
||||
os.Stdout = writer
|
||||
runErr := fn()
|
||||
closeErr := writer.Close()
|
||||
os.Stdout = old
|
||||
if runErr != nil {
|
||||
t.Fatalf("function returned error: %v", runErr)
|
||||
}
|
||||
if closeErr != nil {
|
||||
t.Fatalf("writer.Close returned error: %v", closeErr)
|
||||
}
|
||||
data, err := io.ReadAll(reader)
|
||||
if err != nil {
|
||||
t.Fatalf("io.ReadAll returned error: %v", err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func TestReadPRSummaryInput(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "pr_summary.json")
|
||||
input := PRSummaryInput{Number: 9, Title: "docs: update README", Repository: "owner/repo"}
|
||||
encoded, err := json.Marshal(input)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal returned error: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, encoded, 0600); err != nil {
|
||||
t.Fatalf("os.WriteFile returned error: %v", err)
|
||||
}
|
||||
|
||||
got, err := readPRSummaryInput(path)
|
||||
if err != nil {
|
||||
t.Fatalf("readPRSummaryInput returned error: %v", err)
|
||||
}
|
||||
if got.Number != 9 || got.Title != "docs: update README" {
|
||||
t.Fatalf("got = %+v, want input fields", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -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]) + "..."
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderPRSummaryJSON(t *testing.T) {
|
||||
rendered, err := RenderPRSummary(samplePRSummaryResult(), "json", "en")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderPRSummary returned error: %v", err)
|
||||
}
|
||||
var result PRSummaryResult
|
||||
if err := json.Unmarshal([]byte(rendered), &result); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, rendered)
|
||||
}
|
||||
if result.Number != 42 {
|
||||
t.Fatalf("Number = %d, want 42", result.Number)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPRSummaryMarkdown(t *testing.T) {
|
||||
result := samplePRSummaryResult()
|
||||
rendered, err := RenderPRSummary(result, "markdown", "en")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderPRSummary returned error: %v", err)
|
||||
}
|
||||
for _, want := range []string{result.Title, "Risk level", "Review Focus"} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("markdown output missing %q:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPRSummaryTable(t *testing.T) {
|
||||
rendered, err := RenderPRSummary(samplePRSummaryResult(), "table", "en")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderPRSummary returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(rendered, "#42") || !strings.Contains(rendered, "medium") {
|
||||
t.Fatalf("table output = %q, want PR number and risk", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPRSummaryUnknownFormat(t *testing.T) {
|
||||
_, err := RenderPRSummary(samplePRSummaryResult(), "xml", "en")
|
||||
if err == nil {
|
||||
t.Fatal("RenderPRSummary returned nil error for unknown format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderPRSummaryChineseMarkdown(t *testing.T) {
|
||||
rendered, err := RenderPRSummary(samplePRSummaryResult(), "markdown", "zh-CN")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderPRSummary returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(rendered, "PR 审阅摘要") {
|
||||
t.Fatalf("zh-CN markdown output missing Chinese title:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func samplePRSummaryResult() PRSummaryResult {
|
||||
return PRSummaryResult{
|
||||
Repository: "owner/repo",
|
||||
Number: 42,
|
||||
Title: "feat: add workflow PR summary",
|
||||
Author: "alice",
|
||||
State: "open",
|
||||
BaseBranch: "master",
|
||||
HeadBranch: "feature/pr-summary",
|
||||
ChangedFilesCount: 2,
|
||||
Additions: 100,
|
||||
Deletions: 4,
|
||||
CommitCount: 2,
|
||||
ChangeType: PRChangeTypeFeature,
|
||||
RiskLevel: PRRiskMedium,
|
||||
ReviewFocus: []string{"Check shortcut command compatibility and flag behavior."},
|
||||
TestSuggestions: []string{"Run `go test ./shortcuts/workflow`."},
|
||||
MergeChecklist: []string{"Tests pass."},
|
||||
Reasoning: []string{"change type: feature", "risk level: medium"},
|
||||
Source: "local-json",
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,404 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type RepoReportInput struct {
|
||||
Repository string `json:"repository"`
|
||||
Health *HealthInput `json:"health,omitempty"`
|
||||
Issues []IssueInput `json:"issues,omitempty"`
|
||||
PullRequests []PRSummaryInput `json:"pull_requests,omitempty"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type RepoReportResult struct {
|
||||
Repository string `json:"repository"`
|
||||
Health *HealthResult `json:"health,omitempty"`
|
||||
IssueSummary RepoIssueSummary `json:"issue_summary"`
|
||||
PRSummary RepoPRSummary `json:"pr_summary"`
|
||||
Recommendations []string `json:"recommendations"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
ReportScore int `json:"report_score"`
|
||||
Sections []string `json:"sections"`
|
||||
Reasoning []string `json:"reasoning"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type RepoIssueSummary struct {
|
||||
Total int `json:"total"`
|
||||
ByType map[string]int `json:"by_type"`
|
||||
ByPriority map[string]int `json:"by_priority"`
|
||||
HighRisk int `json:"high_risk"`
|
||||
MissingInfo int `json:"missing_info"`
|
||||
}
|
||||
|
||||
type RepoPRSummary struct {
|
||||
Total int `json:"total"`
|
||||
ByType map[string]int `json:"by_type"`
|
||||
ByRisk map[string]int `json:"by_risk"`
|
||||
HighRisk int `json:"high_risk"`
|
||||
ReviewFocus []string `json:"review_focus"`
|
||||
}
|
||||
|
||||
func newRepoReportShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "repo-report",
|
||||
Description: "Generate a read-only repository workflow report",
|
||||
Flags: []common.Flag{
|
||||
{Name: "from", Usage: "Read repository report input from a JSON file"},
|
||||
{Name: "issue-limit", Usage: "Maximum issues to fetch and analyze", Default: "20"},
|
||||
{Name: "pr-limit", Usage: "Maximum pull requests to fetch and summarize", Default: "10"},
|
||||
{Name: "stale-days", Usage: "Days before an issue or PR is considered stale", Default: "30"},
|
||||
{Name: "include-issues", Usage: "Include issue triage summary", Bool: true, Default: "true"},
|
||||
{Name: "include-prs", Usage: "Include pull request summary", Bool: true, Default: "true"},
|
||||
{Name: "include-health", Usage: "Include repository health summary", Bool: true, Default: "true"},
|
||||
{Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN},
|
||||
},
|
||||
Run: runRepoReport,
|
||||
}
|
||||
}
|
||||
|
||||
func runRepoReport(ctx *common.RuntimeContext) error {
|
||||
lang := normalizeLang(ctx.Arg("lang"))
|
||||
input, notes, err := collectRepoReportInput(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := AnalyzeRepoReport(input, lang)
|
||||
for _, note := range notes {
|
||||
if note.Metric == "" && note.Note == "" {
|
||||
continue
|
||||
}
|
||||
result.Reasoning = append(result.Reasoning, fmt.Sprintf("%s: %s", note.Metric, note.Note))
|
||||
}
|
||||
|
||||
format := ctx.Format
|
||||
if strings.TrimSpace(cmdutil.Format) == "" {
|
||||
format = "markdown"
|
||||
}
|
||||
rendered, err := RenderRepoReport(result, format, lang)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprint(os.Stdout, rendered)
|
||||
return err
|
||||
}
|
||||
|
||||
func collectRepoReportInput(ctx *common.RuntimeContext) (RepoReportInput, []ScoringNote, error) {
|
||||
if path := strings.TrimSpace(ctx.Arg("from")); path != "" {
|
||||
input, err := readRepoReportInput(path)
|
||||
if err != nil {
|
||||
return RepoReportInput{}, nil, err
|
||||
}
|
||||
if strings.TrimSpace(input.Source) == "" {
|
||||
input.Source = "local-json"
|
||||
}
|
||||
return input, nil, nil
|
||||
}
|
||||
|
||||
issueLimit, err := parseIntArg(ctx.Arg("issue-limit"), 20, "issue-limit")
|
||||
if err != nil {
|
||||
return RepoReportInput{}, nil, err
|
||||
}
|
||||
prLimit, err := parseIntArg(ctx.Arg("pr-limit"), 10, "pr-limit")
|
||||
if err != nil {
|
||||
return RepoReportInput{}, nil, err
|
||||
}
|
||||
staleDays, err := parseIntArg(ctx.Arg("stale-days"), 30, "stale-days")
|
||||
if err != nil {
|
||||
return RepoReportInput{}, nil, err
|
||||
}
|
||||
|
||||
return FetchRepoReportInput(ctx, RepoReportFetchOptions{
|
||||
IssueLimit: issueLimit,
|
||||
PRLimit: prLimit,
|
||||
StaleDays: staleDays,
|
||||
IncludeIssues: parseBoolArg(ctx.Arg("include-issues")),
|
||||
IncludePRs: parseBoolArg(ctx.Arg("include-prs")),
|
||||
IncludeHealth: parseBoolArg(ctx.Arg("include-health")),
|
||||
})
|
||||
}
|
||||
|
||||
func readRepoReportInput(path string) (RepoReportInput, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return RepoReportInput{}, fmt.Errorf("read repo report input: %w", err)
|
||||
}
|
||||
var input RepoReportInput
|
||||
if err := json.Unmarshal(data, &input); err != nil {
|
||||
return RepoReportInput{}, fmt.Errorf("parse repo report input: %w", err)
|
||||
}
|
||||
if strings.TrimSpace(input.Repository) == "" && input.Health == nil && len(input.Issues) == 0 && len(input.PullRequests) == 0 {
|
||||
return RepoReportInput{}, fmt.Errorf("parse repo report input: expected RepoReportInput root object")
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func AnalyzeRepoReport(input RepoReportInput, lang string) RepoReportResult {
|
||||
lang = normalizeLang(lang)
|
||||
source := strings.TrimSpace(input.Source)
|
||||
if source == "" {
|
||||
source = "local"
|
||||
}
|
||||
repository := strings.TrimSpace(input.Repository)
|
||||
sections := []string{}
|
||||
reasoning := []string{}
|
||||
|
||||
var healthResult *HealthResult
|
||||
baseScore := 70
|
||||
if input.Health != nil {
|
||||
health := *input.Health
|
||||
if repository == "" {
|
||||
repository = health.Repository
|
||||
}
|
||||
scored := ScoreHealth(health, lang)
|
||||
healthResult = &scored
|
||||
baseScore = scored.HealthScore
|
||||
sections = append(sections, "health")
|
||||
reasoning = append(reasoning, fmt.Sprintf("health score: %d", scored.HealthScore))
|
||||
} else {
|
||||
reasoning = append(reasoning, repoReportText(lang, "health_missing"))
|
||||
}
|
||||
|
||||
issueSummary, issueResults := summarizeRepoIssues(input.Issues, lang)
|
||||
if len(input.Issues) > 0 {
|
||||
sections = append(sections, "issues")
|
||||
reasoning = append(reasoning, fmt.Sprintf("issues analyzed: %d", len(input.Issues)))
|
||||
}
|
||||
|
||||
prSummary, prResults := summarizeRepoPRs(input.PullRequests, lang)
|
||||
if len(input.PullRequests) > 0 {
|
||||
sections = append(sections, "pull_requests")
|
||||
reasoning = append(reasoning, fmt.Sprintf("pull requests analyzed: %d", len(input.PullRequests)))
|
||||
}
|
||||
|
||||
reportScore := computeRepoReportScore(baseScore, input.Health != nil, issueSummary, prSummary)
|
||||
risk := riskLevel(reportScore)
|
||||
if hasSecurityP0(issueResults) || hasCriticalPR(prResults) {
|
||||
risk = "critical"
|
||||
reportScore = minInt(reportScore, 39)
|
||||
reasoning = append(reasoning, repoReportText(lang, "critical_signal"))
|
||||
}
|
||||
|
||||
recommendations := buildRepoReportRecommendations(lang, healthResult, issueSummary, prSummary, risk)
|
||||
if repository == "" {
|
||||
repository = "local"
|
||||
}
|
||||
|
||||
return RepoReportResult{
|
||||
Repository: repository,
|
||||
Health: healthResult,
|
||||
IssueSummary: issueSummary,
|
||||
PRSummary: prSummary,
|
||||
Recommendations: recommendations,
|
||||
RiskLevel: risk,
|
||||
ReportScore: reportScore,
|
||||
Sections: sections,
|
||||
Reasoning: uniqueStrings(reasoning),
|
||||
Source: source,
|
||||
}
|
||||
}
|
||||
|
||||
func summarizeRepoIssues(issues []IssueInput, lang string) (RepoIssueSummary, []TriageResult) {
|
||||
summary := RepoIssueSummary{
|
||||
ByType: map[string]int{},
|
||||
ByPriority: map[string]int{},
|
||||
}
|
||||
results := make([]TriageResult, 0, len(issues))
|
||||
for _, issue := range issues {
|
||||
result := AnalyzeIssue(issue, lang)
|
||||
results = append(results, result)
|
||||
summary.Total++
|
||||
summary.ByType[result.DetectedType]++
|
||||
summary.ByPriority[result.Priority]++
|
||||
if result.Priority == PriorityP0 || result.Priority == PriorityP1 || containsString(result.RiskFlags, RiskSecuritySensitive) {
|
||||
summary.HighRisk++
|
||||
}
|
||||
if len(result.MissingInformation) > 0 {
|
||||
summary.MissingInfo++
|
||||
}
|
||||
}
|
||||
return summary, results
|
||||
}
|
||||
|
||||
func summarizeRepoPRs(inputs []PRSummaryInput, lang string) (RepoPRSummary, []PRSummaryResult) {
|
||||
summary := RepoPRSummary{
|
||||
ByType: map[string]int{},
|
||||
ByRisk: map[string]int{},
|
||||
}
|
||||
results := make([]PRSummaryResult, 0, len(inputs))
|
||||
focus := []string{}
|
||||
for _, input := range inputs {
|
||||
result := AnalyzePRSummary(input, lang)
|
||||
results = append(results, result)
|
||||
summary.Total++
|
||||
summary.ByType[result.ChangeType]++
|
||||
summary.ByRisk[result.RiskLevel]++
|
||||
if result.RiskLevel == PRRiskHigh || result.RiskLevel == PRRiskCritical {
|
||||
summary.HighRisk++
|
||||
}
|
||||
focus = append(focus, result.ReviewFocus...)
|
||||
}
|
||||
summary.ReviewFocus = uniqueStrings(focus)
|
||||
sort.Strings(summary.ReviewFocus)
|
||||
return summary, results
|
||||
}
|
||||
|
||||
func computeRepoReportScore(baseScore int, hasHealth bool, issueSummary RepoIssueSummary, prSummary RepoPRSummary) int {
|
||||
score := baseScore
|
||||
if !hasHealth {
|
||||
score = 70
|
||||
}
|
||||
score -= issueSummary.HighRisk * 8
|
||||
score -= issueSummary.MissingInfo * 3
|
||||
score -= prSummary.HighRisk * 8
|
||||
score -= prSummary.ByRisk[PRRiskCritical] * 10
|
||||
if issueSummary.Total == 0 && prSummary.Total == 0 && !hasHealth {
|
||||
score = 50
|
||||
}
|
||||
return clampInt(score, 0, 100)
|
||||
}
|
||||
|
||||
func hasSecurityP0(results []TriageResult) bool {
|
||||
for _, result := range results {
|
||||
if result.Priority == PriorityP0 || result.DetectedType == IssueTypeSecurity || containsString(result.RiskFlags, RiskSecuritySensitive) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasCriticalPR(results []PRSummaryResult) bool {
|
||||
for _, result := range results {
|
||||
if result.RiskLevel == PRRiskCritical {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func buildRepoReportRecommendations(lang string, health *HealthResult, issueSummary RepoIssueSummary, prSummary RepoPRSummary, risk string) []string {
|
||||
recommendations := []string{}
|
||||
if issueSummary.ByPriority[PriorityP0] > 0 || issueSummary.ByType[IssueTypeSecurity] > 0 {
|
||||
recommendations = append(recommendations, repoReportText(lang, "rec_security_issues"))
|
||||
}
|
||||
if issueSummary.MissingInfo > 0 {
|
||||
recommendations = append(recommendations, repoReportText(lang, "rec_missing_info"))
|
||||
}
|
||||
if prSummary.HighRisk > 0 {
|
||||
recommendations = append(recommendations, repoReportText(lang, "rec_high_risk_prs"))
|
||||
}
|
||||
if health != nil {
|
||||
recommendations = append(recommendations, health.Recommendations...)
|
||||
}
|
||||
if health != nil && health.HealthScore < 65 {
|
||||
recommendations = append(recommendations, repoReportText(lang, "rec_health"))
|
||||
}
|
||||
if risk == "low" && len(recommendations) == 0 {
|
||||
recommendations = append(recommendations, repoReportText(lang, "rec_maintain_report"))
|
||||
}
|
||||
if len(recommendations) == 0 {
|
||||
recommendations = append(recommendations, repoReportText(lang, "rec_review_report"))
|
||||
}
|
||||
recommendations = uniqueStrings(recommendations)
|
||||
if len(recommendations) > 8 {
|
||||
recommendations = recommendations[:8]
|
||||
}
|
||||
return recommendations
|
||||
}
|
||||
|
||||
func repoReportText(lang, key string) string {
|
||||
zh := normalizeLang(lang) == langZH
|
||||
switch key {
|
||||
case "title":
|
||||
if zh {
|
||||
return "仓库工作流报告"
|
||||
}
|
||||
return "Repository Workflow Report"
|
||||
case "overview":
|
||||
if zh {
|
||||
return "总览"
|
||||
}
|
||||
return "Overview"
|
||||
case "health":
|
||||
if zh {
|
||||
return "健康度摘要"
|
||||
}
|
||||
return "Health Summary"
|
||||
case "issues":
|
||||
if zh {
|
||||
return "Issue 分诊摘要"
|
||||
}
|
||||
return "Issue Triage Summary"
|
||||
case "prs":
|
||||
if zh {
|
||||
return "PR 审阅摘要"
|
||||
}
|
||||
return "PR Review Summary"
|
||||
case "recommendations":
|
||||
if zh {
|
||||
return "建议操作"
|
||||
}
|
||||
return "Recommendations"
|
||||
case "reasoning":
|
||||
if zh {
|
||||
return "判断依据"
|
||||
}
|
||||
return "Reasoning"
|
||||
case "not_available":
|
||||
if zh {
|
||||
return "不可用"
|
||||
}
|
||||
return "Not available"
|
||||
case "health_missing":
|
||||
if zh {
|
||||
return "未提供健康度输入。"
|
||||
}
|
||||
return "health input not provided"
|
||||
case "critical_signal":
|
||||
if zh {
|
||||
return "发现安全 P0 Issue 或 critical PR,整体风险上调。"
|
||||
}
|
||||
return "security P0 issue or critical PR raised overall risk"
|
||||
case "rec_security_issues":
|
||||
if zh {
|
||||
return "优先处理安全相关或 P0 Issue。"
|
||||
}
|
||||
return "Prioritize security-related or P0 issues."
|
||||
case "rec_missing_info":
|
||||
if zh {
|
||||
return "要求补充复现步骤、版本、命令输出或日志。"
|
||||
}
|
||||
return "Request missing reproduction steps, version, command output, or logs."
|
||||
case "rec_high_risk_prs":
|
||||
if zh {
|
||||
return "优先审阅 high / critical 风险 PR。"
|
||||
}
|
||||
return "Prioritize high or critical risk pull requests."
|
||||
case "rec_health":
|
||||
if zh {
|
||||
return "根据健康度建议降低仓库治理风险。"
|
||||
}
|
||||
return "Use the health recommendations to reduce repository governance risk."
|
||||
case "rec_maintain_report":
|
||||
if zh {
|
||||
return "保持当前维护节奏,并定期复查仓库工作流报告。"
|
||||
}
|
||||
return "Maintain the current workflow and review the repository report regularly."
|
||||
case "rec_review_report":
|
||||
if zh {
|
||||
return "复查报告中的风险项并安排下一步维护动作。"
|
||||
}
|
||||
return "Review report risks and schedule the next maintenance actions."
|
||||
default:
|
||||
return key
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,138 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type RepoReportFetchOptions struct {
|
||||
Owner string
|
||||
Repo string
|
||||
IssueLimit int
|
||||
PRLimit int
|
||||
StaleDays int
|
||||
IncludeIssues bool
|
||||
IncludePRs bool
|
||||
IncludeHealth bool
|
||||
}
|
||||
|
||||
func FetchRepoReportInput(ctx *common.RuntimeContext, opts RepoReportFetchOptions) (RepoReportInput, []ScoringNote, error) {
|
||||
owner, repo, err := resolveFetchRepo(ctx, opts.Owner, opts.Repo)
|
||||
if err != nil {
|
||||
return RepoReportInput{}, nil, fmt.Errorf("workflow +repo-report remote mode requires --owner and --repo or a Git remote: %w", err)
|
||||
}
|
||||
if opts.IssueLimit <= 0 {
|
||||
opts.IssueLimit = 20
|
||||
}
|
||||
if opts.PRLimit <= 0 {
|
||||
opts.PRLimit = 10
|
||||
}
|
||||
if opts.StaleDays <= 0 {
|
||||
opts.StaleDays = 30
|
||||
}
|
||||
|
||||
input := RepoReportInput{
|
||||
Repository: fmt.Sprintf("%s/%s", owner, repo),
|
||||
Source: "remote-read-only-fetch",
|
||||
}
|
||||
notes := []ScoringNote{}
|
||||
successes := 0
|
||||
|
||||
if opts.IncludeHealth {
|
||||
health, healthNotes, err := FetchHealthInput(ctx, HealthFetchOptions{
|
||||
Owner: owner,
|
||||
Repo: repo,
|
||||
StaleDays: opts.StaleDays,
|
||||
IncludeCI: true,
|
||||
IncludeRelease: true,
|
||||
IncludeDocs: true,
|
||||
})
|
||||
notes = append(notes, healthNotes...)
|
||||
if err != nil {
|
||||
notes = append(notes, ScoringNote{Metric: "repo_report_health", Note: fmt.Sprintf("health fetch failed: %v", err)})
|
||||
} else {
|
||||
input.Health = &health
|
||||
successes++
|
||||
}
|
||||
}
|
||||
|
||||
if opts.IncludeIssues {
|
||||
issues, err := FetchIssuesForTriage(ctx, TriageFetchOptions{
|
||||
Owner: owner,
|
||||
Repo: repo,
|
||||
State: "open",
|
||||
Limit: opts.IssueLimit,
|
||||
Page: 1,
|
||||
})
|
||||
if err != nil {
|
||||
notes = append(notes, ScoringNote{Metric: "repo_report_issues", Note: fmt.Sprintf("issue fetch failed: %v", err)})
|
||||
} else {
|
||||
input.Issues = issues
|
||||
successes++
|
||||
}
|
||||
}
|
||||
|
||||
if opts.IncludePRs {
|
||||
prs, err := fetchPRListForReport(ctx, owner, repo, opts.PRLimit)
|
||||
if err != nil {
|
||||
notes = append(notes, ScoringNote{Metric: "repo_report_prs", Note: fmt.Sprintf("pull request list fetch failed: %v", err)})
|
||||
} else {
|
||||
input.PullRequests = prs
|
||||
successes++
|
||||
if len(prs) > 0 {
|
||||
notes = append(notes, ScoringNote{
|
||||
Metric: "repo_report_prs",
|
||||
Note: "PR report uses list metadata only; changed files and commits require workflow +pr-summary with a PR number.",
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if successes == 0 {
|
||||
if len(notes) == 0 {
|
||||
notes = append(notes, ScoringNote{Metric: "repo_report", Note: "no report sections were enabled or fetched"})
|
||||
}
|
||||
return RepoReportInput{}, uniqueScoringNotes(notes), fmt.Errorf("fetch repo report: all enabled sections failed")
|
||||
}
|
||||
return input, uniqueScoringNotes(notes), nil
|
||||
}
|
||||
|
||||
func fetchPRListForReport(ctx *common.RuntimeContext, owner, repo string, limit int) ([]PRSummaryInput, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Set("state", "open")
|
||||
query.Set("page", "1")
|
||||
query.Set("limit", fmt.Sprintf("%d", limit))
|
||||
|
||||
env, err := ctx.CallAPIWithQuery("GET", workflowRepoPath(owner, repo)+"/pulls", query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := apiList(env.Data)
|
||||
inputs := make([]PRSummaryInput, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
input, ok := normalizePRSummaryItem(item)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
input.Repository = fmt.Sprintf("%s/%s", owner, repo)
|
||||
input.Source = "remote-read-only-fetch:list-metadata"
|
||||
if strings.TrimSpace(input.State) == "" {
|
||||
input.State = "open"
|
||||
}
|
||||
inputs = append(inputs, input)
|
||||
if len(inputs) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return inputs, nil
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFetchRepoReportInputPartialPRUnavailable(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"updated_at": "2026-05-20T00:00:00Z",
|
||||
"has_readme": true,
|
||||
"has_license": true,
|
||||
"has_contributing": true,
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{
|
||||
{"number": 1, "title": "Install failed", "body": "error on install", "updated_at": "2026-05-20T00:00:00Z"},
|
||||
}})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json":
|
||||
http.Error(w, "pulls unavailable", http.StatusServiceUnavailable)
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"releases": []map[string]interface{}{{"created_at": "2026-05-01T00:00:00Z"}}})
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"builds": []map[string]interface{}{{"status": "success"}}})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
|
||||
IssueLimit: 10,
|
||||
PRLimit: 10,
|
||||
StaleDays: 30,
|
||||
IncludeHealth: true,
|
||||
IncludeIssues: true,
|
||||
IncludePRs: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRepoReportInput returned error: %v", err)
|
||||
}
|
||||
if input.Health == nil || len(input.Issues) != 1 {
|
||||
t.Fatalf("input = %+v, want health and issues", input)
|
||||
}
|
||||
if len(input.PullRequests) != 0 {
|
||||
t.Fatalf("len(PullRequests) = %d, want 0", len(input.PullRequests))
|
||||
}
|
||||
if !hasNote(notes, "repo_report_prs") {
|
||||
t.Fatalf("notes = %+v, want repo_report_prs note", notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRepoReportInputHealthFailureIssuesSuccess(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo.json":
|
||||
http.Error(w, "repo unavailable", http.StatusInternalServerError)
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{
|
||||
{"number": 1, "title": "README typo", "body": "docs typo"},
|
||||
}})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{}})
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"releases": []map[string]interface{}{}})
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"builds": []map[string]interface{}{}})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
|
||||
IssueLimit: 10,
|
||||
IncludeHealth: true,
|
||||
IncludeIssues: true,
|
||||
IncludePRs: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRepoReportInput returned error: %v", err)
|
||||
}
|
||||
if input.Health == nil || len(input.Issues) != 1 {
|
||||
t.Fatalf("input = %+v, want degraded health and one issue", input)
|
||||
}
|
||||
if !hasNote(notes, "repository") {
|
||||
t.Fatalf("notes = %+v, want repository note", notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRepoReportInputAllSectionsFail(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "unavailable", http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, _, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
|
||||
IncludeHealth: false,
|
||||
IncludeIssues: true,
|
||||
IncludePRs: true,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("FetchRepoReportInput returned nil error when all sections failed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRepoReportInputRespectsIssueLimitAndIncludeFlags(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("limit"); got != "1" {
|
||||
t.Fatalf("issue limit = %q, want 1", got)
|
||||
}
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{
|
||||
{"number": 1, "title": "First bug", "body": "error"},
|
||||
{"number": 2, "title": "Second bug", "body": "error"},
|
||||
}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
|
||||
IssueLimit: 1,
|
||||
IncludeHealth: false,
|
||||
IncludeIssues: true,
|
||||
IncludePRs: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRepoReportInput returned error: %v", err)
|
||||
}
|
||||
if len(notes) != 0 {
|
||||
t.Fatalf("notes = %+v, want empty", notes)
|
||||
}
|
||||
if len(input.Issues) != 1 {
|
||||
t.Fatalf("len(Issues) = %d, want 1", len(input.Issues))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRepoReportInputPRListMetadata(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/pulls.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("limit"); got != "1" {
|
||||
t.Fatalf("PR limit = %q, want 1", got)
|
||||
}
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{
|
||||
{"number": 1, "title": "feat: add report", "user": map[string]interface{}{"login": "alice"}},
|
||||
{"number": 2, "title": "docs: update guide"},
|
||||
}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
|
||||
PRLimit: 1,
|
||||
IncludeHealth: false,
|
||||
IncludeIssues: false,
|
||||
IncludePRs: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRepoReportInput returned error: %v", err)
|
||||
}
|
||||
if len(input.PullRequests) != 1 {
|
||||
t.Fatalf("len(PullRequests) = %d, want 1", len(input.PullRequests))
|
||||
}
|
||||
if !hasNote(notes, "repo_report_prs") || !strings.Contains(notes[0].Note, "list metadata") {
|
||||
t.Fatalf("notes = %+v, want list metadata note", notes)
|
||||
}
|
||||
}
|
||||
|
||||
func hasNote(notes []ScoringNote, metric string) bool {
|
||||
for _, note := range notes {
|
||||
if note.Metric == metric {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAnalyzeRepoReportAggregatesHealthIssuesAndPRs(t *testing.T) {
|
||||
input := sampleRepoReportInput()
|
||||
result := AnalyzeRepoReport(input, "en")
|
||||
if result.ReportScore < 0 || result.ReportScore > 100 {
|
||||
t.Fatalf("ReportScore = %d, want 0..100", result.ReportScore)
|
||||
}
|
||||
if result.IssueSummary.Total != 3 {
|
||||
t.Fatalf("IssueSummary.Total = %d, want 3", result.IssueSummary.Total)
|
||||
}
|
||||
if result.IssueSummary.ByType[IssueTypeBug] == 0 {
|
||||
t.Fatalf("IssueSummary.ByType = %+v, want bug count", result.IssueSummary.ByType)
|
||||
}
|
||||
if result.PRSummary.Total != 3 {
|
||||
t.Fatalf("PRSummary.Total = %d, want 3", result.PRSummary.Total)
|
||||
}
|
||||
if result.PRSummary.ByRisk[PRRiskHigh] == 0 {
|
||||
t.Fatalf("PRSummary.ByRisk = %+v, want high risk count", result.PRSummary.ByRisk)
|
||||
}
|
||||
if len(result.Recommendations) == 0 {
|
||||
t.Fatal("Recommendations empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeRepoReportWithSecurityIssueRaisesRisk(t *testing.T) {
|
||||
input := RepoReportInput{
|
||||
Repository: "owner/repo",
|
||||
Issues: []IssueInput{{
|
||||
Number: 1,
|
||||
Title: "Token leaked in logs",
|
||||
Body: "A secret token leaked from command output.",
|
||||
Labels: []string{"security"},
|
||||
}},
|
||||
}
|
||||
result := AnalyzeRepoReport(input, "en")
|
||||
if result.RiskLevel != "critical" {
|
||||
t.Fatalf("RiskLevel = %q, want critical", result.RiskLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeRepoReportPartialInput(t *testing.T) {
|
||||
input := RepoReportInput{
|
||||
Repository: "owner/repo",
|
||||
Health: &HealthInput{
|
||||
Repository: "owner/repo",
|
||||
OpenIssues: 1,
|
||||
OpenPRs: 0,
|
||||
RecentActivityKnown: true,
|
||||
RecentActivityDays: 2,
|
||||
ReleaseKnown: true,
|
||||
HasRecentRelease: true,
|
||||
HasReadme: true,
|
||||
HasLicense: true,
|
||||
HasContributing: true,
|
||||
AgentReadinessKnown: true,
|
||||
AgentReadinessScore: 9,
|
||||
},
|
||||
}
|
||||
result := AnalyzeRepoReport(input, "en")
|
||||
if result.Health == nil {
|
||||
t.Fatal("Health result nil")
|
||||
}
|
||||
if len(result.Recommendations) == 0 {
|
||||
t.Fatal("Recommendations empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeRepoReportChinese(t *testing.T) {
|
||||
result := AnalyzeRepoReport(sampleRepoReportInput(), "zh-CN")
|
||||
if len(result.Recommendations) == 0 {
|
||||
t.Fatal("Recommendations empty")
|
||||
}
|
||||
rendered, err := RenderRepoReport(result, "markdown", "zh-CN")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRepoReport returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(rendered, "仓库工作流报告") {
|
||||
t.Fatalf("markdown output missing Chinese title:\n%s", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRepoReportJSON(t *testing.T) {
|
||||
rendered, err := RenderRepoReport(AnalyzeRepoReport(sampleRepoReportInput(), "en"), "json", "en")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRepoReport returned error: %v", err)
|
||||
}
|
||||
var result RepoReportResult
|
||||
if err := json.Unmarshal([]byte(rendered), &result); err != nil {
|
||||
t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, rendered)
|
||||
}
|
||||
if result.Repository != "owner/repo" {
|
||||
t.Fatalf("Repository = %q, want owner/repo", result.Repository)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRepoReportMarkdown(t *testing.T) {
|
||||
result := AnalyzeRepoReport(sampleRepoReportInput(), "en")
|
||||
rendered, err := RenderRepoReport(result, "markdown", "en")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRepoReport returned error: %v", err)
|
||||
}
|
||||
for _, want := range []string{"Repository Workflow Report", "Report score", "Recommendations"} {
|
||||
if !strings.Contains(rendered, want) {
|
||||
t.Fatalf("markdown output missing %q:\n%s", want, rendered)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRepoReportTable(t *testing.T) {
|
||||
rendered, err := RenderRepoReport(AnalyzeRepoReport(sampleRepoReportInput(), "en"), "table", "en")
|
||||
if err != nil {
|
||||
t.Fatalf("RenderRepoReport returned error: %v", err)
|
||||
}
|
||||
if !strings.Contains(rendered, "REPORT_SCORE") || !strings.Contains(rendered, "owner/repo") {
|
||||
t.Fatalf("table output = %q, want report score and repository", rendered)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderRepoReportUnknownFormat(t *testing.T) {
|
||||
_, err := RenderRepoReport(AnalyzeRepoReport(sampleRepoReportInput(), "en"), "xml", "en")
|
||||
if err == nil {
|
||||
t.Fatal("RenderRepoReport returned nil error for unknown format")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadRepoReportInput(t *testing.T) {
|
||||
input, err := readRepoReportInput("testdata/repo_report.json")
|
||||
if err != nil {
|
||||
t.Fatalf("readRepoReportInput returned error: %v", err)
|
||||
}
|
||||
if input.Repository == "" || len(input.Issues) == 0 || len(input.PullRequests) == 0 {
|
||||
t.Fatalf("input = %+v, want populated fixture", input)
|
||||
}
|
||||
}
|
||||
|
||||
func sampleRepoReportInput() RepoReportInput {
|
||||
return RepoReportInput{
|
||||
Repository: "owner/repo",
|
||||
Health: &HealthInput{
|
||||
Repository: "owner/repo",
|
||||
OpenIssues: 5,
|
||||
OpenPRs: 3,
|
||||
StaleIssues: 1,
|
||||
StalePRs: 1,
|
||||
RecentActivityKnown: true,
|
||||
RecentActivityDays: 2,
|
||||
ReleaseKnown: true,
|
||||
HasRecentRelease: true,
|
||||
HasReadme: true,
|
||||
HasLicense: true,
|
||||
HasContributing: true,
|
||||
AgentReadinessKnown: true,
|
||||
AgentReadinessScore: 9,
|
||||
},
|
||||
Issues: []IssueInput{
|
||||
{Number: 1, Title: "Install failed", Body: "error on install", Labels: []string{"bug"}},
|
||||
{Number: 2, Title: "README typo", Body: "docs typo", Labels: []string{"docs"}},
|
||||
{Number: 3, Title: "Crash on login", Body: "panic", Labels: []string{"bug"}},
|
||||
},
|
||||
PullRequests: []PRSummaryInput{
|
||||
{
|
||||
Repository: "owner/repo",
|
||||
Number: 1,
|
||||
Title: "docs: update guide",
|
||||
ChangedFiles: []PRChangedFile{
|
||||
{Filename: "README.md", Additions: 10, Deletions: 1, Changes: 11},
|
||||
},
|
||||
},
|
||||
{
|
||||
Repository: "owner/repo",
|
||||
Number: 2,
|
||||
Title: "feat: add workflow command",
|
||||
ChangedFiles: []PRChangedFile{
|
||||
{Filename: "shortcuts/workflow/workflow.go", Additions: 40, Deletions: 4, Changes: 44},
|
||||
},
|
||||
},
|
||||
{
|
||||
Repository: "owner/repo",
|
||||
Number: 3,
|
||||
Title: "fix: normalize API client errors",
|
||||
ChangedFiles: []PRChangedFile{
|
||||
{Filename: "internal/client/client.go", Additions: 30, Deletions: 10, Changes: 40},
|
||||
},
|
||||
},
|
||||
},
|
||||
Source: "local-json",
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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"`
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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` 以便解析输出
|
||||
|
|
|
|||
Loading…
Reference in New Issue