diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b9c885 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ + +gitlink-cli.exe diff --git a/README.md b/README.md index b4c55e1..670808c 100644 --- a/README.md +++ b/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__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__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? diff --git a/docs/pr-draft.md b/docs/pr-draft.md new file mode 100644 index 0000000..48bc8c6 --- /dev/null +++ b/docs/pr-draft.md @@ -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 +``` diff --git a/docs/workflow-agent-design.md b/docs/workflow-agent-design.md new file mode 100644 index 0000000..59c63ee --- /dev/null +++ b/docs/workflow-agent-design.md @@ -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 `+`. + +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. diff --git a/docs/workflow-agent-test-report.md b/docs/workflow-agent-test-report.md new file mode 100644 index 0000000..c166148 --- /dev/null +++ b/docs/workflow-agent-test-report.md @@ -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 +``` diff --git a/pr-test-file.txt b/pr-test-file.txt deleted file mode 100644 index d848ff9..0000000 --- a/pr-test-file.txt +++ /dev/null @@ -1 +0,0 @@ -PR Test 2026年 4月 7日 星期二 11时45分56秒 CST diff --git a/shortcuts/register.go b/shortcuts/register.go index 84362f5..943b6dd 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -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 { diff --git a/shortcuts/workflow/api_types.go b/shortcuts/workflow/api_types.go new file mode 100644 index 0000000..3a0bf3e --- /dev/null +++ b/shortcuts/workflow/api_types.go @@ -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) +} diff --git a/shortcuts/workflow/health_fetch.go b/shortcuts/workflow/health_fetch.go new file mode 100644 index 0000000..0efbe75 --- /dev/null +++ b/shortcuts/workflow/health_fetch.go @@ -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 +} diff --git a/shortcuts/workflow/health_fetch_test.go b/shortcuts/workflow/health_fetch_test.go new file mode 100644 index 0000000..c9accd3 --- /dev/null +++ b/shortcuts/workflow/health_fetch_test.go @@ -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) + } +} diff --git a/shortcuts/workflow/health_score.go b/shortcuts/workflow/health_score.go new file mode 100644 index 0000000..771d6dd --- /dev/null +++ b/shortcuts/workflow/health_score.go @@ -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 +} diff --git a/shortcuts/workflow/health_score_test.go b/shortcuts/workflow/health_score_test.go new file mode 100644 index 0000000..13ddcc1 --- /dev/null +++ b/shortcuts/workflow/health_score_test.go @@ -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) + } +} diff --git a/shortcuts/workflow/messages.go b/shortcuts/workflow/messages.go new file mode 100644 index 0000000..f7d9966 --- /dev/null +++ b/shortcuts/workflow/messages.go @@ -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": "暂无额外判断依据。", + }, +} diff --git a/shortcuts/workflow/messages_test.go b/shortcuts/workflow/messages_test.go new file mode 100644 index 0000000..d998c6b --- /dev/null +++ b/shortcuts/workflow/messages_test.go @@ -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) + } +} diff --git a/shortcuts/workflow/pr_fetch.go b/shortcuts/workflow/pr_fetch.go new file mode 100644 index 0000000..e8a854f --- /dev/null +++ b/shortcuts/workflow/pr_fetch.go @@ -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]) +} diff --git a/shortcuts/workflow/pr_fetch_test.go b/shortcuts/workflow/pr_fetch_test.go new file mode 100644 index 0000000..3a2e893 --- /dev/null +++ b/shortcuts/workflow/pr_fetch_test.go @@ -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)) + } +} diff --git a/shortcuts/workflow/pr_summary.go b/shortcuts/workflow/pr_summary.go new file mode 100644 index 0000000..398bf1e --- /dev/null +++ b/shortcuts/workflow/pr_summary.go @@ -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 +} diff --git a/shortcuts/workflow/pr_summary_test.go b/shortcuts/workflow/pr_summary_test.go new file mode 100644 index 0000000..bbc957b --- /dev/null +++ b/shortcuts/workflow/pr_summary_test.go @@ -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) + } +} diff --git a/shortcuts/workflow/render.go b/shortcuts/workflow/render.go new file mode 100644 index 0000000..366c2e2 --- /dev/null +++ b/shortcuts/workflow/render.go @@ -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]) + "..." +} diff --git a/shortcuts/workflow/render_test.go b/shortcuts/workflow/render_test.go new file mode 100644 index 0000000..427f05a --- /dev/null +++ b/shortcuts/workflow/render_test.go @@ -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", + } +} diff --git a/shortcuts/workflow/repo_report.go b/shortcuts/workflow/repo_report.go new file mode 100644 index 0000000..d120f28 --- /dev/null +++ b/shortcuts/workflow/repo_report.go @@ -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 + } +} diff --git a/shortcuts/workflow/repo_report_fetch.go b/shortcuts/workflow/repo_report_fetch.go new file mode 100644 index 0000000..9610a71 --- /dev/null +++ b/shortcuts/workflow/repo_report_fetch.go @@ -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 +} diff --git a/shortcuts/workflow/repo_report_fetch_test.go b/shortcuts/workflow/repo_report_fetch_test.go new file mode 100644 index 0000000..37e9838 --- /dev/null +++ b/shortcuts/workflow/repo_report_fetch_test.go @@ -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 +} diff --git a/shortcuts/workflow/repo_report_test.go b/shortcuts/workflow/repo_report_test.go new file mode 100644 index 0000000..059ea94 --- /dev/null +++ b/shortcuts/workflow/repo_report_test.go @@ -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", + } +} diff --git a/shortcuts/workflow/testdata/health_good.json b/shortcuts/workflow/testdata/health_good.json new file mode 100644 index 0000000..b36b018 --- /dev/null +++ b/shortcuts/workflow/testdata/health_good.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 +} diff --git a/shortcuts/workflow/testdata/health_risky.json b/shortcuts/workflow/testdata/health_risky.json new file mode 100644 index 0000000..370ac81 --- /dev/null +++ b/shortcuts/workflow/testdata/health_risky.json @@ -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 +} diff --git a/shortcuts/workflow/testdata/issue_bug.json b/shortcuts/workflow/testdata/issue_bug.json new file mode 100644 index 0000000..75092dd --- /dev/null +++ b/shortcuts/workflow/testdata/issue_bug.json @@ -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"] +} diff --git a/shortcuts/workflow/testdata/issue_security.json b/shortcuts/workflow/testdata/issue_security.json new file mode 100644 index 0000000..2e536cf --- /dev/null +++ b/shortcuts/workflow/testdata/issue_security.json @@ -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"] +} diff --git a/shortcuts/workflow/testdata/pr_summary.json b/shortcuts/workflow/testdata/pr_summary.json new file mode 100644 index 0000000..6a23ec5 --- /dev/null +++ b/shortcuts/workflow/testdata/pr_summary.json @@ -0,0 +1,37 @@ +{ + "repository": "Gitlink/gitlink-cli", + "number": 1, + "title": "feat: add workflow PR summary", + "author": "alice", + "state": "open", + "base_branch": "master", + "head_branch": "feature/workflow-pr-summary", + "body": "Add a read-only workflow PR summary command for maintainers and agents.", + "changed_files": [ + { + "filename": "shortcuts/workflow/pr_summary.go", + "status": "added", + "additions": 120, + "deletions": 0, + "changes": 120 + }, + { + "filename": "docs/workflow-agent-design.md", + "status": "modified", + "additions": 18, + "deletions": 2, + "changes": 20 + } + ], + "commits": [ + { + "sha": "abc1234", + "message": "feat: add workflow PR summary", + "author": "alice", + "date": "2026-05-20T10:00:00Z" + } + ], + "additions": 138, + "deletions": 2, + "source": "local-json" +} diff --git a/shortcuts/workflow/testdata/repo_report.json b/shortcuts/workflow/testdata/repo_report.json new file mode 100644 index 0000000..9506fc2 --- /dev/null +++ b/shortcuts/workflow/testdata/repo_report.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" +} diff --git a/shortcuts/workflow/triage_fetch.go b/shortcuts/workflow/triage_fetch.go new file mode 100644 index 0000000..a509cf8 --- /dev/null +++ b/shortcuts/workflow/triage_fetch.go @@ -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 + } +} diff --git a/shortcuts/workflow/triage_fetch_test.go b/shortcuts/workflow/triage_fetch_test.go new file mode 100644 index 0000000..f7af88a --- /dev/null +++ b/shortcuts/workflow/triage_fetch_test.go @@ -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) + } +} diff --git a/shortcuts/workflow/triage_rules.go b/shortcuts/workflow/triage_rules.go new file mode 100644 index 0000000..2ecaf5a --- /dev/null +++ b/shortcuts/workflow/triage_rules.go @@ -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 +} diff --git a/shortcuts/workflow/triage_rules_test.go b/shortcuts/workflow/triage_rules_test.go new file mode 100644 index 0000000..e7780ef --- /dev/null +++ b/shortcuts/workflow/triage_rules_test.go @@ -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) + } +} diff --git a/shortcuts/workflow/types.go b/shortcuts/workflow/types.go new file mode 100644 index 0000000..416900c --- /dev/null +++ b/shortcuts/workflow/types.go @@ -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"` +} diff --git a/shortcuts/workflow/workflow.go b/shortcuts/workflow/workflow.go new file mode 100644 index 0000000..4dc69e5 --- /dev/null +++ b/shortcuts/workflow/workflow.go @@ -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 +} diff --git a/shortcuts/workflow/workflow_test.go b/shortcuts/workflow/workflow_test.go new file mode 100644 index 0000000..b7d03cf --- /dev/null +++ b/shortcuts/workflow/workflow_test.go @@ -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) + } +} diff --git a/skills/gitlink-workflow/SKILL.md b/skills/gitlink-workflow/SKILL.md index 9997883..0f7aa2a 100644 --- a/skills/gitlink-workflow/SKILL.md +++ b/skills/gitlink-workflow/SKILL.md @@ -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 ` for a focused PR review. + ## 最佳实践 - 所有工作流命令使用 `--format json` 以便解析输出