From 5673a109a41eb44cd7e75127a275ad84a955b550 Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Thu, 21 May 2026 17:50:56 +0800 Subject: [PATCH 01/10] Add workflow agent triage and health commands --- README.md | 50 +++ WORK_CONTINUATION.md | 156 +++++++ docs/competition-solution.md | 121 +++++ docs/pr-draft.md | 57 +++ docs/workflow-agent-design.md | 422 ++++++++++++++++++ docs/workflow-agent-test-report.md | 89 ++++ shortcuts/register.go | 43 +- shortcuts/workflow/api_types.go | 363 +++++++++++++++ shortcuts/workflow/health_fetch.go | 301 +++++++++++++ shortcuts/workflow/health_fetch_test.go | 321 +++++++++++++ shortcuts/workflow/health_score.go | 246 ++++++++++ shortcuts/workflow/health_score_test.go | 113 +++++ shortcuts/workflow/messages.go | 82 ++++ shortcuts/workflow/messages_test.go | 24 + shortcuts/workflow/render.go | 153 +++++++ shortcuts/workflow/testdata/health_good.json | 18 + shortcuts/workflow/testdata/health_risky.json | 18 + shortcuts/workflow/testdata/issue_bug.json | 9 + .../workflow/testdata/issue_security.json | 9 + shortcuts/workflow/triage_fetch.go | 170 +++++++ shortcuts/workflow/triage_fetch_test.go | 257 +++++++++++ shortcuts/workflow/triage_rules.go | 306 +++++++++++++ shortcuts/workflow/triage_rules_test.go | 92 ++++ shortcuts/workflow/types.go | 124 +++++ shortcuts/workflow/workflow.go | 364 +++++++++++++++ shortcuts/workflow/workflow_test.go | 200 +++++++++ 26 files changed, 4088 insertions(+), 20 deletions(-) create mode 100644 WORK_CONTINUATION.md create mode 100644 docs/competition-solution.md create mode 100644 docs/pr-draft.md create mode 100644 docs/workflow-agent-design.md create mode 100644 docs/workflow-agent-test-report.md create mode 100644 shortcuts/workflow/api_types.go create mode 100644 shortcuts/workflow/health_fetch.go create mode 100644 shortcuts/workflow/health_fetch_test.go create mode 100644 shortcuts/workflow/health_score.go create mode 100644 shortcuts/workflow/health_score_test.go create mode 100644 shortcuts/workflow/messages.go create mode 100644 shortcuts/workflow/messages_test.go create mode 100644 shortcuts/workflow/render.go create mode 100644 shortcuts/workflow/testdata/health_good.json create mode 100644 shortcuts/workflow/testdata/health_risky.json create mode 100644 shortcuts/workflow/testdata/issue_bug.json create mode 100644 shortcuts/workflow/testdata/issue_security.json create mode 100644 shortcuts/workflow/triage_fetch.go create mode 100644 shortcuts/workflow/triage_fetch_test.go create mode 100644 shortcuts/workflow/triage_rules.go create mode 100644 shortcuts/workflow/triage_rules_test.go create mode 100644 shortcuts/workflow/types.go create mode 100644 shortcuts/workflow/workflow.go create mode 100644 shortcuts/workflow/workflow_test.go diff --git a/README.md b/README.md index b4c55e1..f412bcf 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,56 @@ 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` + +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 +``` + +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. + ### Raw API For endpoints not covered by shortcuts, use the Raw API directly: diff --git a/WORK_CONTINUATION.md b/WORK_CONTINUATION.md new file mode 100644 index 0000000..0134965 --- /dev/null +++ b/WORK_CONTINUATION.md @@ -0,0 +1,156 @@ +# GitLink CLI Workflow Agent Work Continuation + +## Current Goal + +Implement the first PR slice for the GitLink CLI Agent Workflow enhancement suite for `track1_2026GitLinkCli`. + +First PR scope: +- `gitlink-cli workflow +triage` +- `gitlink-cli workflow +health` + +Current implemented slice: +- Pure workflow DTOs and rule engines. +- Local command layer for `workflow +triage` and `workflow +health`. +- Read-only GitLink API fetch layer for workflow triage and health. +- Fetch layer hardened and documented. +- No remote write behavior. + +## Current Branch + +- Branch: `master` +- Remote: `origin https://gitlink.org.cn/Gitlink/gitlink-cli.git` +- Repository path: `E:\GitLinkCLI-Competition\gitlink-cli` +- Local Go toolchain: `E:\GitLinkCLI-Competition\tools\go1.26.1\go` + +## Completed Content + +- Confirmed current workspace repository is `Gitlink/gitlink-cli`. +- Confirmed `workflow` command group did not previously exist in Go command registration. +- Confirmed `skills/gitlink-workflow/SKILL.md` exists as workflow guidance only. +- Read core command, shortcut, output, client, config, and test patterns. +- Created first workflow agent design draft at `docs/workflow-agent-design.md`. +- Workspace moved out of `C:\Users\zyc\OneDrive\Desktop\4c文档` to `E:\GitLinkCLI-Competition\gitlink-cli`. +- Added pure workflow DTOs under `shortcuts/workflow/types.go`. +- Added pure issue triage rules under `shortcuts/workflow/triage_rules.go`. +- Added pure repository health scoring under `shortcuts/workflow/health_score.go`. +- Added lightweight language messages under `shortcuts/workflow/messages.go`. +- Added unit tests for triage, health, messages, renderers, and local workflow command helpers. +- Installed Go 1.26.1 locally for Windows amd64 after verifying the machine is Intel x64. +- Added `workflow.Shortcuts()` with `+triage` and `+health`. +- Registered the `workflow` shortcut group in `shortcuts/register.go`. +- Added workflow-local JSON, table, and markdown renderers. +- Added local input support: + - `workflow +triage`: single issue flags or `--from` JSON file. + - `workflow +health`: explicit metric flags or `--from` JSON file. +- Verified both commands run locally without GitLink API access. +- Added read-only workflow API fetch helpers and mock tests. +- Added command-level fetch-path smoke tests for `runTriage` and `runHealth`. +- Added README workflow command usage section. +- Added `docs/workflow-agent-test-report.md`. +- Added `docs/competition-solution.md`. +- Added `docs/pr-draft.md`. +- Added workflow testdata fixtures under `shortcuts/workflow/testdata/`. +- Expanded fetch-layer boundary coverage for empty responses, label/author normalization, error-in-body handling, alternative activity timestamps, release shapes, and CI unavailability. + +## Current Go Toolchain Status + +- `where go`: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\go.exe` +- `where gofmt`: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\gofmt.exe` +- `go version`: `go version go1.26.1 windows/amd64` +- Temporary PATH change: applied only in shell commands. +- GOPROXY used for tests: `https://goproxy.cn,direct` +- Go toolchain status: available. +- gofmt status: available. + +## Current Test Status + +- `gofmt` on `shortcuts/workflow/*.go` and `shortcuts/register.go`: passed. +- `go test ./shortcuts/workflow`: passed. +- `go test ./...`: passed. +- Smoke command passed: + - `go run . --format json workflow +triage --title "Token leaked in logs" --body "secret token leaked" --number 1 --labels security` +- Smoke command passed: + - `go run . --format table workflow +health --repository owner/repo --open-issues 2 --open-prs 1 --recent-activity-known --recent-activity-days 3 --release-known --has-recent-release --has-readme --has-license --has-contributing --agent-readiness-known --agent-readiness-score 9` +- Remote read-only smoke command passed: + - `go run . --format table workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5` +- Remote read-only smoke command passed: + - `go run . --format markdown --lang zh-CN workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30` +- Documentation examples now cover both local-parameter and local-JSON-file usage. +- `docs/pr-draft.md`: present and current. + +## Recent Changed Files + +- `WORK_CONTINUATION.md` +- `docs/workflow-agent-design.md` +- `docs/competition-solution.md` +- `docs/pr-draft.md` +- `shortcuts/register.go` +- `shortcuts/workflow/types.go` +- `shortcuts/workflow/messages.go` +- `shortcuts/workflow/triage_rules.go` +- `shortcuts/workflow/health_score.go` +- `shortcuts/workflow/render.go` +- `shortcuts/workflow/workflow.go` +- `shortcuts/workflow/api_types.go` +- `shortcuts/workflow/triage_fetch.go` +- `shortcuts/workflow/health_fetch.go` +- `shortcuts/workflow/triage_rules_test.go` +- `shortcuts/workflow/health_score_test.go` +- `shortcuts/workflow/messages_test.go` +- `shortcuts/workflow/workflow_test.go` +- `shortcuts/workflow/triage_fetch_test.go` +- `shortcuts/workflow/health_fetch_test.go` +- `shortcuts/workflow/testdata/issue_bug.json` +- `shortcuts/workflow/testdata/issue_security.json` +- `shortcuts/workflow/testdata/health_good.json` +- `shortcuts/workflow/testdata/health_risky.json` +- `README.md` +- `docs/workflow-agent-test-report.md` + +## Uncompleted Content + +- `workflow +pr-summary` is not implemented. +- `workflow +release-notes` is not implemented. +- `workflow +stale` is not implemented. +- Remote write operations remain intentionally deferred. + +## Known Issues + +- `codex status` is unavailable from the non-interactive shell: `stdin is not a terminal`. +- Quota reset time unavailable. +- Workflow commands support both local input and read-only GitLink fetch mode. +- Existing global help says default format is table, but shortcut runtime defaults to json when `--format` is omitted. +- Existing output formatter supports `json`, `yaml`, and `table`; workflow-local renderers currently support `json`, `table`, and `markdown`. +- Workflow Skill examples use some older flag names such as `--id`, while current issue commands use `--number` for issues and PR commands use `--id`. +- API response shapes vary across endpoints and should be normalized behind workflow-specific fetch/parsing helpers. + +## Key Design Decisions + +- No new dependency was added. +- `workflow` is a new shortcut group under `shortcuts/workflow`. +- JSON schemas use explicit workflow DTOs. +- Workflow renderers are local to the workflow package; global formatter was not changed. +- All remote-write behavior remains out of scope. +- `+triage` supports local single-issue flags, JSON file input, and read-only GitLink fetch mode. +- `+health` supports local metric flags, JSON file input, and read-only GitLink fetch mode. +- Treat unavailable future API metrics as `unknown` and include them in `scoring_notes`. +- Workflow-local renderers keep json/table/markdown output isolated from the global formatter. + +## Next Minimal Executable Task + +Design workflow +pr-summary: read-only PR metadata, changed files, and commits; implement with httptest mock first; do not add LLM or write operations. + +## How To Continue After Interruption + +1. Open `WORK_CONTINUATION.md`. +2. Run `git status --short --branch`. +3. Use temporary PATH: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin`. +4. Set temporary GOPROXY if dependency download fails: `https://goproxy.cn,direct`. +5. Run `go test ./shortcuts/workflow`. +6. Run `go test ./...`. +7. Start `workflow +pr-summary` design only after confirming the existing workflow tests still pass. +8. Keep all new workflow commands read-only by default. + +## Recommended Next Codex Instruction + +Design workflow +pr-summary with read-only PR metadata, changed files, and commits; implement with httptest mock first; do not add LLM or write operations. diff --git a/docs/competition-solution.md b/docs/competition-solution.md new file mode 100644 index 0000000..ce88672 --- /dev/null +++ b/docs/competition-solution.md @@ -0,0 +1,121 @@ +# GitLink CLI Agent Workflow Enhancement Suite + +## 1. Background + +GitLink CLI serves both human maintainers and AI Agents. The competition focuses on intelligent open-source contribution workflows, where structured analysis, stable output, and safe automation matter more than raw command count. + +## 2. Problem + +Open-source maintenance often suffers from: + +- Issue backlog and delayed triage +- High PR review cost +- Repetitive release note preparation +- Lack of structured repository health evaluation +- AI Agents needing stable, machine-readable output + +## 3. Solution + +This project extends GitLink CLI with the **GitLink CLI Agent Workflow Enhancement Suite**. + +Implemented now: + +- `workflow +triage` +- `workflow +health` +- read-only GitLink fetch layer for workflow triage and health +- expanded fetch boundary tests for empty responses, label and author normalization, error-in-body handling, alternative activity timestamps, release shapes, and CI unavailability +- local-first analysis with no LLM dependency +- stable Agent-facing JSON / table / markdown output + +Planned next: + +- `workflow +pr-summary` +- `workflow +release-notes` +- `workflow +stale` + +## 4. Technical Route + +- Go + Cobra + existing shortcut architecture +- rule-based analysis +- stable DTOs +- `json` / `table` / `markdown` renderers +- `en` / `zh-CN` message mapping +- no LLM dependency +- local-first, dry-run-safe workflow design + +## 5. Implemented Features + +### workflow +triage + +- issue type detection +- priority scoring +- confidence scoring +- missing information detection +- risk flags +- recommended action +- suggested comment +- reasoning and matched rules + +### workflow +health + +- health score +- risk level +- metrics +- scoring notes +- recommendations +- unknown metric tolerance + +## 6. Innovation Points + +- Agent-native structured output +- rule-based intelligence without external LLM dependency +- explainable workflow decisions +- safety-first local analysis +- bilingual command output +- extensible workflow command design +- competition-friendly incremental PR path + +## 7. Testing and Verification + +- Unit tests cover triage, health scoring, messages, rendering, and command helpers. +- Fetch-layer tests cover issue normalization and repository health probing with `httptest`. +- Boundary tests cover empty responses, label and author normalization, error-in-body handling, alternative activity timestamps, release response shapes, and CI unavailability. +- Local command examples were executed successfully. +- Full repository testing passed in the current environment. +- Automated tests use `httptest` and do not depend on real remote API availability. + +## 8. Demonstration Plan + +### Official repository + +Use `Gitlink/gitlink-cli` as the reference repository: + +1. `workflow +triage` with English table output +2. `workflow +triage` with security JSON output +3. `workflow +triage` with Chinese markdown output +4. `workflow +health` with table output +5. `workflow +health` with risky JSON output +6. Explain how agents consume stable JSON + +### Self-built test repository + +Use a small demo repository to show: + +- bug triage +- security triage +- docs triage +- healthy repo score +- risky repo score + +## 9. Roadmap + +- Phase 1: local workflow prototype, completed +- Phase 2: API fetch and normalization, completed +- Phase 3: `pr-summary`, `release-notes`, `stale` + +## 10. PR Plan + +- PR 1: workflow rule engine and local commands +- PR 2: documentation and tests +- PR 3: API fetch layer +- PR 4: `pr-summary` / `release-notes` diff --git a/docs/pr-draft.md b/docs/pr-draft.md new file mode 100644 index 0000000..badea8e --- /dev/null +++ b/docs/pr-draft.md @@ -0,0 +1,57 @@ +# PR Draft: Add workflow agent commands for issue triage and repository health analysis + +## Summary + +This PR adds `workflow +triage` and `workflow +health` with three execution modes: + +- local flags +- local JSON input +- read-only GitLink fetch mode + +It also adds stable `json`, `table`, and `markdown` rendering for Agent consumption. + +## Motivation + +- Help maintainers triage issues faster +- Provide a structured repository health overview +- Give AI Agents stable machine-readable output +- Keep the workflow local-first and safe by default +- Avoid any dependency on external LLM APIs + +## Changes + +- New `shortcuts/workflow` rule engine and DTOs +- Local command layer for `workflow +triage` and `workflow +health` +- Workflow-local renderer for `json`, `table`, and `markdown` +- Read-only GitLink fetch and normalization helpers +- Unit tests for rules, fetch normalization, rendering, and command wiring +- Competition and test documentation updates + +## Safety + +- Remote mode is read-only +- No comment, label, close, merge, or release write actions +- Health scoring tolerates unknown or unavailable metrics +- Test fixtures do not contain secrets or tokens + +## Tests + +- `gofmt -w shortcuts/workflow/*.go shortcuts/register.go` +- `go test ./shortcuts/workflow` +- `go test ./...` +- `httptest` coverage for API normalization and fetch tolerance +- Manual command examples in local and remote read-only modes + +## Known Limitations + +- Real API response shapes may still require minor normalization tweaks +- Write operations are intentionally deferred to a later PR +- `pr-summary`, `release-notes`, and `stale` are planned next + +## Screenshots or Examples + +```bash +gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --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 +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table +``` diff --git a/docs/workflow-agent-design.md b/docs/workflow-agent-design.md new file mode 100644 index 0000000..2b8b20c --- /dev/null +++ b/docs/workflow-agent-design.md @@ -0,0 +1,422 @@ +# 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 does not include a `workflow` command group. The first competition PR should turn the documented workflow concept into concrete, deterministic CLI commands that can be used by human maintainers and AI Agents without calling an external LLM. + +## 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. + +Later PRs: +- `workflow +pr-summary` +- `workflow +release-notes` +- `workflow +stale` + +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 + +## 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: +- `--id` +- optional `--lang` + +Data: +- PR details +- changed files +- commits + +Output: +- `change_type` +- `risk_level` +- `summary` +- `review_focus` +- `test_suggestions` +- `merge_checklist` + +### `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` + +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. +- Future `pr-summary` and `release-notes` should reuse the same stable DTO and message 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..725f1f0 --- /dev/null +++ b/docs/workflow-agent-test-report.md @@ -0,0 +1,89 @@ +# Workflow Agent Test Report + +## Scope + +This phase covers: + +- Issue triage rules +- health scoring 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 + +## 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` + +## 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 +``` + +## 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`. +- `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. 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..af5aead --- /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", "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..4b9c604 --- /dev/null +++ b/shortcuts/workflow/messages.go @@ -0,0 +1,82 @@ +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.", + }, + 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 友好度。", + }, +} 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/render.go b/shortcuts/workflow/render.go new file mode 100644 index 0000000..7e33b10 --- /dev/null +++ b/shortcuts/workflow/render.go @@ -0,0 +1,153 @@ +package workflow + +import ( + "encoding/json" + "fmt" + "io" + "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 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 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 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 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 +} 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/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..4b49e70 --- /dev/null +++ b/shortcuts/workflow/workflow.go @@ -0,0 +1,364 @@ +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(), + } +} + +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 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 { + return strings.TrimSpace(ctx.Arg("from")) != "" || strings.TrimSpace(ctx.Arg("title")) != "" +} + +func hasLocalHealthInput(ctx *common.RuntimeContext) bool { + return strings.TrimSpace(ctx.Arg("from")) != "" || strings.TrimSpace(ctx.Arg("repository")) != "" +} + +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..04a5bd1 --- /dev/null +++ b/shortcuts/workflow/workflow_test.go @@ -0,0 +1,200 @@ +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") + } +} + +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 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) + } +} From 77303472537fa300f9724b956427fe290693f7ec Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Thu, 21 May 2026 20:33:54 +0800 Subject: [PATCH 02/10] Add workflow pr-summary command --- README.md | 12 +- WORK_CONTINUATION.md | 79 ++- docs/competition-solution.md | 24 +- docs/pr-draft.md | 12 +- docs/workflow-agent-design.md | 39 +- docs/workflow-agent-test-report.md | 8 + pr-test-file.txt | 1 - shortcuts/workflow/api_types.go | 2 +- shortcuts/workflow/messages.go | 20 + shortcuts/workflow/pr_fetch.go | 333 ++++++++++ shortcuts/workflow/pr_fetch_test.go | 216 +++++++ shortcuts/workflow/pr_summary.go | 676 ++++++++++++++++++++ shortcuts/workflow/pr_summary_test.go | 286 +++++++++ shortcuts/workflow/render.go | 108 ++++ shortcuts/workflow/render_test.go | 84 +++ shortcuts/workflow/testdata/pr_summary.json | 37 ++ shortcuts/workflow/workflow.go | 1 + shortcuts/workflow/workflow_test.go | 3 + skills/gitlink-workflow/SKILL.md | 18 + 19 files changed, 1896 insertions(+), 63 deletions(-) delete mode 100644 pr-test-file.txt create mode 100644 shortcuts/workflow/pr_fetch.go create mode 100644 shortcuts/workflow/pr_fetch_test.go create mode 100644 shortcuts/workflow/pr_summary.go create mode 100644 shortcuts/workflow/pr_summary_test.go create mode 100644 shortcuts/workflow/render_test.go create mode 100644 shortcuts/workflow/testdata/pr_summary.json diff --git a/README.md b/README.md index f412bcf..b79afca 100644 --- a/README.md +++ b/README.md @@ -296,6 +296,9 @@ gitlink-cli search +users -k "zhangsan" - `workflow +triage` - `workflow +health` +- `workflow +pr-summary` + +`workflow +pr-summary` defaults to `table` when `--format` is omitted. Examples: @@ -326,6 +329,12 @@ gitlink-cli workflow +health --repository Gitlink/gitlink-cli --open-issues 3 -- # 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 ``` Output formats: @@ -339,6 +348,7 @@ 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. ### Raw API @@ -361,7 +371,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`. diff --git a/WORK_CONTINUATION.md b/WORK_CONTINUATION.md index 0134965..92c2217 100644 --- a/WORK_CONTINUATION.md +++ b/WORK_CONTINUATION.md @@ -2,22 +2,20 @@ ## Current Goal -Implement the first PR slice for the GitLink CLI Agent Workflow enhancement suite for `track1_2026GitLinkCli`. - -First PR scope: -- `gitlink-cli workflow +triage` -- `gitlink-cli workflow +health` +Implement the GitLink CLI Agent Workflow enhancement suite for `track1_2026GitLinkCli`. Current implemented slice: -- Pure workflow DTOs and rule engines. -- Local command layer for `workflow +triage` and `workflow +health`. -- Read-only GitLink API fetch layer for workflow triage and health. -- Fetch layer hardened and documented. -- No remote write behavior. +- `workflow +triage` +- `workflow +health` +- `workflow +pr-summary` + +Planned next: +- `workflow +release-notes` +- `workflow +stale` ## Current Branch -- Branch: `master` +- Branch: `codex/workflow-agent` - Remote: `origin https://gitlink.org.cn/Gitlink/gitlink-cli.git` - Repository path: `E:\GitLinkCLI-Competition\gitlink-cli` - Local Go toolchain: `E:\GitLinkCLI-Competition\tools\go1.26.1\go` @@ -36,21 +34,22 @@ Current implemented slice: - Added lightweight language messages under `shortcuts/workflow/messages.go`. - Added unit tests for triage, health, messages, renderers, and local workflow command helpers. - Installed Go 1.26.1 locally for Windows amd64 after verifying the machine is Intel x64. -- Added `workflow.Shortcuts()` with `+triage` and `+health`. +- Added `workflow.Shortcuts()` with `+triage`, `+health`, and `+pr-summary`. - Registered the `workflow` shortcut group in `shortcuts/register.go`. - Added workflow-local JSON, table, and markdown renderers. - Added local input support: - `workflow +triage`: single issue flags or `--from` JSON file. - `workflow +health`: explicit metric flags or `--from` JSON file. -- Verified both commands run locally without GitLink API access. + - `workflow +pr-summary`: PR number fetch or `--from` JSON file. +- Verified all three commands run locally without GitLink API write access. - Added read-only workflow API fetch helpers and mock tests. -- Added command-level fetch-path smoke tests for `runTriage` and `runHealth`. +- Added command-level fetch-path smoke tests for `runTriage`, `runHealth`, and `runPRSummary`. - Added README workflow command usage section. - Added `docs/workflow-agent-test-report.md`. - Added `docs/competition-solution.md`. - Added `docs/pr-draft.md`. - Added workflow testdata fixtures under `shortcuts/workflow/testdata/`. -- Expanded fetch-layer boundary coverage for empty responses, label/author normalization, error-in-body handling, alternative activity timestamps, release shapes, and CI unavailability. +- Expanded fetch-layer boundary coverage for empty responses, label and author normalization, error-in-body handling, alternative activity timestamps, release shapes, CI unavailability, and PR summary normalization. ## Current Go Toolchain Status @@ -64,52 +63,44 @@ Current implemented slice: ## Current Test Status -- `gofmt` on `shortcuts/workflow/*.go` and `shortcuts/register.go`: passed. +- `gofmt` on `shortcuts/workflow/*.go`: passed. - `go test ./shortcuts/workflow`: passed. - `go test ./...`: passed. - Smoke command passed: - `go run . --format json workflow +triage --title "Token leaked in logs" --body "secret token leaked" --number 1 --labels security` - Smoke command passed: - `go run . --format table workflow +health --repository owner/repo --open-issues 2 --open-prs 1 --recent-activity-known --recent-activity-days 3 --release-known --has-recent-release --has-readme --has-license --has-contributing --agent-readiness-known --agent-readiness-score 9` +- Smoke command passed: + - `go run . --format json workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json` - Remote read-only smoke command passed: - `go run . --format table workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5` - Remote read-only smoke command passed: - `go run . --format markdown --lang zh-CN workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30` -- Documentation examples now cover both local-parameter and local-JSON-file usage. -- `docs/pr-draft.md`: present and current. +- Documentation examples now cover local-parameter, local-JSON-file, and read-only fetch usage. ## Recent Changed Files -- `WORK_CONTINUATION.md` -- `docs/workflow-agent-design.md` +- `README.md` - `docs/competition-solution.md` - `docs/pr-draft.md` -- `shortcuts/register.go` -- `shortcuts/workflow/types.go` +- `docs/workflow-agent-design.md` +- `docs/workflow-agent-test-report.md` +- `shortcuts/workflow/api_types.go` - `shortcuts/workflow/messages.go` -- `shortcuts/workflow/triage_rules.go` -- `shortcuts/workflow/health_score.go` - `shortcuts/workflow/render.go` - `shortcuts/workflow/workflow.go` -- `shortcuts/workflow/api_types.go` -- `shortcuts/workflow/triage_fetch.go` -- `shortcuts/workflow/health_fetch.go` -- `shortcuts/workflow/triage_rules_test.go` -- `shortcuts/workflow/health_score_test.go` -- `shortcuts/workflow/messages_test.go` - `shortcuts/workflow/workflow_test.go` -- `shortcuts/workflow/triage_fetch_test.go` -- `shortcuts/workflow/health_fetch_test.go` -- `shortcuts/workflow/testdata/issue_bug.json` -- `shortcuts/workflow/testdata/issue_security.json` -- `shortcuts/workflow/testdata/health_good.json` -- `shortcuts/workflow/testdata/health_risky.json` -- `README.md` -- `docs/workflow-agent-test-report.md` +- `shortcuts/workflow/pr_summary.go` +- `shortcuts/workflow/pr_fetch.go` +- `shortcuts/workflow/pr_summary_test.go` +- `shortcuts/workflow/pr_fetch_test.go` +- `shortcuts/workflow/render_test.go` +- `shortcuts/workflow/testdata/pr_summary.json` +- `skills/gitlink-workflow/SKILL.md` +- `pr-test-file.txt` deleted ## Uncompleted Content -- `workflow +pr-summary` is not implemented. - `workflow +release-notes` is not implemented. - `workflow +stale` is not implemented. - Remote write operations remain intentionally deferred. @@ -119,10 +110,11 @@ Current implemented slice: - `codex status` is unavailable from the non-interactive shell: `stdin is not a terminal`. - Quota reset time unavailable. - Workflow commands support both local input and read-only GitLink fetch mode. -- Existing global help says default format is table, but shortcut runtime defaults to json when `--format` is omitted. +- Existing global help says default format is table, but shortcut runtime still defaults to json when `--format` is omitted. - Existing output formatter supports `json`, `yaml`, and `table`; workflow-local renderers currently support `json`, `table`, and `markdown`. - Workflow Skill examples use some older flag names such as `--id`, while current issue commands use `--number` for issues and PR commands use `--id`. - API response shapes vary across endpoints and should be normalized behind workflow-specific fetch/parsing helpers. +- `README.zh-CN.md` currently shows encoding/garbling risk in the shell and was left untouched in this slice. ## Key Design Decisions @@ -133,12 +125,13 @@ Current implemented slice: - All remote-write behavior remains out of scope. - `+triage` supports local single-issue flags, JSON file input, and read-only GitLink fetch mode. - `+health` supports local metric flags, JSON file input, and read-only GitLink fetch mode. +- `+pr-summary` supports local JSON input and read-only PR metadata fetch mode. - Treat unavailable future API metrics as `unknown` and include them in `scoring_notes`. - Workflow-local renderers keep json/table/markdown output isolated from the global formatter. ## Next Minimal Executable Task -Design workflow +pr-summary: read-only PR metadata, changed files, and commits; implement with httptest mock first; do not add LLM or write operations. +Design workflow +release-notes with read-only PR titles and commit messages; implement with httptest mock first; do not add LLM or write operations. ## How To Continue After Interruption @@ -148,9 +141,9 @@ Design workflow +pr-summary: read-only PR metadata, changed files, and commits; 4. Set temporary GOPROXY if dependency download fails: `https://goproxy.cn,direct`. 5. Run `go test ./shortcuts/workflow`. 6. Run `go test ./...`. -7. Start `workflow +pr-summary` design only after confirming the existing workflow tests still pass. +7. Start `workflow +release-notes` design only after confirming the existing workflow tests still pass. 8. Keep all new workflow commands read-only by default. ## Recommended Next Codex Instruction -Design workflow +pr-summary with read-only PR metadata, changed files, and commits; implement with httptest mock first; do not add LLM or write operations. +Design workflow +release-notes with read-only PR titles and commit messages; implement with httptest mock first; do not add LLM or write operations. diff --git a/docs/competition-solution.md b/docs/competition-solution.md index ce88672..284083e 100644 --- a/docs/competition-solution.md +++ b/docs/competition-solution.md @@ -22,14 +22,15 @@ Implemented now: - `workflow +triage` - `workflow +health` +- `workflow +pr-summary` - read-only GitLink fetch layer for workflow triage and health +- read-only PR metadata, changed files, and commits fetch layer for PR summary - expanded fetch boundary tests for empty responses, label and author normalization, error-in-body handling, alternative activity timestamps, release shapes, and CI unavailability - local-first analysis with no LLM dependency - stable Agent-facing JSON / table / markdown output Planned next: -- `workflow +pr-summary` - `workflow +release-notes` - `workflow +stale` @@ -65,6 +66,15 @@ Planned next: - recommendations - unknown metric tolerance +### workflow +pr-summary + +- change type detection +- risk level analysis +- review focus generation +- test suggestion generation +- merge checklist generation +- read-only fetch of PR metadata, changed files, and commits + ## 6. Innovation Points - Agent-native structured output @@ -78,8 +88,9 @@ Planned next: ## 7. Testing and Verification - Unit tests cover triage, health scoring, messages, rendering, and command helpers. -- Fetch-layer tests cover issue normalization and repository health probing with `httptest`. +- Fetch-layer tests cover issue normalization, repository health probing, and PR metadata/file/commit normalization with `httptest`. - Boundary tests cover empty responses, label and author normalization, error-in-body handling, alternative activity timestamps, release response shapes, and CI unavailability. +- PR summary tests cover docs-only, workflow code, internal client, security-sensitive, mixed-file, zh-CN, render, command, and fetch-failure cases. - Local command examples were executed successfully. - Full repository testing passed in the current environment. - Automated tests use `httptest` and do not depend on real remote API availability. @@ -95,7 +106,8 @@ Use `Gitlink/gitlink-cli` as the reference repository: 3. `workflow +triage` with Chinese markdown output 4. `workflow +health` with table output 5. `workflow +health` with risky JSON output -6. Explain how agents consume stable JSON +6. `workflow +pr-summary` with markdown output +7. Explain how agents consume stable JSON ### Self-built test repository @@ -111,11 +123,13 @@ Use a small demo repository to show: - Phase 1: local workflow prototype, completed - Phase 2: API fetch and normalization, completed -- Phase 3: `pr-summary`, `release-notes`, `stale` +- Phase 3: `pr-summary`, completed +- Phase 4: `release-notes`, `stale` ## 10. PR Plan - PR 1: workflow rule engine and local commands - PR 2: documentation and tests - PR 3: API fetch layer -- PR 4: `pr-summary` / `release-notes` +- PR 4: `pr-summary` +- PR 5: `release-notes` / `stale` diff --git a/docs/pr-draft.md b/docs/pr-draft.md index badea8e..b2e6daa 100644 --- a/docs/pr-draft.md +++ b/docs/pr-draft.md @@ -1,8 +1,8 @@ -# PR Draft: Add workflow agent commands for issue triage and repository health analysis +# PR Draft: Add workflow agent commands for issue triage, repository health, and PR summaries ## Summary -This PR adds `workflow +triage` and `workflow +health` with three execution modes: +This PR adds `workflow +triage`, `workflow +health`, and `workflow +pr-summary` with safe read-only analysis modes: - local flags - local JSON input @@ -14,6 +14,7 @@ It also adds stable `json`, `table`, and `markdown` rendering for Agent consumpt - Help maintainers triage issues faster - Provide a structured repository health overview +- Summarize pull requests with review focus, test suggestions, and merge checklist output - Give AI Agents stable machine-readable output - Keep the workflow local-first and safe by default - Avoid any dependency on external LLM APIs @@ -22,6 +23,7 @@ It also adds stable `json`, `table`, and `markdown` rendering for Agent consumpt - New `shortcuts/workflow` rule engine and DTOs - Local command layer for `workflow +triage` and `workflow +health` +- Local and remote command layer for `workflow +pr-summary` - Workflow-local renderer for `json`, `table`, and `markdown` - Read-only GitLink fetch and normalization helpers - Unit tests for rules, fetch normalization, rendering, and command wiring @@ -30,7 +32,7 @@ It also adds stable `json`, `table`, and `markdown` rendering for Agent consumpt ## Safety - Remote mode is read-only -- No comment, label, close, merge, or release write actions +- No comment, label, close, approve, reject, merge, or release write actions - Health scoring tolerates unknown or unavailable metrics - Test fixtures do not contain secrets or tokens @@ -41,12 +43,13 @@ It also adds stable `json`, `table`, and `markdown` rendering for Agent consumpt - `go test ./...` - `httptest` coverage for API normalization and fetch tolerance - Manual command examples in local and remote read-only modes +- PR summary tests for change type, risk level, partial fetch failures, renderers, and command wiring ## Known Limitations - Real API response shapes may still require minor normalization tweaks - Write operations are intentionally deferred to a later PR -- `pr-summary`, `release-notes`, and `stale` are planned next +- `release-notes` and `stale` are planned next ## Screenshots or Examples @@ -54,4 +57,5 @@ It also adds stable `json`, `table`, and `markdown` rendering for Agent consumpt gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --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 +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table +gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown ``` diff --git a/docs/workflow-agent-design.md b/docs/workflow-agent-design.md index 2b8b20c..f3a5944 100644 --- a/docs/workflow-agent-design.md +++ b/docs/workflow-agent-design.md @@ -17,16 +17,17 @@ First PR: - Produce markdown output for reports, PR comments, Issue comments, and competition materials. - Support `--lang en` and `--lang zh-CN` with a lightweight message helper. -Later PRs: -- `workflow +pr-summary` -- `workflow +release-notes` -- `workflow +stale` +Additional workflow commands: +- `workflow +pr-summary`: 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 ## Current Repository Findings @@ -341,8 +342,17 @@ Command tests: ### `workflow +pr-summary` Inputs: -- `--id` -- optional `--lang` +- `--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 @@ -352,10 +362,22 @@ Data: Output: - `change_type` - `risk_level` -- `summary` - `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 +release-notes` @@ -407,7 +429,8 @@ Design goals already applied: Planned fetch-layer extension: - `triage_fetch.go` and `health_fetch.go` remain the normalization boundary for remote mode. -- Future `pr-summary` and `release-notes` should reuse the same stable DTO and message patterns. +- `pr_fetch.go` now reuses the same stable DTO and message patterns for read-only PR metadata, changed files, and commits. +- 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 diff --git a/docs/workflow-agent-test-report.md b/docs/workflow-agent-test-report.md index 725f1f0..b064229 100644 --- a/docs/workflow-agent-test-report.md +++ b/docs/workflow-agent-test-report.md @@ -6,6 +6,7 @@ This phase covers: - Issue triage rules - health scoring rules +- PR summary rules - local command execution - API fetch boundary tests - remote read-only manual verification @@ -43,6 +44,7 @@ Results: - render tests - command tests - fetch boundary tests +- PR summary rules and fetch tests ## API Fetch Boundary Tests @@ -55,6 +57,9 @@ Results: - 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 ## Manual Command Examples @@ -66,6 +71,8 @@ gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.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 ``` ## Remote Manual Verification @@ -81,6 +88,7 @@ gitlink-cli workflow +health --repository Gitlink/gitlink-cli --open-issues 3 -- - 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. - `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. 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/workflow/api_types.go b/shortcuts/workflow/api_types.go index af5aead..3a0bf3e 100644 --- a/shortcuts/workflow/api_types.go +++ b/shortcuts/workflow/api_types.go @@ -104,7 +104,7 @@ func apiList(data interface{}) []interface{} { case []interface{}: return v case map[string]interface{}: - for _, key := range []string{"issues", "pulls", "releases", "builds", "items", "records", "data"} { + 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 diff --git a/shortcuts/workflow/messages.go b/shortcuts/workflow/messages.go index 4b9c604..f7d9966 100644 --- a/shortcuts/workflow/messages.go +++ b/shortcuts/workflow/messages.go @@ -50,6 +50,16 @@ var messages = map[string]map[string]string{ "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": "复现步骤", @@ -78,5 +88,15 @@ var messages = map[string]map[string]string{ "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/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 index 7e33b10..7d1191b 100644 --- a/shortcuts/workflow/render.go +++ b/shortcuts/workflow/render.go @@ -1,6 +1,7 @@ package workflow import ( + "bytes" "encoding/json" "fmt" "io" @@ -34,6 +35,27 @@ func renderHealthResult(w io.Writer, result HealthResult, format string) error { } } +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 normalizeFormat(format string) string { format = strings.ToLower(strings.TrimSpace(format)) if format == "" { @@ -75,6 +97,25 @@ func writeTriageTable(w io.Writer, report TriageReport) error { 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 { @@ -120,6 +161,61 @@ func writeTriageMarkdown(w io.Writer, report TriageReport) error { 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 @@ -151,3 +247,15 @@ func writeHealthMarkdown(w io.Writer, result HealthResult) error { } 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/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/workflow.go b/shortcuts/workflow/workflow.go index 4b49e70..27ece5f 100644 --- a/shortcuts/workflow/workflow.go +++ b/shortcuts/workflow/workflow.go @@ -23,6 +23,7 @@ func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ newTriageShortcut(), newHealthShortcut(), + newPRSummaryShortcut(), } } diff --git a/shortcuts/workflow/workflow_test.go b/shortcuts/workflow/workflow_test.go index 04a5bd1..b0ed3b9 100644 --- a/shortcuts/workflow/workflow_test.go +++ b/shortcuts/workflow/workflow_test.go @@ -25,6 +25,9 @@ func TestShortcutsExposesWorkflowCommands(t *testing.T) { if !names["health"] { t.Fatal("Shortcuts missing health") } + if !names["pr-summary"] { + t.Fatal("Shortcuts missing pr-summary") + } } func TestRunTriageWithSingleIssueArgs(t *testing.T) { diff --git a/skills/gitlink-workflow/SKILL.md b/skills/gitlink-workflow/SKILL.md index 9997883..d5a3798 100644 --- a/skills/gitlink-workflow/SKILL.md +++ b/skills/gitlink-workflow/SKILL.md @@ -101,6 +101,24 @@ 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. + ## 最佳实践 - 所有工作流命令使用 `--format json` 以便解析输出 From a904f48099db50d531d3a5de34cd2412a703f43c Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Thu, 21 May 2026 22:17:02 +0800 Subject: [PATCH 03/10] Add workflow repo-report command --- README.md | 51 ++- WORK_CONTINUATION.md | 61 ++- docs/competition-solution.md | 40 +- docs/pr-draft.md | 11 +- docs/workflow-agent-design.md | 63 ++- docs/workflow-agent-test-report.md | 49 ++- shortcuts/workflow/render.go | 161 ++++++++ shortcuts/workflow/repo_report.go | 404 +++++++++++++++++++ shortcuts/workflow/repo_report_fetch.go | 138 +++++++ shortcuts/workflow/repo_report_fetch_test.go | 183 +++++++++ shortcuts/workflow/repo_report_test.go | 195 +++++++++ shortcuts/workflow/testdata/repo_report.json | 134 ++++++ shortcuts/workflow/workflow.go | 1 + shortcuts/workflow/workflow_test.go | 29 ++ skills/gitlink-workflow/SKILL.md | 23 ++ 15 files changed, 1515 insertions(+), 28 deletions(-) create mode 100644 shortcuts/workflow/repo_report.go create mode 100644 shortcuts/workflow/repo_report_fetch.go create mode 100644 shortcuts/workflow/repo_report_fetch_test.go create mode 100644 shortcuts/workflow/repo_report_test.go create mode 100644 shortcuts/workflow/testdata/repo_report.json diff --git a/README.md b/README.md index b79afca..670808c 100644 --- a/README.md +++ b/README.md @@ -297,8 +297,10 @@ gitlink-cli search +users -k "zhangsan" - `workflow +triage` - `workflow +health` - `workflow +pr-summary` +- `workflow +repo-report` `workflow +pr-summary` defaults to `table` when `--format` is omitted. +`workflow +repo-report` defaults to `markdown` when `--format` is omitted. Examples: @@ -310,7 +312,11 @@ gitlink-cli workflow +triage --title "Install failed on Windows" --body "go inst gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --format json # Triage with Chinese markdown output -gitlink-cli workflow +triage --title "安装失败,无法登录" --body "运行命令时报错" --lang zh-CN --format markdown +gitlink-cli workflow +triage \ + --title "安装失败,无法登录" \ + --body "运行命令时报错" \ + --lang zh-CN \ + --format markdown # Triage from a local JSON file gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format json @@ -319,13 +325,39 @@ gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json - gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table # Health for a healthy repository -gitlink-cli workflow +health --repository Gitlink/gitlink-cli --open-issues 3 --open-prs 1 --has-readme --has-license --has-contributing --agent-readiness-known --agent-readiness-score 9 --format table +gitlink-cli workflow +health \ + --repository Gitlink/gitlink-cli \ + --open-issues 3 \ + --open-prs 1 \ + --has-readme \ + --has-license \ + --has-contributing \ + --agent-readiness-known \ + --agent-readiness-score 9 \ + --format table # Health for a risky repository -gitlink-cli workflow +health --repository demo/repo --open-issues 60 --stale-issues 25 --open-prs 12 --stale-prs 6 --recent-activity-known --recent-activity-days 120 --release-known=false --format json +gitlink-cli workflow +health \ + --repository demo/repo \ + --open-issues 60 \ + --stale-issues 25 \ + --open-prs 12 \ + --stale-prs 6 \ + --recent-activity-known \ + --recent-activity-days 120 \ + --release-known=false \ + --format json # Health with Chinese markdown output -gitlink-cli workflow +health --repository Gitlink/gitlink-cli --open-issues 3 --open-prs 1 --has-readme --has-license --has-contributing --lang zh-CN --format markdown +gitlink-cli workflow +health \ + --repository Gitlink/gitlink-cli \ + --open-issues 3 \ + --open-prs 1 \ + --has-readme \ + --has-license \ + --has-contributing \ + --lang zh-CN \ + --format markdown # Health by read-only GitLink fetch gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --format table @@ -335,6 +367,12 @@ gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 - # PR review summary from a local JSON file gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json + +# Repository workflow report by read-only GitLink fetch +gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown + +# Repository workflow report from a local JSON file +gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json ``` Output formats: @@ -349,6 +387,7 @@ Safety: - They do not modify remote GitLink data. - They do not depend on LLM APIs. - `workflow +pr-summary` does not comment, approve, reject, or merge pull requests. +- `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes. ### Raw API @@ -520,7 +559,9 @@ Reinstall first: npm install -g @gitlink-ai/cli ``` -If the error persists, check whether the release page contains the asset for your platform, for example `gitlink-cli__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/WORK_CONTINUATION.md b/WORK_CONTINUATION.md index 92c2217..874dc95 100644 --- a/WORK_CONTINUATION.md +++ b/WORK_CONTINUATION.md @@ -8,6 +8,7 @@ Current implemented slice: - `workflow +triage` - `workflow +health` - `workflow +pr-summary` +- `workflow +repo-report` Planned next: - `workflow +release-notes` @@ -40,7 +41,8 @@ Planned next: - Added local input support: - `workflow +triage`: single issue flags or `--from` JSON file. - `workflow +health`: explicit metric flags or `--from` JSON file. - - `workflow +pr-summary`: PR number fetch or `--from` JSON file. +- `workflow +pr-summary`: PR number fetch or `--from` JSON file. + - `workflow +repo-report`: aggregate health, issues, and PR list metadata or `--from` JSON file. - Verified all three commands run locally without GitLink API write access. - Added read-only workflow API fetch helpers and mock tests. - Added command-level fetch-path smoke tests for `runTriage`, `runHealth`, and `runPRSummary`. @@ -50,6 +52,8 @@ Planned next: - Added `docs/pr-draft.md`. - Added workflow testdata fixtures under `shortcuts/workflow/testdata/`. - Expanded fetch-layer boundary coverage for empty responses, label and author normalization, error-in-body handling, alternative activity timestamps, release shapes, CI unavailability, and PR summary normalization. +- Added `workflow +repo-report` with local JSON input, read-only partial fetch aggregation, + report score, overall risk level, markdown/table/json rendering, and tests. ## Current Go Toolchain Status @@ -67,15 +71,54 @@ Planned next: - `go test ./shortcuts/workflow`: passed. - `go test ./...`: passed. - Smoke command passed: - - `go run . --format json workflow +triage --title "Token leaked in logs" --body "secret token leaked" --number 1 --labels security` + ```bash + go run . --format json workflow +triage \ + --title "Token leaked in logs" \ + --body "secret token leaked" \ + --number 1 \ + --labels security + ``` - Smoke command passed: - - `go run . --format table workflow +health --repository owner/repo --open-issues 2 --open-prs 1 --recent-activity-known --recent-activity-days 3 --release-known --has-recent-release --has-readme --has-license --has-contributing --agent-readiness-known --agent-readiness-score 9` + ```bash + go run . --format table workflow +health \ + --repository owner/repo \ + --open-issues 2 \ + --open-prs 1 \ + --recent-activity-known \ + --recent-activity-days 3 \ + --release-known \ + --has-recent-release \ + --has-readme \ + --has-license \ + --has-contributing \ + --agent-readiness-known \ + --agent-readiness-score 9 + ``` - Smoke command passed: - - `go run . --format json workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json` + ```bash + go run . --format json workflow +pr-summary \ + --from shortcuts/workflow/testdata/pr_summary.json + ``` +- Smoke command passed: + ```bash + go run . --format markdown workflow +repo-report \ + --from shortcuts/workflow/testdata/repo_report.json + ``` - Remote read-only smoke command passed: - - `go run . --format table workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5` + ```bash + go run . --format table workflow +triage \ + --owner Gitlink \ + --repo gitlink-cli \ + --state open \ + --limit 5 + ``` - Remote read-only smoke command passed: - - `go run . --format markdown --lang zh-CN workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30` + ```bash + go run . --format markdown --lang zh-CN workflow +health \ + --owner Gitlink \ + --repo gitlink-cli \ + --stale-days 30 + ``` - Documentation examples now cover local-parameter, local-JSON-file, and read-only fetch usage. ## Recent Changed Files @@ -96,6 +139,11 @@ Planned next: - `shortcuts/workflow/pr_fetch_test.go` - `shortcuts/workflow/render_test.go` - `shortcuts/workflow/testdata/pr_summary.json` +- `shortcuts/workflow/repo_report.go` +- `shortcuts/workflow/repo_report_fetch.go` +- `shortcuts/workflow/repo_report_test.go` +- `shortcuts/workflow/repo_report_fetch_test.go` +- `shortcuts/workflow/testdata/repo_report.json` - `skills/gitlink-workflow/SKILL.md` - `pr-test-file.txt` deleted @@ -126,6 +174,7 @@ Planned next: - `+triage` supports local single-issue flags, JSON file input, and read-only GitLink fetch mode. - `+health` supports local metric flags, JSON file input, and read-only GitLink fetch mode. - `+pr-summary` supports local JSON input and read-only PR metadata fetch mode. +- `+repo-report` supports local JSON input and read-only partial aggregation of health, issue, and PR list metadata. - Treat unavailable future API metrics as `unknown` and include them in `scoring_notes`. - Workflow-local renderers keep json/table/markdown output isolated from the global formatter. diff --git a/docs/competition-solution.md b/docs/competition-solution.md index 284083e..8c3832d 100644 --- a/docs/competition-solution.md +++ b/docs/competition-solution.md @@ -2,7 +2,9 @@ ## 1. Background -GitLink CLI serves both human maintainers and AI Agents. The competition focuses on intelligent open-source contribution workflows, where structured analysis, stable output, and safe automation matter more than raw command count. +GitLink CLI serves both human maintainers and AI Agents. +The competition focuses on intelligent open-source contribution workflows, +where structured analysis, stable output, and safe automation matter more than raw command count. ## 2. Problem @@ -23,9 +25,12 @@ Implemented now: - `workflow +triage` - `workflow +health` - `workflow +pr-summary` +- `workflow +repo-report` - read-only GitLink fetch layer for workflow triage and health - read-only PR metadata, changed files, and commits fetch layer for PR summary -- expanded fetch boundary tests for empty responses, label and author normalization, error-in-body handling, alternative activity timestamps, release shapes, and CI unavailability +- partial read-only repository report aggregation for health, issues, and PR list metadata +- expanded fetch boundary tests for empty responses, label and author normalization, + error-in-body handling, alternative activity timestamps, release shapes, and CI unavailability - local-first analysis with no LLM dependency - stable Agent-facing JSON / table / markdown output @@ -75,6 +80,15 @@ Planned next: - merge checklist generation - read-only fetch of PR metadata, changed files, and commits +### workflow +repo-report + +- one-command repository workflow report +- health, issue triage, and PR summary aggregation +- report score and overall risk level +- partial report behavior when optional remote sections fail +- markdown output for competition and maintainer reports +- JSON output for Agent consumption + ## 6. Innovation Points - Agent-native structured output @@ -88,9 +102,15 @@ Planned next: ## 7. Testing and Verification - Unit tests cover triage, health scoring, messages, rendering, and command helpers. -- Fetch-layer tests cover issue normalization, repository health probing, and PR metadata/file/commit normalization with `httptest`. -- Boundary tests cover empty responses, label and author normalization, error-in-body handling, alternative activity timestamps, release response shapes, and CI unavailability. -- PR summary tests cover docs-only, workflow code, internal client, security-sensitive, mixed-file, zh-CN, render, command, and fetch-failure cases. +- Fetch-layer tests cover issue normalization, repository health probing, + and PR metadata/file/commit normalization with `httptest`. +- Boundary tests cover empty responses, label and author normalization, + error-in-body handling, alternative activity timestamps, release response shapes, + and CI unavailability. +- PR summary tests cover docs-only, workflow code, internal client, + security-sensitive, mixed-file, zh-CN, render, command, and fetch-failure cases. +- Repo report tests cover aggregation, scoring, JSON/table/markdown rendering, + command wiring, local JSON input, partial fetch behavior, and include flags. - Local command examples were executed successfully. - Full repository testing passed in the current environment. - Automated tests use `httptest` and do not depend on real remote API availability. @@ -107,7 +127,8 @@ Use `Gitlink/gitlink-cli` as the reference repository: 4. `workflow +health` with table output 5. `workflow +health` with risky JSON output 6. `workflow +pr-summary` with markdown output -7. Explain how agents consume stable JSON +7. `workflow +repo-report` with markdown output for the full competition story +8. Explain how agents consume stable JSON ### Self-built test repository @@ -118,13 +139,15 @@ Use a small demo repository to show: - docs triage - healthy repo score - risky repo score +- full repo report from `shortcuts/workflow/testdata/repo_report.json` ## 9. Roadmap - Phase 1: local workflow prototype, completed - Phase 2: API fetch and normalization, completed - Phase 3: `pr-summary`, completed -- Phase 4: `release-notes`, `stale` +- Phase 4: `repo-report`, completed +- Phase 5: `release-notes`, `stale` ## 10. PR Plan @@ -132,4 +155,5 @@ Use a small demo repository to show: - PR 2: documentation and tests - PR 3: API fetch layer - PR 4: `pr-summary` -- PR 5: `release-notes` / `stale` +- PR 5: `repo-report` +- PR 6: `release-notes` / `stale` diff --git a/docs/pr-draft.md b/docs/pr-draft.md index b2e6daa..67e79c8 100644 --- a/docs/pr-draft.md +++ b/docs/pr-draft.md @@ -1,8 +1,9 @@ -# PR Draft: Add workflow agent commands for issue triage, repository health, and PR summaries +# PR Draft: Add workflow agent commands for issue triage, repository health, PR summaries, and repo reports ## Summary -This PR adds `workflow +triage`, `workflow +health`, and `workflow +pr-summary` with safe read-only analysis modes: +This PR adds `workflow +triage`, `workflow +health`, `workflow +pr-summary`, +and `workflow +repo-report` with safe read-only analysis modes: - local flags - local JSON input @@ -15,6 +16,7 @@ It also adds stable `json`, `table`, and `markdown` rendering for Agent consumpt - Help maintainers triage issues faster - Provide a structured repository health overview - Summarize pull requests with review focus, test suggestions, and merge checklist output +- Generate a repository workflow report that aggregates health, issue triage, and PR signals - Give AI Agents stable machine-readable output - Keep the workflow local-first and safe by default - Avoid any dependency on external LLM APIs @@ -24,6 +26,7 @@ It also adds stable `json`, `table`, and `markdown` rendering for Agent consumpt - New `shortcuts/workflow` rule engine and DTOs - Local command layer for `workflow +triage` and `workflow +health` - Local and remote command layer for `workflow +pr-summary` +- Local and partial remote aggregation for `workflow +repo-report` - Workflow-local renderer for `json`, `table`, and `markdown` - Read-only GitLink fetch and normalization helpers - Unit tests for rules, fetch normalization, rendering, and command wiring @@ -44,10 +47,13 @@ It also adds stable `json`, `table`, and `markdown` rendering for Agent consumpt - `httptest` coverage for API normalization and fetch tolerance - Manual command examples in local and remote read-only modes - PR summary tests for change type, risk level, partial fetch failures, renderers, and command wiring +- Repo report tests for aggregation, partial fetch behavior, renderers, local JSON input, and command wiring ## Known Limitations - Real API response shapes may still require minor normalization tweaks +- Remote `workflow +repo-report` PR aggregation currently uses PR list metadata; + detailed file and commit analysis remains available through `workflow +pr-summary --number` - Write operations are intentionally deferred to a later PR - `release-notes` and `stale` are planned next @@ -58,4 +64,5 @@ gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access t gitlink-cli workflow +health --repository Gitlink/gitlink-cli --open-issues 3 --open-prs 1 --has-readme --has-license --has-contributing --agent-readiness-known --agent-readiness-score 9 --format table gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown +gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown ``` diff --git a/docs/workflow-agent-design.md b/docs/workflow-agent-design.md index f3a5944..59c63ee 100644 --- a/docs/workflow-agent-design.md +++ b/docs/workflow-agent-design.md @@ -2,9 +2,15 @@ ## Background -`gitlink-cli` already provides low-level and shortcut operations for GitLink repositories, issues, pull requests, releases, CI, organizations, search, and users. The repository also includes `skills/gitlink-workflow/SKILL.md`, which describes AI workflow patterns such as Issue triage, PR review, and Release Notes generation. +`gitlink-cli` already provides low-level and shortcut operations for GitLink repositories, +issues, pull requests, releases, CI, organizations, search, and users. +The repository also includes `skills/gitlink-workflow/SKILL.md`, which describes +AI workflow patterns such as Issue triage, PR review, and Release Notes generation. -The current Go command tree does not include a `workflow` command group. The first competition PR should turn the documented workflow concept into concrete, deterministic CLI commands that can be used by human maintainers and AI Agents without calling an external LLM. +The current Go command tree did not include a `workflow` command group before this work. +The competition PR turns the documented workflow concept into concrete, +deterministic CLI commands that can be used by human maintainers and AI Agents +without calling an external LLM. ## Goals @@ -19,6 +25,7 @@ First PR: Additional workflow commands: - `workflow +pr-summary`: done +- `workflow +repo-report`: done - `workflow +release-notes`: planned - `workflow +stale`: planned @@ -26,8 +33,11 @@ Current implementation status: - Rule engine: done - Local command layer: done - API fetch layer: done -- Boundary tests: expanded for empty responses, field normalization, unknown tolerance, and read-only error handling +- Boundary tests: expanded for empty responses, field normalization, + unknown tolerance, and read-only error handling - PR summary command: done with local JSON input, read-only fetch, rules, renderers, and tests +- Repo report command: done with local JSON input, partial read-only fetch aggregation, + scoring, renderers, and tests ## Current Repository Findings @@ -379,6 +389,50 @@ Safety: - no merge - no remote write operation +### `workflow +repo-report` + +Inputs: +- `--owner` +- `--repo` +- `--from` +- `--lang` +- `--format` +- optional `--issue-limit` +- optional `--pr-limit` +- optional `--stale-days` +- optional `--include-issues` +- optional `--include-prs` +- optional `--include-health` + +Default format: +- `markdown` for maintainer and competition reports when `--format` is omitted + +Data: +- repository health input and score +- issue triage results aggregated by type, priority, risk, and missing information +- PR summary results aggregated by type, risk, and review focus + +Output: +- `report_score` +- `risk_level` +- `health` +- `issue_summary` +- `pr_summary` +- `recommendations` +- `reasoning` + +Partial report strategy: +- health, issue, and PR sections are fetched independently +- if at least one enabled section succeeds, the command returns a partial report +- failed sections are recorded in scoring notes or reasoning +- PR remote aggregation currently uses PR list metadata only; + detailed changed files and commits remain available through `workflow +pr-summary --number` + +Safety: +- read-only aggregation only +- no comments, labels, closes, approve/reject, or merge operations +- no LLM dependency + ### `workflow +release-notes` Inputs: @@ -418,6 +472,8 @@ The current fetch layer uses: - `triage_fetch.go` - `health_fetch.go` +- `pr_fetch.go` +- `repo_report_fetch.go` Design goals already applied: @@ -430,6 +486,7 @@ Planned fetch-layer extension: - `triage_fetch.go` and `health_fetch.go` remain the normalization boundary for remote mode. - `pr_fetch.go` now reuses the same stable DTO and message patterns for read-only PR metadata, changed files, and commits. +- `repo_report_fetch.go` composes the existing fetch helpers and records partial failures instead of failing the whole report. - Future `release-notes` should reuse the same normalization and renderer patterns. - Unknown or missing fields should stay explicit in JSON output so Agents can decide how to proceed. diff --git a/docs/workflow-agent-test-report.md b/docs/workflow-agent-test-report.md index b064229..8366c37 100644 --- a/docs/workflow-agent-test-report.md +++ b/docs/workflow-agent-test-report.md @@ -7,6 +7,7 @@ This phase covers: - Issue triage rules - health scoring rules - PR summary rules +- repository report aggregation rules - local command execution - API fetch boundary tests - remote read-only manual verification @@ -45,6 +46,7 @@ Results: - command tests - fetch boundary tests - PR summary rules and fetch tests +- repo report aggregation, render, command, and partial fetch tests ## API Fetch Boundary Tests @@ -60,19 +62,55 @@ Results: - PR summary fetch normalizes PR metadata, changed files, commits, authors, branches, and list limits - PR summary tolerates partial files or commits fetch failures while keeping base PR metadata - PR summary base PR error-in-body responses return readable errors +- repo report fetch composes health, issue, and PR sections +- repo report returns a partial report when at least one enabled section succeeds +- repo report returns an error when all enabled fetched sections fail +- repo report issue and PR limits are covered ## Manual Command Examples ```bash gitlink-cli workflow +triage --title "Install failed on Windows" --body "go install failed with error" --format table gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --format json -gitlink-cli workflow +triage --title "安装失败,无法登录" --body "运行命令时报错" --lang zh-CN --format markdown +gitlink-cli workflow +triage \ + --title "安装失败,无法登录" \ + --body "运行命令时报错" \ + --lang zh-CN \ + --format markdown gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format json -gitlink-cli workflow +health --repository Gitlink/gitlink-cli --open-issues 3 --open-prs 1 --has-readme --has-license --has-contributing --agent-readiness-known --agent-readiness-score 9 --format table -gitlink-cli workflow +health --repository demo/repo --open-issues 60 --stale-issues 25 --open-prs 12 --stale-prs 6 --recent-activity-known --recent-activity-days 120 --release-known=false --format json -gitlink-cli workflow +health --repository Gitlink/gitlink-cli --open-issues 3 --open-prs 1 --has-readme --has-license --has-contributing --lang zh-CN --format markdown +gitlink-cli workflow +health \ + --repository Gitlink/gitlink-cli \ + --open-issues 3 \ + --open-prs 1 \ + --has-readme \ + --has-license \ + --has-contributing \ + --agent-readiness-known \ + --agent-readiness-score 9 \ + --format table +gitlink-cli workflow +health \ + --repository demo/repo \ + --open-issues 60 \ + --stale-issues 25 \ + --open-prs 12 \ + --stale-prs 6 \ + --recent-activity-known \ + --recent-activity-days 120 \ + --release-known=false \ + --format json +gitlink-cli workflow +health \ + --repository Gitlink/gitlink-cli \ + --open-issues 3 \ + --open-prs 1 \ + --has-readme \ + --has-license \ + --has-contributing \ + --lang zh-CN \ + --format markdown gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json +gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown +gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json ``` ## Remote Manual Verification @@ -89,6 +127,9 @@ gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.j - `workflow +triage` still supports local parameters or a local JSON file via `--from`. - `workflow +health` still supports local parameters or a local JSON file via `--from`. - `workflow +pr-summary` supports local JSON input and read-only GitLink fetch mode. +- `workflow +repo-report` supports local JSON input and partial read-only GitLink fetch aggregation. +- Remote `workflow +repo-report` PR aggregation currently uses PR list metadata only; + detailed file and commit analysis remains available through `workflow +pr-summary --number`. - `json/table/markdown` are rendered inside the workflow package, not by the global formatter. - Fetch-layer tests use `httptest` and do not depend on the real remote API. diff --git a/shortcuts/workflow/render.go b/shortcuts/workflow/render.go index 7d1191b..366c2e2 100644 --- a/shortcuts/workflow/render.go +++ b/shortcuts/workflow/render.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "sort" "strings" "text/tabwriter" ) @@ -56,6 +57,27 @@ func RenderPRSummary(result PRSummaryResult, format string, lang string) (string return buf.String(), nil } +func RenderRepoReport(result RepoReportResult, format string, lang string) (string, error) { + var buf bytes.Buffer + switch normalizeFormat(format) { + case "json": + if err := writeJSON(&buf, result); err != nil { + return "", err + } + case "markdown": + if err := writeRepoReportMarkdown(&buf, result, lang); err != nil { + return "", err + } + case "table": + if err := writeRepoReportTable(&buf, result, lang); err != nil { + return "", err + } + default: + return "", fmt.Errorf("unsupported workflow output format %q", format) + } + return buf.String(), nil +} + func normalizeFormat(format string) string { format = strings.ToLower(strings.TrimSpace(format)) if format == "" { @@ -132,6 +154,35 @@ func writeHealthTable(w io.Writer, result HealthResult) error { return tw.Flush() } +func writeRepoReportTable(w io.Writer, result RepoReportResult, lang string) error { + tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0) + healthScore := repoReportText(lang, "not_available") + if result.Health != nil { + healthScore = fmt.Sprintf("%d", result.Health.HealthScore) + } + topRecommendation := repoReportText(lang, "not_available") + if len(result.Recommendations) > 0 { + topRecommendation = truncateTableText(result.Recommendations[0], 96) + } + if _, err := fmt.Fprintln(tw, "REPOSITORY\tREPORT_SCORE\tRISK\tHEALTH_SCORE\tISSUES\tHIGH_RISK_ISSUES\tPRS\tHIGH_RISK_PRS\tTOP_RECOMMENDATION"); err != nil { + return err + } + if _, err := fmt.Fprintf(tw, "%s\t%d\t%s\t%s\t%d\t%d\t%d\t%d\t%s\n", + result.Repository, + result.ReportScore, + result.RiskLevel, + healthScore, + result.IssueSummary.Total, + result.IssueSummary.HighRisk, + result.PRSummary.Total, + result.PRSummary.HighRisk, + topRecommendation, + ); err != nil { + return err + } + return tw.Flush() +} + func writeTriageMarkdown(w io.Writer, report TriageReport) error { if _, err := fmt.Fprintf(w, "# Issue Triage Report\n\nRepository: `%s`\n\n", report.Repository); err != nil { return err @@ -161,6 +212,116 @@ func writeTriageMarkdown(w io.Writer, report TriageReport) error { return nil } +func writeRepoReportMarkdown(w io.Writer, result RepoReportResult, lang string) error { + lang = normalizeLang(lang) + if _, err := fmt.Fprintf(w, "# %s\n\n", repoReportText(lang, "title")); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "## %s\n\n", repoReportText(lang, "overview")); err != nil { + return err + } + healthScore := repoReportText(lang, "not_available") + if result.Health != nil { + healthScore = fmt.Sprintf("%d", result.Health.HealthScore) + } + overview := []string{ + fmt.Sprintf("- Repository: `%s`", result.Repository), + fmt.Sprintf("- Report score: `%d`", result.ReportScore), + fmt.Sprintf("- Risk level: `%s`", result.RiskLevel), + fmt.Sprintf("- Health score: `%s`", healthScore), + fmt.Sprintf("- Issues analyzed: `%d`", result.IssueSummary.Total), + fmt.Sprintf("- Pull requests analyzed: `%d`", result.PRSummary.Total), + fmt.Sprintf("- Source: `%s`", result.Source), + } + for _, line := range overview { + if _, err := fmt.Fprintln(w, line); err != nil { + return err + } + } + + if _, err := fmt.Fprintf(w, "\n## %s\n\n", repoReportText(lang, "health")); err != nil { + return err + } + if result.Health == nil { + if _, err := fmt.Fprintf(w, "- %s\n", repoReportText(lang, "not_available")); err != nil { + return err + } + } else { + if _, err := fmt.Fprintf(w, "- Score: `%d`\n- Risk: `%s`\n", result.Health.HealthScore, result.Health.RiskLevel); err != nil { + return err + } + } + + if _, err := fmt.Fprintf(w, "\n## %s\n\n", repoReportText(lang, "issues")); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "- Total: `%d`\n- High risk: `%d`\n- Missing information: `%d`\n", result.IssueSummary.Total, result.IssueSummary.HighRisk, result.IssueSummary.MissingInfo); err != nil { + return err + } + writeCountMapMarkdown(w, "By type", result.IssueSummary.ByType) + writeCountMapMarkdown(w, "By priority", result.IssueSummary.ByPriority) + + if _, err := fmt.Fprintf(w, "\n## %s\n\n", repoReportText(lang, "prs")); err != nil { + return err + } + if _, err := fmt.Fprintf(w, "- Total: `%d`\n- High risk: `%d`\n", result.PRSummary.Total, result.PRSummary.HighRisk); err != nil { + return err + } + writeCountMapMarkdown(w, "By type", result.PRSummary.ByType) + writeCountMapMarkdown(w, "By risk", result.PRSummary.ByRisk) + if len(result.PRSummary.ReviewFocus) > 0 { + if _, err := fmt.Fprintln(w, "- Review focus:"); err != nil { + return err + } + for _, focus := range result.PRSummary.ReviewFocus { + if _, err := fmt.Fprintf(w, " - %s\n", focus); err != nil { + return err + } + } + } + + if err := writeRepoReportMarkdownList(w, repoReportText(lang, "recommendations"), result.Recommendations, repoReportText(lang, "not_available")); err != nil { + return err + } + return writeRepoReportMarkdownList(w, repoReportText(lang, "reasoning"), result.Reasoning, repoReportText(lang, "not_available")) +} + +func writeCountMapMarkdown(w io.Writer, title string, values map[string]int) error { + if len(values) == 0 { + return nil + } + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + if _, err := fmt.Fprintf(w, "- %s:\n", title); err != nil { + return err + } + for _, key := range keys { + if _, err := fmt.Fprintf(w, " - `%s`: `%d`\n", key, values[key]); err != nil { + return err + } + } + return nil +} + +func writeRepoReportMarkdownList(w io.Writer, title string, values []string, fallback string) error { + if _, err := fmt.Fprintf(w, "\n## %s\n\n", title); err != nil { + return err + } + if len(values) == 0 { + _, err := fmt.Fprintf(w, "- %s\n", fallback) + return err + } + for _, value := range values { + if _, err := fmt.Fprintf(w, "- %s\n", value); err != nil { + return err + } + } + return nil +} + func writePRSummaryMarkdown(w io.Writer, result PRSummaryResult, lang string) error { lang = normalizeLang(lang) if _, err := fmt.Fprintf(w, "# %s\n\n", message(lang, "pr_summary_title")); err != nil { 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/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/workflow.go b/shortcuts/workflow/workflow.go index 27ece5f..473e802 100644 --- a/shortcuts/workflow/workflow.go +++ b/shortcuts/workflow/workflow.go @@ -24,6 +24,7 @@ func Shortcuts() []*common.Shortcut { newTriageShortcut(), newHealthShortcut(), newPRSummaryShortcut(), + newRepoReportShortcut(), } } diff --git a/shortcuts/workflow/workflow_test.go b/shortcuts/workflow/workflow_test.go index b0ed3b9..b7d03cf 100644 --- a/shortcuts/workflow/workflow_test.go +++ b/shortcuts/workflow/workflow_test.go @@ -28,6 +28,9 @@ func TestShortcutsExposesWorkflowCommands(t *testing.T) { if !names["pr-summary"] { t.Fatal("Shortcuts missing pr-summary") } + if !names["repo-report"] { + t.Fatal("Shortcuts missing repo-report") + } } func TestRunTriageWithSingleIssueArgs(t *testing.T) { @@ -191,6 +194,32 @@ func TestRunHealthRemoteModeUsesFetch(t *testing.T) { } } +func TestCollectRepoReportFromJSONFile(t *testing.T) { + ctx := &common.RuntimeContext{ + Args: map[string]string{ + "from": filepath.Join("testdata", "repo_report.json"), + }, + } + input, notes, err := collectRepoReportInput(ctx) + if err != nil { + t.Fatalf("collectRepoReportInput returned error: %v", err) + } + if len(notes) != 0 { + t.Fatalf("notes = %+v, want empty", notes) + } + if input.Repository == "" || len(input.Issues) == 0 || len(input.PullRequests) == 0 { + t.Fatalf("input = %+v, want populated report fixture", input) + } +} + +func TestCollectRepoReportMissingInputs(t *testing.T) { + ctx := &common.RuntimeContext{Args: map[string]string{}} + _, _, err := collectRepoReportInput(ctx) + if err == nil { + t.Fatal("collectRepoReportInput returned nil error without --from or owner/repo") + } +} + func writeJSONFixture(t *testing.T, path string, data interface{}) { t.Helper() encoded, err := json.Marshal(data) diff --git a/skills/gitlink-workflow/SKILL.md b/skills/gitlink-workflow/SKILL.md index d5a3798..62cf3e3 100644 --- a/skills/gitlink-workflow/SKILL.md +++ b/skills/gitlink-workflow/SKILL.md @@ -119,6 +119,29 @@ Rules: - This command is read-only: it does not comment, approve, reject, merge, label, or close pull requests. - Do not use LLM APIs for this workflow; it is rule-based and explainable. +## Workflow: Repository Report (Read-only) + +Use `workflow +repo-report` when a maintainer or Agent needs a single repository workflow report +that aggregates health, issue triage, and PR review signals. + +```bash +# Maintainer report +gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown + +# Agent-readable report +gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format json + +# Local fixture mode +gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown +``` + +Rules: +- Prefer `--format json` when another Agent consumes the output. +- Prefer `--format markdown` for maintainer reports, competition materials, and review handoff. +- Treat remote mode as read-only aggregation only. +- Do not comment, label, close, approve, reject, or merge from this workflow. +- PR details in remote report mode may be partial; use `workflow +pr-summary --number ` for a focused PR review. + ## 最佳实践 - 所有工作流命令使用 `--format json` 以便解析输出 From 2dc230430b16843461b68bb77ba237983eb7b64c Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Thu, 21 May 2026 22:23:40 +0800 Subject: [PATCH 04/10] docs: add competition submission materials --- WORK_CONTINUATION.md | 20 +++- docs/competition-solution.md | 27 +++++ docs/competition-submit.zh-CN.md | 161 +++++++++++++++++++++++++++++ docs/defense-qa.md | 61 +++++++++++ docs/demo-script.md | 103 ++++++++++++++++++ docs/final-submission-checklist.md | 51 +++++++++ docs/pr-draft.md | 127 +++++++++++++++-------- docs/workflow-agent-test-report.md | 36 +++++++ 8 files changed, 542 insertions(+), 44 deletions(-) create mode 100644 docs/competition-submit.zh-CN.md create mode 100644 docs/defense-qa.md create mode 100644 docs/demo-script.md create mode 100644 docs/final-submission-checklist.md diff --git a/WORK_CONTINUATION.md b/WORK_CONTINUATION.md index 874dc95..6ccfffc 100644 --- a/WORK_CONTINUATION.md +++ b/WORK_CONTINUATION.md @@ -54,6 +54,12 @@ Planned next: - Expanded fetch-layer boundary coverage for empty responses, label and author normalization, error-in-body handling, alternative activity timestamps, release shapes, CI unavailability, and PR summary normalization. - Added `workflow +repo-report` with local JSON input, read-only partial fetch aggregation, report score, overall risk level, markdown/table/json rendering, and tests. +- Added competition submission materials: + - `docs/competition-submit.zh-CN.md` + - `docs/demo-script.md` + - `docs/final-submission-checklist.md` + - `docs/defense-qa.md` +- Updated PR draft, test report, competition solution, and continuation notes for final submission readiness. ## Current Go Toolchain Status @@ -126,6 +132,10 @@ Planned next: - `README.md` - `docs/competition-solution.md` - `docs/pr-draft.md` +- `docs/competition-submit.zh-CN.md` +- `docs/demo-script.md` +- `docs/final-submission-checklist.md` +- `docs/defense-qa.md` - `docs/workflow-agent-design.md` - `docs/workflow-agent-test-report.md` - `shortcuts/workflow/api_types.go` @@ -152,6 +162,10 @@ Planned next: - `workflow +release-notes` is not implemented. - `workflow +stale` is not implemented. - Remote write operations remain intentionally deferred. +- GitLink official PR is not created yet. +- CI screenshot/result is not recorded yet. +- Demo video is not recorded yet. +- Final competition submission links are not filled in yet. ## Known Issues @@ -180,7 +194,7 @@ Planned next: ## Next Minimal Executable Task -Design workflow +release-notes with read-only PR titles and commit messages; implement with httptest mock first; do not add LLM or write operations. +Create GitLink official PR and record CI result. ## How To Continue After Interruption @@ -190,9 +204,9 @@ Design workflow +release-notes with read-only PR titles and commit messages; imp 4. Set temporary GOPROXY if dependency download fails: `https://goproxy.cn,direct`. 5. Run `go test ./shortcuts/workflow`. 6. Run `go test ./...`. -7. Start `workflow +release-notes` design only after confirming the existing workflow tests still pass. +7. Create the GitLink official PR before adding more features. 8. Keep all new workflow commands read-only by default. ## Recommended Next Codex Instruction -Design workflow +release-notes with read-only PR titles and commit messages; implement with httptest mock first; do not add LLM or write operations. +Create GitLink official PR from branch `codex/workflow-agent`, then record PR URL and CI result in `docs/final-submission-checklist.md`. diff --git a/docs/competition-solution.md b/docs/competition-solution.md index 8c3832d..46a5954 100644 --- a/docs/competition-solution.md +++ b/docs/competition-solution.md @@ -51,6 +51,15 @@ Planned next: ## 5. Implemented Features +| Command | Status | Main Value | +|---|---|---| +| `workflow +triage` | Done | Issue classification, priority, missing information, and actions | +| `workflow +health` | Done | Repository health score, risk level, and recommendations | +| `workflow +pr-summary` | Done | PR risk, review focus, test suggestions, and merge checklist | +| `workflow +repo-report` | Done | Aggregated repository workflow report for maintainers and Agents | +| `workflow +release-notes` | Planned | Release note generation from PR titles and commits | +| `workflow +stale` | Planned | Stale issue and PR analysis | + ### workflow +triage - issue type detection @@ -157,3 +166,21 @@ Use a small demo repository to show: - PR 4: `pr-summary` - PR 5: `repo-report` - PR 6: `release-notes` / `stale` + +## 11. Evaluation Mapping + +| Criterion | Evidence | +|---|---| +| 功能完整性 20% | Four implemented commands cover Issue triage, health scoring, PR summary, and repo report | +| 创新性 20% | Agent-native JSON, explainable rules, local-first safety model, repository workflow report | +| 实用价值 20% | Reduces maintainer triage/review overhead and creates copy-ready markdown reports | +| 文档与演示 20% | README, design doc, test report, competition write-up, demo script, defense Q&A | +| 成果落地 20% | Prepared for GitLink official PR, CI verification, and maintainer review iteration | + +## 12. Landing Plan + +- Push the implementation branch to the public repository. +- Create a GitLink official PR against `Gitlink/gitlink-cli`. +- Record CI result and PR URL in `docs/final-submission-checklist.md`. +- Respond to maintainer review over the expected 1-2 week review cycle. +- Keep `release-notes` and `stale` as follow-up work instead of expanding this PR further. diff --git a/docs/competition-submit.zh-CN.md b/docs/competition-submit.zh-CN.md new file mode 100644 index 0000000..513823f --- /dev/null +++ b/docs/competition-submit.zh-CN.md @@ -0,0 +1,161 @@ +# GitLink CLI Agent Workflow 增强套件参赛说明 + +## 1. 作品概述 + +作品名称:GitLink CLI Agent Workflow 增强套件。 + +本作品基于 `Gitlink/gitlink-cli` 开源仓库,面向开源项目维护者和 AI Agent +增加规则型、可解释、只读安全的协作分析工作流能力。目标不是替代维护者, +而是把 Issue 分诊、仓库健康度评估、PR 审阅摘要和仓库工作流报告变成 +CLI 原生命令,降低维护前的信息整理成本。 + +当前已实现四个命令: + +- `workflow +triage` +- `workflow +health` +- `workflow +pr-summary` +- `workflow +repo-report` + +## 2. 对应赛题与场景 + +本作品主要对应子赛题一:`gitlink-cli` 功能增强 / 开源项目贡献。 + +对应工作流场景: + +- Issue 自动分拣 +- PR Review 辅助 +- 仓库健康度评估 +- 仓库工作流报告生成 + +## 3. 功能完整性说明 + +### workflow +triage + +`workflow +triage` 对 Issue 做规则型智能分诊,支持本地参数、`--from` +JSON 文件和 GitLink 远端只读 fetch。输出包括 Issue 类型识别、优先级判断、 +缺失信息检测、风险标记、建议操作、建议评论、规则命中原因,并支持 +`json`、`table`、`markdown` 三种格式。 + +### workflow +health + +`workflow +health` 生成仓库健康度评分,覆盖 Issue / PR backlog、最近活跃度、 +Release、CI、文档、License、CONTRIBUTING 和 Agent readiness 等指标。 +当远端 API 或某些指标不可用时,命令不会伪造结果,而是标记为 unknown +并在 scoring notes 中说明,保证 Agent 和维护者可以判断可信度。 + +### workflow +pr-summary + +`workflow +pr-summary` 对单个 PR 生成审阅摘要,分析 PR 元数据、changed files +和 commits,输出 change type、risk level、review focus、test suggestions、 +merge checklist 和 reasoning。该命令只读,不评论、不 approve、不 reject、 +不 merge。 + +### workflow +repo-report + +`workflow +repo-report` 聚合 health、triage 和 pr-summary 的能力,生成一份 +仓库工作流报告。报告包含整体分数、风险等级、Issue 分布、PR 风险分布、 +维护建议和判断依据,适合维护者、比赛材料和 AI Agent 使用。 + +## 4. 创新性说明 + +- Agent-native structured output:`json` 给 Agent,`markdown` 给维护者,`table` 给终端用户。 +- Rule-based explainable intelligence:不依赖 LLM,所有判断都有规则依据。 +- Safety-first read-only workflow:远端模式只读,不污染仓库状态。 +- GitLink CLI 原生集成:基于 shortcuts 架构,不是外部脚本。 +- Repository workflow report:一条命令聚合多个仓库治理维度。 +- Bilingual support:支持 `en` / `zh-CN`。 + +## 5. 实用价值说明 + +| 问题 | 对应功能 | 价值 | +|---|---|---| +| 开源仓库 Issue 积压 | `workflow +triage` | 快速识别类型、优先级和缺失信息 | +| PR 审阅前理解成本高 | `workflow +pr-summary` | 生成审阅重点、测试建议和合并清单 | +| 仓库维护状态不清晰 | `workflow +health` | 给出健康度评分、风险等级和修复建议 | +| 维护者需要汇总报告 | `workflow +repo-report` | 一条命令生成可复制的仓库工作流报告 | +| AI Agent 需要稳定输出 | `json` DTO | 字段稳定,适合脚本和 Agent 消费 | + +## 6. 技术路线 + +- Go + Cobra +- `gitlink-cli` shortcuts 架构 +- GitLink API 只读 fetch +- API response normalization +- rule engine +- health scoring +- workflow-local renderer +- `json` / `table` / `markdown` +- `httptest` mock +- testdata reproducible examples + +## 7. 安全边界 + +- 不调用 LLM API +- 不执行远端写操作 +- 不自动评论 +- 不自动打标签 +- 不关闭 Issue +- 不 approve / reject / merge PR +- 不修改 `internal/output` +- 远端模式只读 fetch +- 所有分析在本地规则层完成 + +## 8. 测试与验证 + +测试命令: + +```bash +gofmt -w shortcuts/workflow/*.go shortcuts/register.go +go test ./shortcuts/workflow +go test ./... +``` + +测试覆盖: + +- triage rules +- health scoring +- pr-summary rules +- repo-report aggregation +- fetch normalization +- partial failure +- `json` / `table` / `markdown` render +- `--from` testdata +- command smoke tests + +## 9. 可复现演示命令 + +稳定本地演示命令: + +```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 +``` + +远端只读演示命令: + +```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 +``` + +如果真实远端受认证、网络或 API 形态影响,可使用 `--from` testdata 稳定复现。 + +## 10. 成果落地计划 + +- 当前成果已在个人仓库完成。 +- 下一步将提交 GitLink 官方主仓库 PR。 +- 以 PR 已提交且通过 CI 作为成果落地基础。 +- 争取在 Review 后根据维护者意见迭代。 +- `release-notes` / `stale` 作为后续规划。 + +## 11. 后续规划 + +- `workflow +release-notes` +- `workflow +stale` +- 更完整的真实 GitLink API field normalization +- 官方 Skill 收录申请 +- 更多真实项目验证 diff --git a/docs/defense-qa.md b/docs/defense-qa.md new file mode 100644 index 0000000..c776098 --- /dev/null +++ b/docs/defense-qa.md @@ -0,0 +1,61 @@ +# Defense Q&A + +## 1. 作品解决了什么问题? + +本作品解决开源项目维护中信息整理成本高的问题。维护者面对大量 Issue、PR 和仓库状态信号时,往往需要手动判断优先级、风险和下一步动作。本项目把这些判断沉淀为 GitLink CLI 原生命令,提供可复现、可解释、只读安全的工作流分析能力。 + +## 2. 为什么不使用 LLM? + +本阶段不使用 LLM 是为了降低依赖风险和运行成本,并保证输出稳定可测。比赛目标是贡献可落地的 CLI 功能,规则型分析更适合进入基础工具链。未来如果需要接入 LLM,也可以在稳定 DTO 和安全边界之上扩展,而不是直接绑定外部模型。 + +## 3. 规则型分析如何体现智能化? + +智能化不等于必须调用大模型。本项目通过关键词、权重评分、缺失信息检测、风险标记和健康度评分,把维护经验转化为自动化规则。每个结论都有 reasoning 和 matched rules,维护者可以理解判断来源,Agent 也可以消费结构化结果继续处理。 + +## 4. 如何保证不污染远端仓库? + +所有 workflow 命令都遵守只读边界。远端模式只 fetch 数据,不评论、不打标签、不关闭 Issue,也不 approve、reject 或 merge PR。命令默认生成分析结果和建议,不执行写操作,因此不会改变真实仓库状态,适合在评审和演示中安全运行。 + +## 5. 与普通 CLI 命令相比有什么区别? + +普通 CLI 命令主要完成单个 API 操作,例如查看 Issue 或 PR。本作品新增的是工作流级分析命令,会聚合输入、应用规则、生成风险等级、建议和报告。它不是简单包装 API,而是为维护者和 AI Agent 提供更高层的协作决策辅助。 + +## 6. 与人工维护 Issue / PR 相比有什么价值? + +人工维护仍然是最终决策者,但本项目能先完成重复的信息整理工作。例如自动识别 bug、docs、security,指出缺失复现信息,给出 PR 审阅重点和测试建议。这样维护者可以把时间放在判断和修复上,而不是反复阅读和归类。 + +## 7. json/table/markdown 三种输出分别面向谁? + +`json` 面向 AI Agent 和脚本,字段稳定,便于自动处理;`table` 面向终端用户,适合快速查看摘要;`markdown` 面向维护者、Issue/PR 评论草稿和比赛文档,便于复制传播。三种输出复用同一分析结果,减少重复实现。 + +## 8. repo-report 的评分如何计算? + +`repo-report` 以 health score 为基础,结合高风险 Issue、缺失信息数量、高风险或 critical PR 等信号进行扣分,并限制在 0 到 100。风险等级按分数区间划分;如果出现 security P0 Issue 或 critical PR,会提升整体风险等级,保证安全问题优先暴露。 + +## 9. 如果 GitLink API 字段变化怎么办? + +fetch 层使用 response normalization 处理多种字段形态,例如不同的 author、label、release、PR 字段别名。如果真实 API 继续变化,后续只需要在 workflow fetch 层补充映射和 httptest,不需要修改规则引擎或输出协议,维护成本较低。 + +## 10. 为什么成果可以落地到 gitlink-cli 主仓库? + +实现遵循现有 shortcuts 架构,没有修改 `cmd/` 和 `internal/output`,也没有新增第三方依赖。功能边界清楚、默认只读、测试覆盖集中,适合以 PR 形式提交到主仓库。维护者可以分阶段 review,不需要一次接受复杂平台级改造。 + +## 11. 当前局限是什么? + +当前局限主要是远端 API 形态仍需更多真实项目验证,`repo-report` 的远端 PR 部分使用 PR 列表元数据,深度 files/commits 分析仍通过单独的 `workflow +pr-summary` 完成。此外 `release-notes` 和 `stale` 仍是后续规划,尚未实现。 + +## 12. 后续规划是什么? + +后续计划包括 `workflow +release-notes`、`workflow +stale`、更完整的真实 GitLink API 字段归一化、官方 Skill 收录申请和更多真实项目验证。所有后续功能仍会坚持只读优先、可测试、可解释,不会默认执行破坏性远端操作。 + +## 13. 如何验证功能正常? + +可以运行 `gofmt -w shortcuts/workflow/*.go shortcuts/register.go`、`go test ./shortcuts/workflow` 和 `go test ./...`。演示时优先使用 `shortcuts/workflow/testdata/` 下的 JSON 文件,避免网络和认证影响。远端命令也只读,可作为 smoke 验证。 + +## 14. 如果 PR 没被合并,成果落地如何体现? + +子赛题一鼓励提交官方 PR。即使短期未合并,只要 PR 已提交、CI 通过并进入维护者 Review,就已经具备成果落地基础。项目还提供个人仓库、完整测试、文档、演示脚本和后续迭代计划,便于根据维护者反馈继续推进。 + +## 15. 这个项目如何服务 AI Agent? + +AI Agent 需要稳定、结构化、可解释的工具输出。本项目为 Issue、PR、健康度和仓库报告提供稳定 JSON DTO,并保留 reasoning、risk、recommendations 等字段。Agent 可以读取这些结果,生成后续任务、报告或维护计划,而不依赖不稳定的自然语言解析。 diff --git a/docs/demo-script.md b/docs/demo-script.md new file mode 100644 index 0000000..7fbc429 --- /dev/null +++ b/docs/demo-script.md @@ -0,0 +1,103 @@ +# Demo Script + +## 0:00-0:20 项目背景 + +屏幕内容: +- 打开仓库 README 的 Workflow Agent Commands 小节。 +- 展示 `workflow +triage`、`workflow +health`、`workflow +pr-summary`、`workflow +repo-report`。 + +旁白: +本项目为 GitLink CLI 增加面向维护者和 AI Agent 的规则型工作流增强套件。 +它不依赖 LLM,不做远端写操作,通过 CLI 原生命令提供 Issue 分诊、健康度评分、 +PR 审阅摘要和仓库工作流报告。 + +截图点位: +- README workflow 命令列表。 + +## 0:20-0:50 workflow +triage + +演示命令: + +```bash +gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format table +``` + +旁白: +这个命令会对 Issue 做规则型分诊,自动识别 Issue 类型,判断优先级, +发现缺失信息,并给出建议操作。表格输出适合维护者在终端快速查看。 + +截图点位: +- table 输出中的 type、priority、missing、action。 + +## 0:50-1:20 workflow +health + +演示命令: + +```bash +gitlink-cli workflow +health --from shortcuts/workflow/testdata/health_good.json --format markdown +``` + +旁白: +健康度命令根据 Issue、PR、Release、CI、文档、License 和贡献指南等指标, +生成仓库健康度评分、风险等级和维护建议。Markdown 输出可直接复制到报告中。 + +截图点位: +- health score、risk level、recommendations。 + +## 1:20-1:50 workflow +pr-summary + +演示命令: + +```bash +gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format markdown +``` + +旁白: +PR 摘要命令会识别 PR 类型和风险等级,生成 review focus、test suggestions +和 merge checklist。它只读分析,不会评论、approve、reject 或 merge PR。 + +截图点位: +- Review Focus、Test Suggestions、Merge Checklist。 + +## 1:50-2:20 workflow +repo-report + +演示命令: + +```bash +gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown +``` + +旁白: +仓库报告命令聚合 Issue、PR 和仓库健康度,生成一份完整的仓库工作流报告。 +它适合维护者快速了解项目状态,也适合比赛展示项目的综合能力。 + +截图点位: +- Report score、Risk level、Issue Triage Summary、PR Review Summary。 + +## 2:20-2:40 JSON 输出给 Agent + +演示命令: + +```bash +gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json +``` + +旁白: +同一个报告可以输出稳定 JSON 字段,供 AI Agent 和脚本消费。 +这让 Agent 可以基于结构化结果继续做排序、摘要或生成后续任务。 + +截图点位: +- JSON 中的 `report_score`、`risk_level`、`issue_summary`、`pr_summary`。 + +## 2:40-3:00 安全边界与总结 + +屏幕内容: +- 展示参赛说明中的安全边界小节。 + +旁白: +整个 workflow-agent 套件不依赖 LLM,远端模式只读,不评论、不打标签、不关闭 Issue, +也不 approve、reject 或 merge PR。后续规划包括 `workflow +release-notes` +和 `workflow +stale`,但当前提交保持聚焦、可测试、可落地。 + +截图点位: +- 安全边界列表。 diff --git a/docs/final-submission-checklist.md b/docs/final-submission-checklist.md new file mode 100644 index 0000000..9337f21 --- /dev/null +++ b/docs/final-submission-checklist.md @@ -0,0 +1,51 @@ +# Final Submission Checklist + +## Repository Links + +- GitHub repository: +- GitHub branch: +- GitLink fork: +- GitLink official PR: +- CI result: + +## Core Files + +- README.md +- README.zh-CN.md +- docs/competition-submit.zh-CN.md +- docs/workflow-agent-design.md +- docs/workflow-agent-test-report.md +- docs/competition-solution.md +- docs/demo-script.md +- docs/pr-draft.md +- skills/gitlink-workflow/SKILL.md + +## Commands Implemented + +- workflow +triage +- workflow +health +- workflow +pr-summary +- workflow +repo-report + +## Test Commands + +- gofmt -w shortcuts/workflow/*.go shortcuts/register.go +- go test ./shortcuts/workflow +- go test ./... + +## Demo Assets + +- demo video: +- screenshots: +- command output logs: + +## Before Submission + +- [ ] GitLink official PR created +- [ ] CI passed +- [ ] README examples verified +- [ ] demo video recorded +- [ ] competition platform form filled +- [ ] repository links submitted +- [ ] PR link submitted +- [ ] docs links submitted diff --git a/docs/pr-draft.md b/docs/pr-draft.md index 67e79c8..df3d05a 100644 --- a/docs/pr-draft.md +++ b/docs/pr-draft.md @@ -1,68 +1,113 @@ -# PR Draft: Add workflow agent commands for issue triage, repository health, PR summaries, and repo reports +# feat(workflow): add agent workflow commands for repository maintenance ## Summary -This PR adds `workflow +triage`, `workflow +health`, `workflow +pr-summary`, -and `workflow +repo-report` with safe read-only analysis modes: +This PR adds four read-only workflow commands for repository maintenance: -- local flags -- local JSON input -- read-only GitLink fetch mode +- `workflow +triage` +- `workflow +health` +- `workflow +pr-summary` +- `workflow +repo-report` -It also adds stable `json`, `table`, and `markdown` rendering for Agent consumption. +The commands provide rule-based, explainable analysis with stable `json`, concise `table`, +and copy-friendly `markdown` output. ## Motivation -- Help maintainers triage issues faster -- Provide a structured repository health overview -- Summarize pull requests with review focus, test suggestions, and merge checklist output -- Generate a repository workflow report that aggregates health, issue triage, and PR signals -- Give AI Agents stable machine-readable output -- Keep the workflow local-first and safe by default -- Avoid any dependency on external LLM APIs +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 -- New `shortcuts/workflow` rule engine and DTOs -- Local command layer for `workflow +triage` and `workflow +health` -- Local and remote command layer for `workflow +pr-summary` -- Local and partial remote aggregation for `workflow +repo-report` -- Workflow-local renderer for `json`, `table`, and `markdown` -- Read-only GitLink fetch and normalization helpers -- Unit tests for rules, fetch normalization, rendering, and command wiring -- Competition and test documentation updates +### `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 comment, label, close, approve, reject, merge, or release write actions -- Health scoring tolerates unknown or unavailable metrics +- 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 -- `gofmt -w shortcuts/workflow/*.go shortcuts/register.go` -- `go test ./shortcuts/workflow` -- `go test ./...` -- `httptest` coverage for API normalization and fetch tolerance -- Manual command examples in local and remote read-only modes -- PR summary tests for change type, risk level, partial fetch failures, renderers, and command wiring -- Repo report tests for aggregation, partial fetch behavior, renderers, local JSON input, and command wiring +```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` +- `docs/competition-solution.md` +- `docs/competition-submit.zh-CN.md` +- `docs/demo-script.md` +- `docs/final-submission-checklist.md` +- `docs/defense-qa.md` +- `skills/gitlink-workflow/SKILL.md` +- `WORK_CONTINUATION.md` ## Known Limitations -- Real API response shapes may still require minor normalization tweaks +- `workflow +release-notes` is not implemented. +- `workflow +stale` is not implemented. +- Real GitLink API shapes may require follow-up normalization. - Remote `workflow +repo-report` PR aggregation currently uses PR list metadata; - detailed file and commit analysis remains available through `workflow +pr-summary --number` -- Write operations are intentionally deferred to a later PR -- `release-notes` and `stale` are planned next + detailed changed-file and commit analysis is available through `workflow +pr-summary --number`. -## Screenshots or Examples +## Examples ```bash -gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --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 +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table -gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown -gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown +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-test-report.md b/docs/workflow-agent-test-report.md index 8366c37..c166148 100644 --- a/docs/workflow-agent-test-report.md +++ b/docs/workflow-agent-test-report.md @@ -136,3 +136,39 @@ gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report ## 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 +``` From 018c08e1785d3561cf66731445f69ad812c7366e Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Fri, 22 May 2026 09:53:39 +0800 Subject: [PATCH 05/10] docs: align workflow agent competition materials --- WORK_CONTINUATION.md | 6 +++--- docs/pr-draft.md | 2 -- skills/gitlink-workflow/SKILL.md | 6 ++++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/WORK_CONTINUATION.md b/WORK_CONTINUATION.md index 6ccfffc..0ca89b9 100644 --- a/WORK_CONTINUATION.md +++ b/WORK_CONTINUATION.md @@ -41,7 +41,7 @@ Planned next: - Added local input support: - `workflow +triage`: single issue flags or `--from` JSON file. - `workflow +health`: explicit metric flags or `--from` JSON file. -- `workflow +pr-summary`: PR number fetch or `--from` JSON file. + - `workflow +pr-summary`: PR number fetch or `--from` JSON file. - `workflow +repo-report`: aggregate health, issues, and PR list metadata or `--from` JSON file. - Verified all three commands run locally without GitLink API write access. - Added read-only workflow API fetch helpers and mock tests. @@ -194,7 +194,7 @@ Planned next: ## Next Minimal Executable Task -Create GitLink official PR and record CI result. +Create GitLink official PR, record CI result, and record demo video. ## How To Continue After Interruption @@ -209,4 +209,4 @@ Create GitLink official PR and record CI result. ## Recommended Next Codex Instruction -Create GitLink official PR from branch `codex/workflow-agent`, then record PR URL and CI result in `docs/final-submission-checklist.md`. +Create GitLink official PR from branch `codex/workflow-agent`, then record PR URL, CI result, and demo video status in `docs/final-submission-checklist.md`. diff --git a/docs/pr-draft.md b/docs/pr-draft.md index df3d05a..d16dd97 100644 --- a/docs/pr-draft.md +++ b/docs/pr-draft.md @@ -100,8 +100,6 @@ Coverage includes: - `workflow +release-notes` is not implemented. - `workflow +stale` is not implemented. - Real GitLink API shapes may require follow-up normalization. -- Remote `workflow +repo-report` PR aggregation currently uses PR list metadata; - detailed changed-file and commit analysis is available through `workflow +pr-summary --number`. ## Examples diff --git a/skills/gitlink-workflow/SKILL.md b/skills/gitlink-workflow/SKILL.md index 62cf3e3..0f7aa2a 100644 --- a/skills/gitlink-workflow/SKILL.md +++ b/skills/gitlink-workflow/SKILL.md @@ -119,10 +119,11 @@ Rules: - This command is read-only: it does not comment, approve, reject, merge, label, or close pull requests. - Do not use LLM APIs for this workflow; it is rule-based and explainable. -## Workflow: Repository Report (Read-only) +## 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 @@ -132,13 +133,14 @@ gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format ma gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format json # Local fixture mode -gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown +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. From b0327204d030c18faaf0d00eeade8774c8359981 Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Fri, 22 May 2026 10:05:51 +0800 Subject: [PATCH 06/10] docs: update final submission checklist placeholders --- docs/final-submission-checklist.md | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/docs/final-submission-checklist.md b/docs/final-submission-checklist.md index 9337f21..00cdb96 100644 --- a/docs/final-submission-checklist.md +++ b/docs/final-submission-checklist.md @@ -2,11 +2,12 @@ ## Repository Links -- GitHub repository: -- GitHub branch: -- GitLink fork: -- GitLink official PR: -- CI result: +- GitHub repository: 待填写 +- GitHub branch: 待填写 +- GitLink fork: 待填写 +- GitLink official PR: 待填写 +- CI result: 待填写 +- Competition submission: 待填写 ## Core Files @@ -35,9 +36,9 @@ ## Demo Assets -- demo video: -- screenshots: -- command output logs: +- demo video: 待填写 +- screenshots: 待填写 +- command output logs: 待填写 ## Before Submission From be8327822a13f2a0e54631bc92190b168bbb7d34 Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Fri, 22 May 2026 11:41:43 +0800 Subject: [PATCH 07/10] docs: remove continuation file from PR draft --- docs/pr-draft.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/pr-draft.md b/docs/pr-draft.md index d16dd97..24fe18e 100644 --- a/docs/pr-draft.md +++ b/docs/pr-draft.md @@ -93,7 +93,6 @@ Coverage includes: - `docs/final-submission-checklist.md` - `docs/defense-qa.md` - `skills/gitlink-workflow/SKILL.md` -- `WORK_CONTINUATION.md` ## Known Limitations From 29dee2697e65e52bd958240e619f9c92cf9e52dc Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Fri, 22 May 2026 12:09:45 +0800 Subject: [PATCH 08/10] chore: ignore local gitlink-cli binary --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4b9c885 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ + +gitlink-cli.exe From b3f139801b11435bc3b68ad5411e4ab59ea7e6f8 Mon Sep 17 00:00:00 2001 From: whzy <2402686765@qq.com> Date: Fri, 22 May 2026 12:37:34 +0800 Subject: [PATCH 09/10] docs: trim materials for official PR --- WORK_CONTINUATION.md | 212 ----------------------------- docs/competition-solution.md | 186 ------------------------- docs/competition-submit.zh-CN.md | 161 ---------------------- docs/defense-qa.md | 61 --------- docs/demo-script.md | 103 -------------- docs/final-submission-checklist.md | 52 ------- docs/pr-draft.md | 5 - 7 files changed, 780 deletions(-) delete mode 100644 WORK_CONTINUATION.md delete mode 100644 docs/competition-solution.md delete mode 100644 docs/competition-submit.zh-CN.md delete mode 100644 docs/defense-qa.md delete mode 100644 docs/demo-script.md delete mode 100644 docs/final-submission-checklist.md diff --git a/WORK_CONTINUATION.md b/WORK_CONTINUATION.md deleted file mode 100644 index 0ca89b9..0000000 --- a/WORK_CONTINUATION.md +++ /dev/null @@ -1,212 +0,0 @@ -# GitLink CLI Workflow Agent Work Continuation - -## Current Goal - -Implement the GitLink CLI Agent Workflow enhancement suite for `track1_2026GitLinkCli`. - -Current implemented slice: -- `workflow +triage` -- `workflow +health` -- `workflow +pr-summary` -- `workflow +repo-report` - -Planned next: -- `workflow +release-notes` -- `workflow +stale` - -## Current Branch - -- Branch: `codex/workflow-agent` -- Remote: `origin https://gitlink.org.cn/Gitlink/gitlink-cli.git` -- Repository path: `E:\GitLinkCLI-Competition\gitlink-cli` -- Local Go toolchain: `E:\GitLinkCLI-Competition\tools\go1.26.1\go` - -## Completed Content - -- Confirmed current workspace repository is `Gitlink/gitlink-cli`. -- Confirmed `workflow` command group did not previously exist in Go command registration. -- Confirmed `skills/gitlink-workflow/SKILL.md` exists as workflow guidance only. -- Read core command, shortcut, output, client, config, and test patterns. -- Created first workflow agent design draft at `docs/workflow-agent-design.md`. -- Workspace moved out of `C:\Users\zyc\OneDrive\Desktop\4c文档` to `E:\GitLinkCLI-Competition\gitlink-cli`. -- Added pure workflow DTOs under `shortcuts/workflow/types.go`. -- Added pure issue triage rules under `shortcuts/workflow/triage_rules.go`. -- Added pure repository health scoring under `shortcuts/workflow/health_score.go`. -- Added lightweight language messages under `shortcuts/workflow/messages.go`. -- Added unit tests for triage, health, messages, renderers, and local workflow command helpers. -- Installed Go 1.26.1 locally for Windows amd64 after verifying the machine is Intel x64. -- Added `workflow.Shortcuts()` with `+triage`, `+health`, and `+pr-summary`. -- Registered the `workflow` shortcut group in `shortcuts/register.go`. -- Added workflow-local JSON, table, and markdown renderers. -- Added local input support: - - `workflow +triage`: single issue flags or `--from` JSON file. - - `workflow +health`: explicit metric flags or `--from` JSON file. - - `workflow +pr-summary`: PR number fetch or `--from` JSON file. - - `workflow +repo-report`: aggregate health, issues, and PR list metadata or `--from` JSON file. -- Verified all three commands run locally without GitLink API write access. -- Added read-only workflow API fetch helpers and mock tests. -- Added command-level fetch-path smoke tests for `runTriage`, `runHealth`, and `runPRSummary`. -- Added README workflow command usage section. -- Added `docs/workflow-agent-test-report.md`. -- Added `docs/competition-solution.md`. -- Added `docs/pr-draft.md`. -- Added workflow testdata fixtures under `shortcuts/workflow/testdata/`. -- Expanded fetch-layer boundary coverage for empty responses, label and author normalization, error-in-body handling, alternative activity timestamps, release shapes, CI unavailability, and PR summary normalization. -- Added `workflow +repo-report` with local JSON input, read-only partial fetch aggregation, - report score, overall risk level, markdown/table/json rendering, and tests. -- Added competition submission materials: - - `docs/competition-submit.zh-CN.md` - - `docs/demo-script.md` - - `docs/final-submission-checklist.md` - - `docs/defense-qa.md` -- Updated PR draft, test report, competition solution, and continuation notes for final submission readiness. - -## Current Go Toolchain Status - -- `where go`: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\go.exe` -- `where gofmt`: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\gofmt.exe` -- `go version`: `go version go1.26.1 windows/amd64` -- Temporary PATH change: applied only in shell commands. -- GOPROXY used for tests: `https://goproxy.cn,direct` -- Go toolchain status: available. -- gofmt status: available. - -## Current Test Status - -- `gofmt` on `shortcuts/workflow/*.go`: passed. -- `go test ./shortcuts/workflow`: passed. -- `go test ./...`: passed. -- Smoke command passed: - ```bash - go run . --format json workflow +triage \ - --title "Token leaked in logs" \ - --body "secret token leaked" \ - --number 1 \ - --labels security - ``` -- Smoke command passed: - ```bash - go run . --format table workflow +health \ - --repository owner/repo \ - --open-issues 2 \ - --open-prs 1 \ - --recent-activity-known \ - --recent-activity-days 3 \ - --release-known \ - --has-recent-release \ - --has-readme \ - --has-license \ - --has-contributing \ - --agent-readiness-known \ - --agent-readiness-score 9 - ``` -- Smoke command passed: - ```bash - go run . --format json workflow +pr-summary \ - --from shortcuts/workflow/testdata/pr_summary.json - ``` -- Smoke command passed: - ```bash - go run . --format markdown workflow +repo-report \ - --from shortcuts/workflow/testdata/repo_report.json - ``` -- Remote read-only smoke command passed: - ```bash - go run . --format table workflow +triage \ - --owner Gitlink \ - --repo gitlink-cli \ - --state open \ - --limit 5 - ``` -- Remote read-only smoke command passed: - ```bash - go run . --format markdown --lang zh-CN workflow +health \ - --owner Gitlink \ - --repo gitlink-cli \ - --stale-days 30 - ``` -- Documentation examples now cover local-parameter, local-JSON-file, and read-only fetch usage. - -## Recent Changed Files - -- `README.md` -- `docs/competition-solution.md` -- `docs/pr-draft.md` -- `docs/competition-submit.zh-CN.md` -- `docs/demo-script.md` -- `docs/final-submission-checklist.md` -- `docs/defense-qa.md` -- `docs/workflow-agent-design.md` -- `docs/workflow-agent-test-report.md` -- `shortcuts/workflow/api_types.go` -- `shortcuts/workflow/messages.go` -- `shortcuts/workflow/render.go` -- `shortcuts/workflow/workflow.go` -- `shortcuts/workflow/workflow_test.go` -- `shortcuts/workflow/pr_summary.go` -- `shortcuts/workflow/pr_fetch.go` -- `shortcuts/workflow/pr_summary_test.go` -- `shortcuts/workflow/pr_fetch_test.go` -- `shortcuts/workflow/render_test.go` -- `shortcuts/workflow/testdata/pr_summary.json` -- `shortcuts/workflow/repo_report.go` -- `shortcuts/workflow/repo_report_fetch.go` -- `shortcuts/workflow/repo_report_test.go` -- `shortcuts/workflow/repo_report_fetch_test.go` -- `shortcuts/workflow/testdata/repo_report.json` -- `skills/gitlink-workflow/SKILL.md` -- `pr-test-file.txt` deleted - -## Uncompleted Content - -- `workflow +release-notes` is not implemented. -- `workflow +stale` is not implemented. -- Remote write operations remain intentionally deferred. -- GitLink official PR is not created yet. -- CI screenshot/result is not recorded yet. -- Demo video is not recorded yet. -- Final competition submission links are not filled in yet. - -## Known Issues - -- `codex status` is unavailable from the non-interactive shell: `stdin is not a terminal`. -- Quota reset time unavailable. -- Workflow commands support both local input and read-only GitLink fetch mode. -- Existing global help says default format is table, but shortcut runtime still defaults to json when `--format` is omitted. -- Existing output formatter supports `json`, `yaml`, and `table`; workflow-local renderers currently support `json`, `table`, and `markdown`. -- Workflow Skill examples use some older flag names such as `--id`, while current issue commands use `--number` for issues and PR commands use `--id`. -- API response shapes vary across endpoints and should be normalized behind workflow-specific fetch/parsing helpers. -- `README.zh-CN.md` currently shows encoding/garbling risk in the shell and was left untouched in this slice. - -## Key Design Decisions - -- No new dependency was added. -- `workflow` is a new shortcut group under `shortcuts/workflow`. -- JSON schemas use explicit workflow DTOs. -- Workflow renderers are local to the workflow package; global formatter was not changed. -- All remote-write behavior remains out of scope. -- `+triage` supports local single-issue flags, JSON file input, and read-only GitLink fetch mode. -- `+health` supports local metric flags, JSON file input, and read-only GitLink fetch mode. -- `+pr-summary` supports local JSON input and read-only PR metadata fetch mode. -- `+repo-report` supports local JSON input and read-only partial aggregation of health, issue, and PR list metadata. -- Treat unavailable future API metrics as `unknown` and include them in `scoring_notes`. -- Workflow-local renderers keep json/table/markdown output isolated from the global formatter. - -## Next Minimal Executable Task - -Create GitLink official PR, record CI result, and record demo video. - -## How To Continue After Interruption - -1. Open `WORK_CONTINUATION.md`. -2. Run `git status --short --branch`. -3. Use temporary PATH: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin`. -4. Set temporary GOPROXY if dependency download fails: `https://goproxy.cn,direct`. -5. Run `go test ./shortcuts/workflow`. -6. Run `go test ./...`. -7. Create the GitLink official PR before adding more features. -8. Keep all new workflow commands read-only by default. - -## Recommended Next Codex Instruction - -Create GitLink official PR from branch `codex/workflow-agent`, then record PR URL, CI result, and demo video status in `docs/final-submission-checklist.md`. diff --git a/docs/competition-solution.md b/docs/competition-solution.md deleted file mode 100644 index 46a5954..0000000 --- a/docs/competition-solution.md +++ /dev/null @@ -1,186 +0,0 @@ -# GitLink CLI Agent Workflow Enhancement Suite - -## 1. Background - -GitLink CLI serves both human maintainers and AI Agents. -The competition focuses on intelligent open-source contribution workflows, -where structured analysis, stable output, and safe automation matter more than raw command count. - -## 2. Problem - -Open-source maintenance often suffers from: - -- Issue backlog and delayed triage -- High PR review cost -- Repetitive release note preparation -- Lack of structured repository health evaluation -- AI Agents needing stable, machine-readable output - -## 3. Solution - -This project extends GitLink CLI with the **GitLink CLI Agent Workflow Enhancement Suite**. - -Implemented now: - -- `workflow +triage` -- `workflow +health` -- `workflow +pr-summary` -- `workflow +repo-report` -- read-only GitLink fetch layer for workflow triage and health -- read-only PR metadata, changed files, and commits fetch layer for PR summary -- partial read-only repository report aggregation for health, issues, and PR list metadata -- expanded fetch boundary tests for empty responses, label and author normalization, - error-in-body handling, alternative activity timestamps, release shapes, and CI unavailability -- local-first analysis with no LLM dependency -- stable Agent-facing JSON / table / markdown output - -Planned next: - -- `workflow +release-notes` -- `workflow +stale` - -## 4. Technical Route - -- Go + Cobra + existing shortcut architecture -- rule-based analysis -- stable DTOs -- `json` / `table` / `markdown` renderers -- `en` / `zh-CN` message mapping -- no LLM dependency -- local-first, dry-run-safe workflow design - -## 5. Implemented Features - -| Command | Status | Main Value | -|---|---|---| -| `workflow +triage` | Done | Issue classification, priority, missing information, and actions | -| `workflow +health` | Done | Repository health score, risk level, and recommendations | -| `workflow +pr-summary` | Done | PR risk, review focus, test suggestions, and merge checklist | -| `workflow +repo-report` | Done | Aggregated repository workflow report for maintainers and Agents | -| `workflow +release-notes` | Planned | Release note generation from PR titles and commits | -| `workflow +stale` | Planned | Stale issue and PR analysis | - -### workflow +triage - -- issue type detection -- priority scoring -- confidence scoring -- missing information detection -- risk flags -- recommended action -- suggested comment -- reasoning and matched rules - -### workflow +health - -- health score -- risk level -- metrics -- scoring notes -- recommendations -- unknown metric tolerance - -### workflow +pr-summary - -- change type detection -- risk level analysis -- review focus generation -- test suggestion generation -- merge checklist generation -- read-only fetch of PR metadata, changed files, and commits - -### workflow +repo-report - -- one-command repository workflow report -- health, issue triage, and PR summary aggregation -- report score and overall risk level -- partial report behavior when optional remote sections fail -- markdown output for competition and maintainer reports -- JSON output for Agent consumption - -## 6. Innovation Points - -- Agent-native structured output -- rule-based intelligence without external LLM dependency -- explainable workflow decisions -- safety-first local analysis -- bilingual command output -- extensible workflow command design -- competition-friendly incremental PR path - -## 7. Testing and Verification - -- Unit tests cover triage, health scoring, messages, rendering, and command helpers. -- Fetch-layer tests cover issue normalization, repository health probing, - and PR metadata/file/commit normalization with `httptest`. -- Boundary tests cover empty responses, label and author normalization, - error-in-body handling, alternative activity timestamps, release response shapes, - and CI unavailability. -- PR summary tests cover docs-only, workflow code, internal client, - security-sensitive, mixed-file, zh-CN, render, command, and fetch-failure cases. -- Repo report tests cover aggregation, scoring, JSON/table/markdown rendering, - command wiring, local JSON input, partial fetch behavior, and include flags. -- Local command examples were executed successfully. -- Full repository testing passed in the current environment. -- Automated tests use `httptest` and do not depend on real remote API availability. - -## 8. Demonstration Plan - -### Official repository - -Use `Gitlink/gitlink-cli` as the reference repository: - -1. `workflow +triage` with English table output -2. `workflow +triage` with security JSON output -3. `workflow +triage` with Chinese markdown output -4. `workflow +health` with table output -5. `workflow +health` with risky JSON output -6. `workflow +pr-summary` with markdown output -7. `workflow +repo-report` with markdown output for the full competition story -8. Explain how agents consume stable JSON - -### Self-built test repository - -Use a small demo repository to show: - -- bug triage -- security triage -- docs triage -- healthy repo score -- risky repo score -- full repo report from `shortcuts/workflow/testdata/repo_report.json` - -## 9. Roadmap - -- Phase 1: local workflow prototype, completed -- Phase 2: API fetch and normalization, completed -- Phase 3: `pr-summary`, completed -- Phase 4: `repo-report`, completed -- Phase 5: `release-notes`, `stale` - -## 10. PR Plan - -- PR 1: workflow rule engine and local commands -- PR 2: documentation and tests -- PR 3: API fetch layer -- PR 4: `pr-summary` -- PR 5: `repo-report` -- PR 6: `release-notes` / `stale` - -## 11. Evaluation Mapping - -| Criterion | Evidence | -|---|---| -| 功能完整性 20% | Four implemented commands cover Issue triage, health scoring, PR summary, and repo report | -| 创新性 20% | Agent-native JSON, explainable rules, local-first safety model, repository workflow report | -| 实用价值 20% | Reduces maintainer triage/review overhead and creates copy-ready markdown reports | -| 文档与演示 20% | README, design doc, test report, competition write-up, demo script, defense Q&A | -| 成果落地 20% | Prepared for GitLink official PR, CI verification, and maintainer review iteration | - -## 12. Landing Plan - -- Push the implementation branch to the public repository. -- Create a GitLink official PR against `Gitlink/gitlink-cli`. -- Record CI result and PR URL in `docs/final-submission-checklist.md`. -- Respond to maintainer review over the expected 1-2 week review cycle. -- Keep `release-notes` and `stale` as follow-up work instead of expanding this PR further. diff --git a/docs/competition-submit.zh-CN.md b/docs/competition-submit.zh-CN.md deleted file mode 100644 index 513823f..0000000 --- a/docs/competition-submit.zh-CN.md +++ /dev/null @@ -1,161 +0,0 @@ -# GitLink CLI Agent Workflow 增强套件参赛说明 - -## 1. 作品概述 - -作品名称:GitLink CLI Agent Workflow 增强套件。 - -本作品基于 `Gitlink/gitlink-cli` 开源仓库,面向开源项目维护者和 AI Agent -增加规则型、可解释、只读安全的协作分析工作流能力。目标不是替代维护者, -而是把 Issue 分诊、仓库健康度评估、PR 审阅摘要和仓库工作流报告变成 -CLI 原生命令,降低维护前的信息整理成本。 - -当前已实现四个命令: - -- `workflow +triage` -- `workflow +health` -- `workflow +pr-summary` -- `workflow +repo-report` - -## 2. 对应赛题与场景 - -本作品主要对应子赛题一:`gitlink-cli` 功能增强 / 开源项目贡献。 - -对应工作流场景: - -- Issue 自动分拣 -- PR Review 辅助 -- 仓库健康度评估 -- 仓库工作流报告生成 - -## 3. 功能完整性说明 - -### workflow +triage - -`workflow +triage` 对 Issue 做规则型智能分诊,支持本地参数、`--from` -JSON 文件和 GitLink 远端只读 fetch。输出包括 Issue 类型识别、优先级判断、 -缺失信息检测、风险标记、建议操作、建议评论、规则命中原因,并支持 -`json`、`table`、`markdown` 三种格式。 - -### workflow +health - -`workflow +health` 生成仓库健康度评分,覆盖 Issue / PR backlog、最近活跃度、 -Release、CI、文档、License、CONTRIBUTING 和 Agent readiness 等指标。 -当远端 API 或某些指标不可用时,命令不会伪造结果,而是标记为 unknown -并在 scoring notes 中说明,保证 Agent 和维护者可以判断可信度。 - -### workflow +pr-summary - -`workflow +pr-summary` 对单个 PR 生成审阅摘要,分析 PR 元数据、changed files -和 commits,输出 change type、risk level、review focus、test suggestions、 -merge checklist 和 reasoning。该命令只读,不评论、不 approve、不 reject、 -不 merge。 - -### workflow +repo-report - -`workflow +repo-report` 聚合 health、triage 和 pr-summary 的能力,生成一份 -仓库工作流报告。报告包含整体分数、风险等级、Issue 分布、PR 风险分布、 -维护建议和判断依据,适合维护者、比赛材料和 AI Agent 使用。 - -## 4. 创新性说明 - -- Agent-native structured output:`json` 给 Agent,`markdown` 给维护者,`table` 给终端用户。 -- Rule-based explainable intelligence:不依赖 LLM,所有判断都有规则依据。 -- Safety-first read-only workflow:远端模式只读,不污染仓库状态。 -- GitLink CLI 原生集成:基于 shortcuts 架构,不是外部脚本。 -- Repository workflow report:一条命令聚合多个仓库治理维度。 -- Bilingual support:支持 `en` / `zh-CN`。 - -## 5. 实用价值说明 - -| 问题 | 对应功能 | 价值 | -|---|---|---| -| 开源仓库 Issue 积压 | `workflow +triage` | 快速识别类型、优先级和缺失信息 | -| PR 审阅前理解成本高 | `workflow +pr-summary` | 生成审阅重点、测试建议和合并清单 | -| 仓库维护状态不清晰 | `workflow +health` | 给出健康度评分、风险等级和修复建议 | -| 维护者需要汇总报告 | `workflow +repo-report` | 一条命令生成可复制的仓库工作流报告 | -| AI Agent 需要稳定输出 | `json` DTO | 字段稳定,适合脚本和 Agent 消费 | - -## 6. 技术路线 - -- Go + Cobra -- `gitlink-cli` shortcuts 架构 -- GitLink API 只读 fetch -- API response normalization -- rule engine -- health scoring -- workflow-local renderer -- `json` / `table` / `markdown` -- `httptest` mock -- testdata reproducible examples - -## 7. 安全边界 - -- 不调用 LLM API -- 不执行远端写操作 -- 不自动评论 -- 不自动打标签 -- 不关闭 Issue -- 不 approve / reject / merge PR -- 不修改 `internal/output` -- 远端模式只读 fetch -- 所有分析在本地规则层完成 - -## 8. 测试与验证 - -测试命令: - -```bash -gofmt -w shortcuts/workflow/*.go shortcuts/register.go -go test ./shortcuts/workflow -go test ./... -``` - -测试覆盖: - -- triage rules -- health scoring -- pr-summary rules -- repo-report aggregation -- fetch normalization -- partial failure -- `json` / `table` / `markdown` render -- `--from` testdata -- command smoke tests - -## 9. 可复现演示命令 - -稳定本地演示命令: - -```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 -``` - -远端只读演示命令: - -```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 -``` - -如果真实远端受认证、网络或 API 形态影响,可使用 `--from` testdata 稳定复现。 - -## 10. 成果落地计划 - -- 当前成果已在个人仓库完成。 -- 下一步将提交 GitLink 官方主仓库 PR。 -- 以 PR 已提交且通过 CI 作为成果落地基础。 -- 争取在 Review 后根据维护者意见迭代。 -- `release-notes` / `stale` 作为后续规划。 - -## 11. 后续规划 - -- `workflow +release-notes` -- `workflow +stale` -- 更完整的真实 GitLink API field normalization -- 官方 Skill 收录申请 -- 更多真实项目验证 diff --git a/docs/defense-qa.md b/docs/defense-qa.md deleted file mode 100644 index c776098..0000000 --- a/docs/defense-qa.md +++ /dev/null @@ -1,61 +0,0 @@ -# Defense Q&A - -## 1. 作品解决了什么问题? - -本作品解决开源项目维护中信息整理成本高的问题。维护者面对大量 Issue、PR 和仓库状态信号时,往往需要手动判断优先级、风险和下一步动作。本项目把这些判断沉淀为 GitLink CLI 原生命令,提供可复现、可解释、只读安全的工作流分析能力。 - -## 2. 为什么不使用 LLM? - -本阶段不使用 LLM 是为了降低依赖风险和运行成本,并保证输出稳定可测。比赛目标是贡献可落地的 CLI 功能,规则型分析更适合进入基础工具链。未来如果需要接入 LLM,也可以在稳定 DTO 和安全边界之上扩展,而不是直接绑定外部模型。 - -## 3. 规则型分析如何体现智能化? - -智能化不等于必须调用大模型。本项目通过关键词、权重评分、缺失信息检测、风险标记和健康度评分,把维护经验转化为自动化规则。每个结论都有 reasoning 和 matched rules,维护者可以理解判断来源,Agent 也可以消费结构化结果继续处理。 - -## 4. 如何保证不污染远端仓库? - -所有 workflow 命令都遵守只读边界。远端模式只 fetch 数据,不评论、不打标签、不关闭 Issue,也不 approve、reject 或 merge PR。命令默认生成分析结果和建议,不执行写操作,因此不会改变真实仓库状态,适合在评审和演示中安全运行。 - -## 5. 与普通 CLI 命令相比有什么区别? - -普通 CLI 命令主要完成单个 API 操作,例如查看 Issue 或 PR。本作品新增的是工作流级分析命令,会聚合输入、应用规则、生成风险等级、建议和报告。它不是简单包装 API,而是为维护者和 AI Agent 提供更高层的协作决策辅助。 - -## 6. 与人工维护 Issue / PR 相比有什么价值? - -人工维护仍然是最终决策者,但本项目能先完成重复的信息整理工作。例如自动识别 bug、docs、security,指出缺失复现信息,给出 PR 审阅重点和测试建议。这样维护者可以把时间放在判断和修复上,而不是反复阅读和归类。 - -## 7. json/table/markdown 三种输出分别面向谁? - -`json` 面向 AI Agent 和脚本,字段稳定,便于自动处理;`table` 面向终端用户,适合快速查看摘要;`markdown` 面向维护者、Issue/PR 评论草稿和比赛文档,便于复制传播。三种输出复用同一分析结果,减少重复实现。 - -## 8. repo-report 的评分如何计算? - -`repo-report` 以 health score 为基础,结合高风险 Issue、缺失信息数量、高风险或 critical PR 等信号进行扣分,并限制在 0 到 100。风险等级按分数区间划分;如果出现 security P0 Issue 或 critical PR,会提升整体风险等级,保证安全问题优先暴露。 - -## 9. 如果 GitLink API 字段变化怎么办? - -fetch 层使用 response normalization 处理多种字段形态,例如不同的 author、label、release、PR 字段别名。如果真实 API 继续变化,后续只需要在 workflow fetch 层补充映射和 httptest,不需要修改规则引擎或输出协议,维护成本较低。 - -## 10. 为什么成果可以落地到 gitlink-cli 主仓库? - -实现遵循现有 shortcuts 架构,没有修改 `cmd/` 和 `internal/output`,也没有新增第三方依赖。功能边界清楚、默认只读、测试覆盖集中,适合以 PR 形式提交到主仓库。维护者可以分阶段 review,不需要一次接受复杂平台级改造。 - -## 11. 当前局限是什么? - -当前局限主要是远端 API 形态仍需更多真实项目验证,`repo-report` 的远端 PR 部分使用 PR 列表元数据,深度 files/commits 分析仍通过单独的 `workflow +pr-summary` 完成。此外 `release-notes` 和 `stale` 仍是后续规划,尚未实现。 - -## 12. 后续规划是什么? - -后续计划包括 `workflow +release-notes`、`workflow +stale`、更完整的真实 GitLink API 字段归一化、官方 Skill 收录申请和更多真实项目验证。所有后续功能仍会坚持只读优先、可测试、可解释,不会默认执行破坏性远端操作。 - -## 13. 如何验证功能正常? - -可以运行 `gofmt -w shortcuts/workflow/*.go shortcuts/register.go`、`go test ./shortcuts/workflow` 和 `go test ./...`。演示时优先使用 `shortcuts/workflow/testdata/` 下的 JSON 文件,避免网络和认证影响。远端命令也只读,可作为 smoke 验证。 - -## 14. 如果 PR 没被合并,成果落地如何体现? - -子赛题一鼓励提交官方 PR。即使短期未合并,只要 PR 已提交、CI 通过并进入维护者 Review,就已经具备成果落地基础。项目还提供个人仓库、完整测试、文档、演示脚本和后续迭代计划,便于根据维护者反馈继续推进。 - -## 15. 这个项目如何服务 AI Agent? - -AI Agent 需要稳定、结构化、可解释的工具输出。本项目为 Issue、PR、健康度和仓库报告提供稳定 JSON DTO,并保留 reasoning、risk、recommendations 等字段。Agent 可以读取这些结果,生成后续任务、报告或维护计划,而不依赖不稳定的自然语言解析。 diff --git a/docs/demo-script.md b/docs/demo-script.md deleted file mode 100644 index 7fbc429..0000000 --- a/docs/demo-script.md +++ /dev/null @@ -1,103 +0,0 @@ -# Demo Script - -## 0:00-0:20 项目背景 - -屏幕内容: -- 打开仓库 README 的 Workflow Agent Commands 小节。 -- 展示 `workflow +triage`、`workflow +health`、`workflow +pr-summary`、`workflow +repo-report`。 - -旁白: -本项目为 GitLink CLI 增加面向维护者和 AI Agent 的规则型工作流增强套件。 -它不依赖 LLM,不做远端写操作,通过 CLI 原生命令提供 Issue 分诊、健康度评分、 -PR 审阅摘要和仓库工作流报告。 - -截图点位: -- README workflow 命令列表。 - -## 0:20-0:50 workflow +triage - -演示命令: - -```bash -gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format table -``` - -旁白: -这个命令会对 Issue 做规则型分诊,自动识别 Issue 类型,判断优先级, -发现缺失信息,并给出建议操作。表格输出适合维护者在终端快速查看。 - -截图点位: -- table 输出中的 type、priority、missing、action。 - -## 0:50-1:20 workflow +health - -演示命令: - -```bash -gitlink-cli workflow +health --from shortcuts/workflow/testdata/health_good.json --format markdown -``` - -旁白: -健康度命令根据 Issue、PR、Release、CI、文档、License 和贡献指南等指标, -生成仓库健康度评分、风险等级和维护建议。Markdown 输出可直接复制到报告中。 - -截图点位: -- health score、risk level、recommendations。 - -## 1:20-1:50 workflow +pr-summary - -演示命令: - -```bash -gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format markdown -``` - -旁白: -PR 摘要命令会识别 PR 类型和风险等级,生成 review focus、test suggestions -和 merge checklist。它只读分析,不会评论、approve、reject 或 merge PR。 - -截图点位: -- Review Focus、Test Suggestions、Merge Checklist。 - -## 1:50-2:20 workflow +repo-report - -演示命令: - -```bash -gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown -``` - -旁白: -仓库报告命令聚合 Issue、PR 和仓库健康度,生成一份完整的仓库工作流报告。 -它适合维护者快速了解项目状态,也适合比赛展示项目的综合能力。 - -截图点位: -- Report score、Risk level、Issue Triage Summary、PR Review Summary。 - -## 2:20-2:40 JSON 输出给 Agent - -演示命令: - -```bash -gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json -``` - -旁白: -同一个报告可以输出稳定 JSON 字段,供 AI Agent 和脚本消费。 -这让 Agent 可以基于结构化结果继续做排序、摘要或生成后续任务。 - -截图点位: -- JSON 中的 `report_score`、`risk_level`、`issue_summary`、`pr_summary`。 - -## 2:40-3:00 安全边界与总结 - -屏幕内容: -- 展示参赛说明中的安全边界小节。 - -旁白: -整个 workflow-agent 套件不依赖 LLM,远端模式只读,不评论、不打标签、不关闭 Issue, -也不 approve、reject 或 merge PR。后续规划包括 `workflow +release-notes` -和 `workflow +stale`,但当前提交保持聚焦、可测试、可落地。 - -截图点位: -- 安全边界列表。 diff --git a/docs/final-submission-checklist.md b/docs/final-submission-checklist.md deleted file mode 100644 index 00cdb96..0000000 --- a/docs/final-submission-checklist.md +++ /dev/null @@ -1,52 +0,0 @@ -# Final Submission Checklist - -## Repository Links - -- GitHub repository: 待填写 -- GitHub branch: 待填写 -- GitLink fork: 待填写 -- GitLink official PR: 待填写 -- CI result: 待填写 -- Competition submission: 待填写 - -## Core Files - -- README.md -- README.zh-CN.md -- docs/competition-submit.zh-CN.md -- docs/workflow-agent-design.md -- docs/workflow-agent-test-report.md -- docs/competition-solution.md -- docs/demo-script.md -- docs/pr-draft.md -- skills/gitlink-workflow/SKILL.md - -## Commands Implemented - -- workflow +triage -- workflow +health -- workflow +pr-summary -- workflow +repo-report - -## Test Commands - -- gofmt -w shortcuts/workflow/*.go shortcuts/register.go -- go test ./shortcuts/workflow -- go test ./... - -## Demo Assets - -- demo video: 待填写 -- screenshots: 待填写 -- command output logs: 待填写 - -## Before Submission - -- [ ] GitLink official PR created -- [ ] CI passed -- [ ] README examples verified -- [ ] demo video recorded -- [ ] competition platform form filled -- [ ] repository links submitted -- [ ] PR link submitted -- [ ] docs links submitted diff --git a/docs/pr-draft.md b/docs/pr-draft.md index 24fe18e..48bc8c6 100644 --- a/docs/pr-draft.md +++ b/docs/pr-draft.md @@ -87,11 +87,6 @@ Coverage includes: - `README.md` - `docs/workflow-agent-design.md` - `docs/workflow-agent-test-report.md` -- `docs/competition-solution.md` -- `docs/competition-submit.zh-CN.md` -- `docs/demo-script.md` -- `docs/final-submission-checklist.md` -- `docs/defense-qa.md` - `skills/gitlink-workflow/SKILL.md` ## Known Limitations From 6b355e19e26ed5c91e64df209e9d24769dc73b5a Mon Sep 17 00:00:00 2001 From: wbtiger <28288271@qq.com> Date: Tue, 26 May 2026 16:27:29 +0800 Subject: [PATCH 10/10] fix(workflow): improve input mode detection and error hint - hasLocalTriageInput now also detects triage-specific flags (--body, --number, --author, --url, --labels) to enter local input mode. - hasLocalHealthInput now checks all health-related flags to prevent invalid values like --open-issues=abc from silently falling through to remote fetch instead of being validated. - Remote fetch failure in +triage now includes a hint suggesting --title or --from for local analysis. Co-Authored-By: Claude Opus 4.7 --- shortcuts/workflow/workflow.go | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/shortcuts/workflow/workflow.go b/shortcuts/workflow/workflow.go index 473e802..4dc69e5 100644 --- a/shortcuts/workflow/workflow.go +++ b/shortcuts/workflow/workflow.go @@ -122,7 +122,7 @@ func runTriage(ctx *common.RuntimeContext) error { Since: ctx.Arg("since"), }) if err != nil { - return err + 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 { @@ -199,11 +199,31 @@ func collectIssuesFromArgs(ctx *common.RuntimeContext) ([]IssueInput, error) { } func hasLocalTriageInput(ctx *common.RuntimeContext) bool { - return strings.TrimSpace(ctx.Arg("from")) != "" || strings.TrimSpace(ctx.Arg("title")) != "" + 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 { - return strings.TrimSpace(ctx.Arg("from")) != "" || strings.TrimSpace(ctx.Arg("repository")) != "" + 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) {