merge: resolve conflicts with upstream/master

This commit is contained in:
wbtiger 2026-05-27 01:06:27 +08:00
commit 419388f11e
75 changed files with 9723 additions and 32 deletions

2
.gitignore vendored Normal file
View File

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

105
README.md
View File

@ -313,6 +313,105 @@ gitlink-cli search +repos -k "machine learning"
gitlink-cli search +users -k "zhangsan"
```
### Workflow Agent Commands
`workflow` provides rule-based repository analysis for maintainers and AI Agents. It currently supports:
- `workflow +triage`
- `workflow +health`
- `workflow +pr-summary`
- `workflow +repo-report`
`workflow +pr-summary` defaults to `table` when `--format` is omitted.
`workflow +repo-report` defaults to `markdown` when `--format` is omitted.
Examples:
```bash
# Triage with local parameters
gitlink-cli workflow +triage --title "Install failed on Windows" --body "go install failed with error" --format table
# Triage with JSON output
gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --format json
# Triage with Chinese markdown output
gitlink-cli workflow +triage \
--title "安装失败,无法登录" \
--body "运行命令时报错" \
--lang zh-CN \
--format markdown
# Triage from a local JSON file
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format json
# Triage by read-only GitLink fetch
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table
# Health for a healthy repository
gitlink-cli workflow +health \
--repository Gitlink/gitlink-cli \
--open-issues 3 \
--open-prs 1 \
--has-readme \
--has-license \
--has-contributing \
--agent-readiness-known \
--agent-readiness-score 9 \
--format table
# Health for a risky repository
gitlink-cli workflow +health \
--repository demo/repo \
--open-issues 60 \
--stale-issues 25 \
--open-prs 12 \
--stale-prs 6 \
--recent-activity-known \
--recent-activity-days 120 \
--release-known=false \
--format json
# Health with Chinese markdown output
gitlink-cli workflow +health \
--repository Gitlink/gitlink-cli \
--open-issues 3 \
--open-prs 1 \
--has-readme \
--has-license \
--has-contributing \
--lang zh-CN \
--format markdown
# Health by read-only GitLink fetch
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --format table
# PR review summary by read-only GitLink fetch
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
# PR review summary from a local JSON file
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json
# Repository workflow report by read-only GitLink fetch
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
# Repository workflow report from a local JSON file
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
```
Output formats:
- `json` for scripts and AI Agents
- `table` for terminal review
- `markdown` for Issue comments, PR comments, release notes, and competition write-ups
Safety:
- Current workflow commands use local analysis by default and can also read GitLink data in read-only fetch mode.
- They do not modify remote GitLink data.
- They do not depend on LLM APIs.
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
- `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes.
### Raw API
For endpoints not covered by shortcuts, use the Raw API directly:
@ -334,7 +433,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`.
@ -485,7 +584,9 @@ Reinstall first:
npm install -g @gitlink-ai/cli
```
If the error persists, check whether the release page contains the asset for your platform, for example `gitlink-cli_<version>_windows_amd64.zip` on Windows x64. You can also download the binary manually from the release page or build from source with `go install .`.
If the error persists, check whether the release page contains the asset for your platform,
for example `gitlink-cli_<version>_windows_amd64.zip` on Windows x64.
You can also download the binary manually from the release page or build from source with `go install .`.
### Q: Where are credentials stored on Windows?

View File

@ -3,7 +3,9 @@ package api
import (
"encoding/json"
"fmt"
"io"
"net/url"
"os"
"strings"
"github.com/spf13/cobra"
@ -20,12 +22,15 @@ func NewAPICmd() *cobra.Command {
Long: `Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.`,
Example: ` gitlink-cli api GET /users/me
gitlink-cli api GET /projects --query 'page=1&limit=10'
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'`,
gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'
gitlink-cli api POST /:owner/:repo/issues --body-file issue.json`,
Args: cobra.ExactArgs(2),
RunE: runAPI,
}
apiCmd.Flags().String("body", "", "Request body (JSON string)")
apiCmd.Flags().String("body-file", "", "Read request body JSON from a file")
apiCmd.Flags().Bool("body-stdin", false, "Read request body JSON from stdin")
apiCmd.Flags().String("query", "", "Query parameters (key=val&key2=val2)")
apiCmd.Flags().StringSlice("header", nil, "Additional headers (key:value)")
@ -46,12 +51,9 @@ func runAPI(c *cobra.Command, args []string) error {
}
cli.Debug = cmdutil.Debug
var body interface{}
bodyStr, _ := c.Flags().GetString("body")
if bodyStr != "" {
if err := json.Unmarshal([]byte(bodyStr), &body); err != nil {
return fmt.Errorf("invalid JSON body: %w", err)
}
body, err := readJSONBody(c)
if err != nil {
return err
}
var query url.Values
@ -76,6 +78,49 @@ func runAPI(c *cobra.Command, args []string) error {
return output.Print(env, resolveFormat())
}
func readJSONBody(c *cobra.Command) (interface{}, error) {
bodyStr, _ := c.Flags().GetString("body")
bodyFile, _ := c.Flags().GetString("body-file")
bodyStdin, _ := c.Flags().GetBool("body-stdin")
sources := 0
if bodyStr != "" {
sources++
}
if bodyFile != "" {
sources++
}
if bodyStdin {
sources++
}
if sources == 0 {
return nil, nil
}
if sources > 1 {
return nil, fmt.Errorf("use only one of --body, --body-file, or --body-stdin")
}
var data []byte
var err error
switch {
case bodyStr != "":
data = []byte(bodyStr)
case bodyFile != "":
data, err = os.ReadFile(bodyFile)
case bodyStdin:
data, err = io.ReadAll(c.InOrStdin())
}
if err != nil {
return nil, fmt.Errorf("read JSON body: %w", err)
}
var body interface{}
if err := json.Unmarshal(data, &body); err != nil {
return nil, fmt.Errorf("invalid JSON body: %w", err)
}
return body, nil
}
func resolveFormat() string {
f := cmdutil.Format
if f == "" {

90
cmd/api/api_test.go Normal file
View File

@ -0,0 +1,90 @@
package api
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestReadJSONBodyFromInlineFlag(t *testing.T) {
cmd := NewAPICmd()
cmd.Flags().Set("body", `{"title":"hello","count":2}`)
body, err := readJSONBody(cmd)
if err != nil {
t.Fatalf("readJSONBody returned error: %v", err)
}
values := body.(map[string]interface{})
if values["title"] != "hello" {
t.Fatalf("title = %v, want hello", values["title"])
}
if values["count"] != float64(2) {
t.Fatalf("count = %v, want 2", values["count"])
}
}
func TestReadJSONBodyFromFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "body.json")
if err := os.WriteFile(path, []byte(`{"description":"来自文件"}`), 0o600); err != nil {
t.Fatalf("write body file: %v", err)
}
cmd := NewAPICmd()
cmd.Flags().Set("body-file", path)
body, err := readJSONBody(cmd)
if err != nil {
t.Fatalf("readJSONBody returned error: %v", err)
}
values := body.(map[string]interface{})
if values["description"] != "来自文件" {
t.Fatalf("description = %v, want 来自文件", values["description"])
}
}
func TestReadJSONBodyFromStdin(t *testing.T) {
cmd := NewAPICmd()
cmd.Flags().Set("body-stdin", "true")
cmd.SetIn(strings.NewReader(`{"notes":"from stdin"}`))
body, err := readJSONBody(cmd)
if err != nil {
t.Fatalf("readJSONBody returned error: %v", err)
}
values := body.(map[string]interface{})
if values["notes"] != "from stdin" {
t.Fatalf("notes = %v, want from stdin", values["notes"])
}
}
func TestReadJSONBodyRejectsMultipleSources(t *testing.T) {
cmd := NewAPICmd()
cmd.Flags().Set("body", `{"title":"hello"}`)
cmd.Flags().Set("body-stdin", "true")
if _, err := readJSONBody(cmd); err == nil {
t.Fatal("expected multiple body sources to return an error")
}
}
func TestReadJSONBodyRejectsInvalidJSON(t *testing.T) {
cmd := NewAPICmd()
cmd.Flags().Set("body", `{"title":`)
if _, err := readJSONBody(cmd); err == nil {
t.Fatal("expected invalid JSON to return an error")
}
}
func TestReadJSONBodyWithoutSource(t *testing.T) {
cmd := NewAPICmd()
body, err := readJSONBody(cmd)
if err != nil {
t.Fatalf("readJSONBody returned error: %v", err)
}
if body != nil {
t.Fatalf("body = %v, want nil", body)
}
}

View File

@ -0,0 +1,19 @@
# Milestone shortcut
新增 `milestone` Shortcut 组,补齐 GitLink 里程碑 OpenAPI 的常用操作封装:
- `milestone +list`
- `milestone +create`
- `milestone +view`
- `milestone +update`
- `milestone +delete`
- `milestone +close`
- `milestone +reopen`
实现要点:
- 支持列表筛选、分页、排序,以及详情页关联 Issue 过滤参数。
- 写入时将 CLI 参数 `--due-date` 映射为 API 字段 `effective_date`
- `+update` 在没有任何变更字段时直接报错,避免发送空更新。
- `+close``+reopen` 使用 GitLink 的 milestone 状态更新接口。
- 补充单元测试覆盖各命令的 HTTP 方法、路径、查询参数和 payload。

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

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

View File

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

View File

@ -0,0 +1,174 @@
# Workflow Agent Test Report
## Scope
This phase covers:
- Issue triage rules
- health scoring rules
- PR summary rules
- repository report aggregation rules
- local command execution
- API fetch boundary tests
- remote read-only manual verification
- `json` / `table` / `markdown` rendering
- language handling
- mock tests do not depend on the real remote API
## Environment
- OS: Windows
- Go version: `go1.26.1 windows/amd64`
- Go path: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\go.exe`
- gofmt path: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\gofmt.exe`
## Test Commands
Executed:
```bash
gofmt -w shortcuts/workflow/*.go shortcuts/register.go
go test ./shortcuts/workflow
go test ./...
```
Results:
- `go test ./shortcuts/workflow` passed.
- `go test ./...` passed.
## Unit Tests
- triage rules tests
- health score tests
- messages tests
- render tests
- command tests
- fetch boundary tests
- PR summary rules and fetch tests
- repo report aggregation, render, command, and partial fetch tests
## API Fetch Boundary Tests
- empty issue responses return a clear error instead of panicking
- missing issue titles still allow body-only issues to be normalized
- label normalization supports string arrays, object arrays, and title/name variants
- author normalization supports string, `user`, and `creator` shapes
- GitLink error-in-body responses return readable errors
- health activity timestamps accept `updated_at`, `updatedAt`, `last_activity_at`, `merged_at`, and `closed_at`
- release responses accept `releases`, `data`, and direct array shapes
- CI unavailability is recorded as `unknown` without failing the whole health run
- stale-days values `0` and negative values fall back to the default `30`
- PR summary fetch normalizes PR metadata, changed files, commits, authors, branches, and list limits
- PR summary tolerates partial files or commits fetch failures while keeping base PR metadata
- PR summary base PR error-in-body responses return readable errors
- repo report fetch composes health, issue, and PR sections
- repo report returns a partial report when at least one enabled section succeeds
- repo report returns an error when all enabled fetched sections fail
- repo report issue and PR limits are covered
## Manual Command Examples
```bash
gitlink-cli workflow +triage --title "Install failed on Windows" --body "go install failed with error" --format table
gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --format json
gitlink-cli workflow +triage \
--title "安装失败,无法登录" \
--body "运行命令时报错" \
--lang zh-CN \
--format markdown
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format json
gitlink-cli workflow +health \
--repository Gitlink/gitlink-cli \
--open-issues 3 \
--open-prs 1 \
--has-readme \
--has-license \
--has-contributing \
--agent-readiness-known \
--agent-readiness-score 9 \
--format table
gitlink-cli workflow +health \
--repository demo/repo \
--open-issues 60 \
--stale-issues 25 \
--open-prs 12 \
--stale-prs 6 \
--recent-activity-known \
--recent-activity-days 120 \
--release-known=false \
--format json
gitlink-cli workflow +health \
--repository Gitlink/gitlink-cli \
--open-issues 3 \
--open-prs 1 \
--has-readme \
--has-license \
--has-contributing \
--lang zh-CN \
--format markdown
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
```
## Remote Manual Verification
- Command: `gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table`
- Result: succeeded, returned five issues in table form.
- Command: `gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --lang zh-CN --format markdown`
- Result: succeeded, returned a markdown health report with score `58` and risk level `high`.
- Remote writes: `No`
## Known Limitations
- Current workflow commands support local analysis and read-only GitLink fetch mode.
- `workflow +triage` still supports local parameters or a local JSON file via `--from`.
- `workflow +health` still supports local parameters or a local JSON file via `--from`.
- `workflow +pr-summary` supports local JSON input and read-only GitLink fetch mode.
- `workflow +repo-report` supports local JSON input and partial read-only GitLink fetch aggregation.
- Remote `workflow +repo-report` PR aggregation currently uses PR list metadata only;
detailed file and commit analysis remains available through `workflow +pr-summary --number`.
- `json/table/markdown` are rendered inside the workflow package, not by the global formatter.
- Fetch-layer tests use `httptest` and do not depend on the real remote API.
## Conclusion
The rule-based Agent Workflow prototype, including the read-only fetch layer, is implemented, tested, and locally runnable.
## Final Verification
Final verification should be run before opening the official GitLink PR:
```bash
gofmt -w shortcuts/workflow/*.go shortcuts/register.go
go test ./shortcuts/workflow
go test ./...
```
Expected result:
- `go test ./shortcuts/workflow` passes.
- `go test ./...` passes.
- No remote write operation is performed by workflow commands.
## Competition Demo Commands
Prefer local fixtures for stable demos:
```bash
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format table
gitlink-cli workflow +health --from shortcuts/workflow/testdata/health_good.json --format markdown
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format markdown
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown
```
Read-only remote smoke commands:
```bash
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --format table
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
```

View File

@ -0,0 +1,8 @@
outputs/
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
*.log
*.tmp
*.swp

View File

@ -0,0 +1,17 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Copyright 2026 GitLink Workflow Project
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@ -0,0 +1,53 @@
# GitLink 构建端到端自动化工作流
面向 GitLink 竞赛子赛题三的端到端自动化工作流项目。
本项目面向开源社区运营场景,使用 `gitlink-cli` 串联仓库信息、Issue、PR 和 Release 数据采集自动生成社区周报、Release Notes 草稿和结构化摘要,并支持将摘要发布到指定 GitLink Issue。该流程覆盖“数据采集 -> 指标分析 -> 文档生成 -> 结果发布”的完整闭环。
## 交付物
- `scripts/gitlink_workflow.py`:主工作流入口
- `scripts/run_demo.ps1`:一键复现脚本
- `docs/architecture.md`:架构图与流程说明
- `docs/quickstart.md`:最短复现路径
- `docs/runbook.md`:运行手册
- `docs/verification.md`:真实仓库验证记录
- `docs/submission-checklist.md`:参赛提交核对清单
- `docs/upload-to-gitlink.md`:仓库目录结构说明
- `examples/sample_config.json`:参赛仓库配置
- `examples/demo_active_config.json`:公开仓库验证配置
- `examples/demo_outputs/`:真实运行示例产物
- `tests/test_gitlink_workflow.py`:单测
- `LICENSE`Apache 2.0
## 运行方式
推荐直接运行一键脚本:
```powershell
.\scripts\run_demo.ps1
```
切换到参赛仓库配置:
```powershell
.\scripts\run_demo.ps1 -Config examples\sample_config.json
```
## 输出
- `outputs/*_report.md`
- `outputs/*_release_notes.md`
- `outputs/*_summary.json`
## 已验证仓库
- `puygob236/gitlink-cli`完成仓库信息、Issue、PR、Release 采集,并完成 Issue 摘要回写验证
- `Gitlink/gitlink-cli`完成仓库信息、Issue、PR、Release 采集并生成包含有效统计数据的周报、Release Notes 和结构化摘要
## 项目定位
- 满足子赛题三“端到端自动化工作流”的要求
- 串联 4 个数据采集命令和 1 个结果发布命令
- 支持在真实 GitLink 项目上复现
- 提供运行脚本、验证记录、示例产物和单元测试

View File

@ -0,0 +1,26 @@
# 架构说明
本项目采用“采集 -> 归一化 -> 分析 -> 生成 -> 发布”的五段式流程。
![GitLink 社区运营端到端自动化工作流架构](assets/architecture-workflow-v2.svg)
## 设计目标
- 低门槛:只依赖 `gitlink-cli` 和 Python 标准库
- 可复现:同一配置可重复跑出同类报告
- 可维护:采集、归一化、分析、生成和发布步骤保持清晰边界
- 可验证:报告文件、结构化摘要和 Issue 评论均可作为运行结果核验依据
## 为什么选这个链路
子赛题三要求使用现有命令或 Skill 组合形成完整解决方案。本方案覆盖:
1. 仓库信息采集
2. Issue 列表采集
3. PR 列表采集
4. Release 列表采集
5. 报告生成
6. Issue 摘要发布
该链路满足不少于 3 个 CLI 调用的要求,并形成从数据获取到结果发布的端到端闭环。

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 400 KiB

View File

@ -0,0 +1,32 @@
# 示例输出摘要
## 验证目标
`Gitlink/gitlink-cli`
## 运行命令
```powershell
.\scripts\run_demo.ps1
```
## 关键结果
- Issues: 15
- PR: 20
- Release: 11
- 输出文件:
- `outputs/Gitlink_gitlink-cli_20260520_140525_report.md`
- `outputs/Gitlink_gitlink-cli_20260520_140525_release_notes.md`
- `outputs/Gitlink_gitlink-cli_20260520_140525_summary.json`
## 仓库内示例产物
- `examples/demo_outputs/Gitlink_gitlink-cli_report.md`
- `examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md`
- `examples/demo_outputs/puygob236_gitlink-cli_report.md`
- `examples/demo_outputs/puygob236_gitlink-cli_release_notes.md`
## 额外验证
`puygob236/gitlink-cli` 已完成仓库信息、Issue、PR 和 Release 采集验证,并完成摘要回写到 Issue 的发布验证。

View File

@ -0,0 +1,34 @@
# 快速开始
## 一键运行
直接运行一键脚本:
```powershell
.\scripts\run_demo.ps1
```
脚本会自动通过 `npm exec` 找到 `@gitlink-ai/cli`,把 `gitlink-cli` 放到临时 PATH 里,再执行:
- 仓库信息采集
- Issue 列表采集
- PR 列表采集
- Release 列表采集
- 周报生成
- Release Notes 草稿生成
## 配置切换
- `examples/demo_active_config.json`:公开仓库验证配置,默认指向 `Gitlink/gitlink-cli`
- `examples/sample_config.json`:参赛仓库验证配置,默认指向 `puygob236/gitlink-cli`
## 输出
- `outputs/*_report.md`
- `outputs/*_release_notes.md`
- `outputs/*_summary.json`
## 已验证事实
- `puygob236/gitlink-cli` 已完成采集、报告生成和 Issue 摘要回写验证
- `Gitlink/gitlink-cli` 可生成带统计内容的周报和 Release Notes

View File

@ -0,0 +1,54 @@
# 运行手册
## 前置条件
- 已安装 `gitlink-cli`
- 已完成 `gitlink-cli auth login`
- 目标仓库有可读权限
官方快速开始里要求的验证命令是:
```powershell
gitlink-cli user +me
```
## 运行方式
### 1. 只生成报告
```powershell
python .\scripts\gitlink_workflow.py --config .\examples\sample_config.json
```
### 2. 生成报告并发布摘要
```powershell
python .\scripts\gitlink_workflow.py --config .\examples\sample_config.json --publish-issue-id 123
```
### 3. 一键复现
```powershell
.\scripts\run_demo.ps1
```
## 输出文件
- `outputs/*_report.md`:完整周报
- `outputs/*_release_notes.md`Release Notes 草稿
- `outputs/*_summary.json`:结构化摘要
## 验证清单
- `repo +info` 能返回仓库信息
- `issue +list` 能返回 Issue 列表
- `pr +list` 能返回 PR 列表
- `release +list` 能返回 Release 列表
- 报告文件能落盘
- Release Notes 草稿能落盘
- 发布模式能把摘要写回指定 Issue
## 真实项目配置
- `examples/demo_active_config.json` 指向 `Gitlink/gitlink-cli`,用于验证活跃公开仓库的数据分析能力。
- `examples/sample_config.json` 指向 `puygob236/gitlink-cli`,用于验证参赛仓库的采集和 Issue 回写能力。

View File

@ -0,0 +1,28 @@
# 提交核对清单
## 官方交付要求映射
| 要求 | 本项目对应内容 |
| --- | --- |
| 工作流串联不少于 3 个 CLI 命令或 Skill 调用 | `scripts/gitlink_workflow.py` 串联 `repo +info`、`issue +list`、`pr +list`、`release +list`,并支持 `issue +comment` 发布摘要 |
| 提供可复现执行脚本或 Agent 对话记录 | `scripts/run_demo.ps1` |
| 在至少一个真实 GitLink 项目上运行并展示效果 | `docs/verification.md`、`docs/demo-output.md`、`examples/demo_outputs/` |
| 提供工作流说明文档 | `README.md`、`docs/quickstart.md`、`docs/runbook.md` |
| 提供架构图 | `docs/architecture.md` 引用 `docs/assets/architecture-workflow-v2.svg` |
| 代码开源并托管到 GitLink | `https://gitlink.org.cn/puygob236/gitlink-cli``examples/workflows/community-ops-automation/` |
| 提供完整中文 README | `README.md` |
| 开源协议 | `LICENSE`Apache 2.0 |
## 验证状态
- `python -m py_compile .\scripts\gitlink_workflow.py .\tests\test_gitlink_workflow.py`:通过
- `python -m unittest discover -s tests`:通过
- `.\scripts\run_demo.ps1`:已在 `Gitlink/gitlink-cli` 上跑通
- `.\scripts\run_demo.ps1 -Config examples\sample_config.json`:已在 `puygob236/gitlink-cli` 上跑通
- `.\scripts\run_demo.ps1 -Config examples\sample_config.json -PublishIssueId 2`:已完成 Issue 摘要回写验证
## 交付内容
- `README.md`、`docs/`、`scripts/`、`examples/`、`tests/`、`LICENSE` 均位于 `examples/workflows/community-ops-automation/`
- `outputs/` 为运行时生成目录,评审可通过复现脚本重新生成。
- `examples/demo_outputs/` 提供固定示例产物,便于快速查看报告格式和输出内容。

View File

@ -0,0 +1,30 @@
# GitLink 仓库目录结构
本作品以 `gitlink-cli` 工作流示例的形式托管在 GitLink 仓库中目录与主项目源码保持隔离避免改变主仓库既有命令、Skill 和设计文档结构。
## 作品路径
```text
examples/workflows/community-ops-automation/
```
## 目录内容
- `README.md`:项目说明与复现入口
- `LICENSE`Apache 2.0 开源协议
- `.gitignore`:运行时产物忽略规则
- `docs/`:架构、运行、验证和交付说明
- `examples/`:配置文件和示例输出
- `scripts/`:工作流执行脚本
- `tests/`:单元测试
## 仓库内验证
进入作品目录后运行:
```powershell
python -m unittest discover -s tests
.\scripts\run_demo.ps1
```
生成的 `outputs/` 是运行时目录;固定示例产物位于 `examples/demo_outputs/`

View File

@ -0,0 +1,67 @@
# 验证记录
## 环境
- Windows PowerShell
- Python 3
- `@gitlink-ai/cli` 0.1.13
## 已验证的真实仓库
### `puygob236/gitlink-cli`
- `repo +info` 可访问
- `issue +list` 可访问
- `pr +list` 可访问
- `release +list` 可访问
- 已完成 Issue 摘要回写验证
### `Gitlink/gitlink-cli`
- `repo +info` 可访问
- `issue +list` 可访问
- `pr +list` 可访问
- `release +list` 可访问
- 当前可提取到的统计结果:
- Issues: 15
- PR: 20
- Release: 11
## 本地输出
已生成的文件:
- `outputs/Gitlink_gitlink-cli_20260515_040153_report.md`
- `outputs/Gitlink_gitlink-cli_20260515_040153_summary.json`
- `outputs/Gitlink_gitlink-cli_20260515_121523_report.md`
- `outputs/Gitlink_gitlink-cli_20260515_121523_release_notes.md`
- `outputs/Gitlink_gitlink-cli_20260515_121523_summary.json`
- `outputs/puygob236_gitlink-cli_20260515_121544_report.md`
- `outputs/puygob236_gitlink-cli_20260515_121544_release_notes.md`
- `outputs/puygob236_gitlink-cli_20260515_121544_summary.json`
- `outputs/puygob236_gitlink-cli_20260515_121845_report.md`
- `outputs/puygob236_gitlink-cli_20260515_121845_release_notes.md`
- `outputs/puygob236_gitlink-cli_20260515_121845_summary.json`
- `outputs/Gitlink_gitlink-cli_20260520_140525_report.md`
- `outputs/Gitlink_gitlink-cli_20260520_140525_release_notes.md`
- `outputs/Gitlink_gitlink-cli_20260520_140525_summary.json`
- `outputs/puygob236_gitlink-cli_20260520_143224_report.md`
- `outputs/puygob236_gitlink-cli_20260520_143224_release_notes.md`
- `outputs/puygob236_gitlink-cli_20260520_143224_summary.json`
其中 `20260520_140525` 对应公开仓库数据分析验证,`20260520_143224` 对应参赛仓库采集与 Issue 回写验证。
## 示例产物
`outputs/` 是运行时目录,仓库交付中同时提供了轻量示例:
- `examples/demo_outputs/Gitlink_gitlink-cli_report.md`
- `examples/demo_outputs/Gitlink_gitlink-cli_release_notes.md`
- `examples/demo_outputs/puygob236_gitlink-cli_report.md`
- `examples/demo_outputs/puygob236_gitlink-cli_release_notes.md`
## 复现方式
```powershell
.\scripts\run_demo.ps1
```

View File

@ -0,0 +1,6 @@
{
"owner": "Gitlink",
"repo": "gitlink-cli",
"window_days": 7,
"output_dir": "outputs"
}

View File

@ -0,0 +1,18 @@
# gitlink-cli Release Notes 草稿
- 统计窗口:近 7 天
- 生成时间2026-05-20 14:05:25 UTC
## 变更概览
- 已合并 PR8 个
- 最近窗口内合并 PR2 个
## 变更分类
### feature
- feat(pr): add pr +comment shortcut (2026-05-14)
### fix
- fix(npm): improve missing binary diagnostics (2026-05-19)
## 发布说明
- 存在 1 个超过 7 天未更新的开放 Issue建议优先清理。

View File

@ -0,0 +1,32 @@
# gitlink-cli 自动化周报
- 统计窗口:近 7 天
- 生成时间2026-05-20 14:05:25 UTC
## 核心指标
| 指标 | 数值 |
| --- | ---: |
| Issues 总数 | 15 |
| 打开 Issues | 5 |
| 超窗 Issue | 1 |
| PR 总数 | 20 |
| 打开 PR | 5 |
| 已合并 PR | 8 |
| Release 数 | 11 |
## 热点标签
- 无
## 最近合并 PR
### fix
- fix(npm): improve missing binary diagnostics (2026-05-19)
### feature
- feat(pr): add pr +comment shortcut (2026-05-14)
## 风险提示
### 超窗 Issue
- 2 gitlink-cli 使用讨论与反馈收集 (open) 2026-04-18
### 建议动作
- 存在 1 个超过 7 天未更新的开放 Issue建议优先清理。

View File

@ -0,0 +1,10 @@
# 示例输出说明
本目录保存一次真实 GitLink 项目的演示输出,便于评审在不重新运行脚本时快速查看效果。
- `Gitlink_gitlink-cli_report.md`:活跃官方仓库周报示例
- `Gitlink_gitlink-cli_release_notes.md`:活跃官方仓库 Release Notes 草稿示例
- `puygob236_gitlink-cli_report.md`:参赛 fork 连通性周报示例
- `puygob236_gitlink-cli_release_notes.md`:参赛 fork Release Notes 草稿示例
完整结构化摘要会在运行脚本后生成到 `outputs/*_summary.json`

View File

@ -0,0 +1,14 @@
# gitlink-cli Release Notes 草稿
- 统计窗口:近 7 天
- 生成时间2026-05-20 14:32:24 UTC
## 变更概览
- 已合并 PR0 个
- 最近窗口内合并 PR0 个
## 变更分类
- 无
## 发布说明
- 当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。

View File

@ -0,0 +1,26 @@
# gitlink-cli 自动化周报
- 统计窗口:近 7 天
- 生成时间2026-05-20 14:32:24 UTC
## 核心指标
| 指标 | 数值 |
| --- | ---: |
| Issues 总数 | 2 |
| 打开 Issues | 2 |
| 超窗 Issue | 0 |
| PR 总数 | 0 |
| 打开 PR | 0 |
| 已合并 PR | 0 |
| Release 数 | 0 |
## 热点标签
- 无
## 最近合并 PR
- 无
## 风险提示
### 建议动作
- 当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。

View File

@ -0,0 +1,6 @@
{
"owner": "puygob236",
"repo": "gitlink-cli",
"window_days": 7,
"output_dir": "outputs"
}

View File

@ -0,0 +1,814 @@
from __future__ import annotations
import argparse
import json
import os
import subprocess
from collections import Counter, defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Iterable
class WorkflowError(RuntimeError):
pass
CLI_PAGE_SIZE = 100
def parse_args(argv: list[str] | None = None) -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="GitLink 社区运营自动化工作流:周报 + Release Notes + 风险提示"
)
parser.add_argument(
"--config",
type=Path,
default=Path("examples/sample_config.json"),
help="配置文件路径",
)
parser.add_argument("--owner", help="覆盖配置中的仓库所有者")
parser.add_argument("--repo", help="覆盖配置中的仓库名称")
parser.add_argument(
"--window-days",
type=int,
help="统计窗口,默认从配置文件读取或使用 7 天",
)
parser.add_argument(
"--output-dir",
type=Path,
help="输出目录,默认从配置文件读取或使用 outputs",
)
parser.add_argument(
"--publish-issue-id",
type=int,
help="发布摘要到指定 Issue 评论,未提供则只生成本地报告",
)
parser.add_argument(
"--now",
help="固定当前时间,便于测试,格式为 ISO8601",
)
parser.add_argument(
"--skip-releases",
action="store_true",
help="跳过 release 列表采集",
)
parser.add_argument(
"--cli-bin",
help="gitlink-cli 可执行文件路径;可配合 GITLINK_CLI_BIN 使用",
)
return parser.parse_args(argv)
def load_json_file(path: Path) -> dict[str, Any]:
if not path.exists():
return {}
return json.loads(path.read_text(encoding="utf-8"))
def sanitize_repo_name(value: str) -> str:
return value.replace("/", "_").replace("\\", "_")
def parse_datetime(value: Any) -> datetime | None:
if value in (None, "", []):
return None
if isinstance(value, datetime):
dt = value
else:
text = str(value).strip()
if not text:
return None
text = text.replace("Z", "+00:00")
try:
dt = datetime.fromisoformat(text)
except ValueError:
return None
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
def parse_iso_now(value: str | None) -> datetime:
if not value:
return datetime.now(timezone.utc)
dt = parse_datetime(value)
if dt is None:
raise WorkflowError(f"无法解析 --now 的值: {value}")
return dt
def first_value(item: dict[str, Any], keys: Iterable[str], default: Any = None) -> Any:
for key in keys:
if key in item:
value = item[key]
if value not in (None, "", []):
return value
return default
def normalize_labels(value: Any) -> list[str]:
labels: list[str] = []
if isinstance(value, list):
for item in value:
if isinstance(item, dict):
name = first_value(item, ("name", "title", "label_name"))
if name:
labels.append(str(name))
elif item not in (None, ""):
labels.append(str(item))
elif isinstance(value, str) and value:
labels.append(value)
return labels
def extract_first_list(payload: Any, keys: Iterable[str]) -> list[Any]:
if isinstance(payload, list):
return payload
if isinstance(payload, dict):
for key in keys:
value = payload.get(key)
if isinstance(value, list):
return value
for value in payload.values():
found = extract_first_list(value, keys)
if found:
return found
return []
def extract_first_dict(payload: Any, keys: Iterable[str]) -> dict[str, Any]:
if isinstance(payload, dict):
for key in keys:
value = payload.get(key)
if isinstance(value, dict):
return value
for value in payload.values():
found = extract_first_dict(value, keys)
if found:
return found
if isinstance(payload, list):
for item in payload:
found = extract_first_dict(item, keys)
if found:
return found
return {}
def run_gitlink_cli(command: list[str], owner: str, repo: str, cwd: Path | None = None) -> Any:
if shutil_which("gitlink-cli") is None:
raise WorkflowError("未找到 gitlink-cli请先安装并确保它在 PATH 中")
cli_path = shutil_which("gitlink-cli") or "gitlink-cli"
if cli_path.lower().endswith((".cmd", ".bat")):
cmd = [
"cmd",
"/c",
cli_path,
*command,
"--owner",
owner,
"--repo",
repo,
"--format",
"json",
]
else:
cmd = [
cli_path,
*command,
"--owner",
owner,
"--repo",
repo,
"--format",
"json",
]
proc = subprocess.run(
cmd,
cwd=str(cwd) if cwd else None,
capture_output=True,
text=True,
encoding="utf-8",
)
if proc.returncode != 0:
stderr = proc.stderr.strip() or proc.stdout.strip() or "未知错误"
raise WorkflowError(f"{' '.join(cmd)} 失败: {stderr}")
return parse_json_output(proc.stdout)
def parse_json_output(text: str) -> Any:
stripped = text.strip()
if not stripped:
raise WorkflowError("CLI 返回空结果")
try:
return json.loads(stripped)
except json.JSONDecodeError:
first_json = min(
[idx for idx in (stripped.find("{"), stripped.find("[")) if idx != -1],
default=-1,
)
if first_json > 0:
return json.loads(stripped[first_json:])
raise WorkflowError(f"无法解析 CLI JSON 输出: {stripped[:120]}")
def normalize_repo_info(payload: Any) -> dict[str, Any]:
repo = extract_first_dict(payload, ("project", "repo", "repository", "data"))
if not repo and isinstance(payload, dict):
repo = payload
return {
"name": first_value(repo, ("name", "repo_name", "project_name", "identifier"), ""),
"description": first_value(repo, ("description", "desc", "summary"), ""),
"default_branch": first_value(repo, ("default_branch", "defaultBranch"), ""),
"language": first_value(repo, ("language",), ""),
"raw": repo,
}
def normalize_issue_state(item: dict[str, Any], query_state: str | None = None) -> str:
raw_status = first_value(item, ("status_id", "status", "state_id"), None)
raw_name = str(
first_value(item, ("issue_status", "status_name", "state", "status_name_cn"), "")
).strip().lower()
if raw_status is not None:
try:
raw_status = int(raw_status)
except (TypeError, ValueError):
raw_status = str(raw_status).strip().lower()
if raw_status in {5, "5", "closed", "close"} or "" in raw_name or "closed" in raw_name:
return "closed"
if raw_status in {1, "1", 2, "2", 3, "3", "open", "opened"} or "" in raw_name or "" in raw_name:
return "open"
if query_state:
return query_state
return "open"
def normalize_issue(item: dict[str, Any], query_state: str | None = None) -> dict[str, Any]:
return {
"id": str(first_value(item, ("project_issues_index", "iid", "issue_id", "id", "number"), "")),
"title": str(first_value(item, ("subject", "title", "name"), "(untitled)")),
"state": normalize_issue_state(item, query_state=query_state),
"created_at": parse_datetime(
first_value(item, ("created_at", "createdAt", "created_time", "created", "format_time"))
),
"updated_at": parse_datetime(
first_value(item, ("updated_at", "updatedAt", "updated_time", "updated", "format_time"))
),
"labels": normalize_labels(first_value(item, ("labels", "label_list", "label"), [])),
"raw": item,
}
def normalize_issues(payload: Any, query_state: str | None = None) -> list[dict[str, Any]]:
items = extract_first_list(payload, ("issues", "issue_list", "items", "list"))
normalized: list[dict[str, Any]] = []
for item in items:
if not isinstance(item, dict):
continue
normalized.append(normalize_issue(item, query_state=query_state))
return normalized
def normalize_pr_state(item: dict[str, Any], query_state: str | None = None) -> str:
raw_status = first_value(item, ("pull_request_status", "pull_request_staus", "status_id", "state_id"), None)
if raw_status is not None:
try:
raw_status = int(raw_status)
except (TypeError, ValueError):
raw_status = str(raw_status).strip().lower()
if raw_status in {1, "1", "merged"}:
return "merged"
if raw_status in {2, "2", "closed", "close"}:
return "closed"
if raw_status in {0, "0", "open", "opened"}:
return "open"
if query_state:
return query_state
return "open"
def normalize_pr(item: dict[str, Any], query_state: str | None = None) -> dict[str, Any]:
state = normalize_pr_state(item, query_state=query_state)
merged_at = parse_datetime(first_value(item, ("merged_at", "mergedAt", "merged_time")))
merged_flag = state == "merged" or merged_at is not None
return {
"id": str(
first_value(item, ("pull_request_number", "iid", "pr_id", "merge_request_iid", "id", "number"), "")
),
"title": str(first_value(item, ("title", "subject", "name"), "(untitled)")),
"state": state,
"created_at": parse_datetime(
first_value(item, ("created_at", "createdAt", "created_time", "created", "pr_full_time"))
),
"updated_at": parse_datetime(
first_value(item, ("updated_at", "updatedAt", "updated_time", "updated", "pr_full_time"))
),
"merged_at": merged_at
or (parse_datetime(first_value(item, ("pr_full_time",))) if state == "merged" else None),
"merged": merged_flag,
"labels": normalize_labels(first_value(item, ("labels", "label_list", "label"), [])),
"raw": item,
}
def normalize_prs(payload: Any, query_state: str | None = None) -> list[dict[str, Any]]:
items = extract_first_list(payload, ("pull_requests", "merge_requests", "prs", "items", "list"))
normalized: list[dict[str, Any]] = []
for item in items:
if not isinstance(item, dict):
continue
normalized.append(normalize_pr(item, query_state=query_state))
return normalized
def normalize_releases(payload: Any) -> list[dict[str, Any]]:
items = extract_first_list(payload, ("releases", "items", "list"))
normalized: list[dict[str, Any]] = []
for item in items:
if not isinstance(item, dict):
continue
normalized.append(
{
"id": str(first_value(item, ("version_id", "id", "release_id", "iid"), "")),
"title": str(first_value(item, ("name", "title", "tag_name"), "(untitled)")),
"created_at": parse_datetime(
first_value(item, ("created_at", "createdAt", "released_at", "releasedAt"))
),
"raw": item,
}
)
return normalized
def is_open(state: str) -> bool:
return state == "open"
def is_closed(state: str) -> bool:
return state in {"closed", "close", "done", "resolved"}
def classify_title(title: str) -> str:
lowered = title.strip().lower()
prefix = lowered.split(":", 1)[0]
prefix = prefix.split("(", 1)[0].strip()
mapping = {
"feat": "feature",
"feature": "feature",
"fix": "fix",
"bugfix": "fix",
"docs": "docs",
"doc": "docs",
"refactor": "refactor",
"test": "test",
"chore": "chore",
"ci": "ci",
}
return mapping.get(prefix, "other")
def within_window(dt: datetime | None, cutoff: datetime) -> bool:
return dt is not None and dt >= cutoff
def dedupe_records(records: list[dict[str, Any]]) -> list[dict[str, Any]]:
seen: set[str] = set()
result: list[dict[str, Any]] = []
for item in records:
key = str(item.get("id", "")).strip()
if not key or key in seen:
continue
seen.add(key)
result.append(item)
return result
def fetch_paginated_payload(
command: list[str],
owner: str,
repo: str,
item_keys: tuple[str, ...],
page_size: int = CLI_PAGE_SIZE,
) -> list[dict[str, Any]]:
items: list[dict[str, Any]] = []
page = 1
max_pages = 50
while True:
if page > max_pages:
break
payload = run_gitlink_cli(
[*command, "--page", str(page), "--limit", str(page_size)],
owner,
repo,
)
page_items = extract_first_list(payload, item_keys)
page_items = [item for item in page_items if isinstance(item, dict)]
if not page_items:
break
items.extend(page_items)
if len(page_items) < page_size:
break
page += 1
return items
def fetch_issues(owner: str, repo: str) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
for state in ("open", "closed"):
payloads = fetch_paginated_payload(
["issue", "+list", "--state", state],
owner,
repo,
("issues", "issue_list", "items", "list"),
)
records.extend(normalize_issues({"issues": payloads}, query_state=state))
return dedupe_records(records)
def fetch_prs(owner: str, repo: str) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
for state in ("open", "merged", "closed"):
payloads = fetch_paginated_payload(
["pr", "+list", "--state", state],
owner,
repo,
("pull_requests", "merge_requests", "prs", "items", "list"),
)
records.extend(normalize_prs({"pull_requests": payloads}, query_state=state))
return dedupe_records(records)
def fetch_releases(owner: str, repo: str) -> list[dict[str, Any]]:
payloads = fetch_paginated_payload(
["release", "+list"],
owner,
repo,
("releases", "items", "list"),
)
return dedupe_records(normalize_releases({"releases": payloads}))
def summarize_workflow(
repo_info: dict[str, Any],
issues: list[dict[str, Any]],
prs: list[dict[str, Any]],
releases: list[dict[str, Any]],
now: datetime,
window_days: int,
) -> dict[str, Any]:
cutoff = now - timedelta(days=window_days)
open_issues = [item for item in issues if is_open(item["state"])]
closed_issues = [item for item in issues if is_closed(item["state"])]
stale_issues = [
item
for item in open_issues
if item["updated_at"] is None or item["updated_at"] < cutoff
]
merged_prs = [item for item in prs if item["merged"] or item["state"] == "merged"]
open_prs = [item for item in prs if is_open(item["state"]) or (not item["merged"] and not is_closed(item["state"]))]
stale_prs = [
item
for item in open_prs
if item["updated_at"] is None or item["updated_at"] < cutoff
]
recent_merged_prs = [
item
for item in merged_prs
if within_window(item["merged_at"] or item["updated_at"] or item["created_at"], cutoff)
]
issue_label_counter: Counter[str] = Counter()
for item in issues:
issue_label_counter.update(item["labels"])
pr_buckets: dict[str, list[dict[str, Any]]] = defaultdict(list)
for item in recent_merged_prs:
pr_buckets[classify_title(item["title"])].append(item)
actions: list[str] = []
if stale_issues:
actions.append(
f"存在 {len(stale_issues)} 个超过 {window_days} 天未更新的开放 Issue建议优先清理。"
)
if stale_prs:
actions.append(
f"存在 {len(stale_prs)} 个超过 {window_days} 天未更新的开放 PR建议安排 review 或重新拆解。"
)
if not releases:
actions.append("当前未采集到 Release 记录,建议补充发布说明或确认 Release 权限。")
return {
"repo": repo_info,
"window_days": window_days,
"now": now,
"cutoff": cutoff,
"counts": {
"issues_total": len(issues),
"issues_open": len(open_issues),
"issues_closed": len(closed_issues),
"issues_stale": len(stale_issues),
"prs_total": len(prs),
"prs_open": len(open_prs),
"prs_merged": len(merged_prs),
"prs_stale": len(stale_prs),
"releases_total": len(releases),
},
"labels": issue_label_counter.most_common(8),
"stale_issues": stale_issues,
"stale_prs": stale_prs,
"recent_merged_prs": recent_merged_prs,
"pr_buckets": {key: value for key, value in pr_buckets.items()},
"actions": actions,
}
def render_list_block(items: list[dict[str, Any]], title_key: str = "title") -> str:
if not items:
return "- 无"
lines = []
for item in items[:10]:
parts = [f"- {item.get('id', '')} {item.get(title_key, '')}".strip()]
state = item.get("state")
if state:
parts.append(f"({state})")
dt = item.get("updated_at") or item.get("merged_at") or item.get("created_at")
if isinstance(dt, datetime):
parts.append(dt.strftime("%Y-%m-%d"))
lines.append(" ".join(parts))
return "\n".join(lines)
def render_markdown_report(summary: dict[str, Any]) -> str:
repo = summary["repo"]
counts = summary["counts"]
lines: list[str] = []
title = repo["name"] or "GitLink 仓库"
lines.append(f"# {title} 自动化周报")
if repo.get("description"):
lines.append("")
lines.append(repo["description"])
lines.append("")
lines.append(f"- 统计窗口:近 {summary['window_days']}")
lines.append(f"- 生成时间:{summary['now'].strftime('%Y-%m-%d %H:%M:%S UTC')}")
lines.append("")
lines.append("## 核心指标")
lines.append("")
lines.append("| 指标 | 数值 |")
lines.append("| --- | ---: |")
lines.append(f"| Issues 总数 | {counts['issues_total']} |")
lines.append(f"| 打开 Issues | {counts['issues_open']} |")
lines.append(f"| 超窗 Issue | {counts['issues_stale']} |")
lines.append(f"| PR 总数 | {counts['prs_total']} |")
lines.append(f"| 打开 PR | {counts['prs_open']} |")
lines.append(f"| 已合并 PR | {counts['prs_merged']} |")
lines.append(f"| Release 数 | {counts['releases_total']} |")
lines.append("")
lines.append("## 热点标签")
if summary["labels"]:
for label, count in summary["labels"]:
lines.append(f"- {label}: {count}")
else:
lines.append("- 无")
lines.append("")
lines.append("## 最近合并 PR")
recent_groups = summary["pr_buckets"]
if recent_groups:
for bucket, items in recent_groups.items():
lines.append(f"### {bucket}")
for item in items[:8]:
merged_at = item.get("merged_at") or item.get("updated_at") or item.get("created_at")
suffix = f" ({merged_at.strftime('%Y-%m-%d')})" if isinstance(merged_at, datetime) else ""
lines.append(f"- {item['title']}{suffix}")
else:
lines.append("- 无")
lines.append("")
lines.append("## 风险提示")
if summary["stale_issues"]:
lines.append("### 超窗 Issue")
lines.append(render_list_block(summary["stale_issues"]))
lines.append("")
if summary["stale_prs"]:
lines.append("### 超窗 PR")
lines.append(render_list_block(summary["stale_prs"]))
lines.append("")
if summary["actions"]:
lines.append("### 建议动作")
for action in summary["actions"]:
lines.append(f"- {action}")
else:
lines.append("- 当前未发现明显风险。")
return "\n".join(lines).rstrip() + "\n"
def render_release_notes(summary: dict[str, Any]) -> str:
repo = summary["repo"]
lines: list[str] = []
title = repo["name"] or "GitLink 仓库"
lines.append(f"# {title} Release Notes 草稿")
lines.append("")
lines.append(f"- 统计窗口:近 {summary['window_days']}")
lines.append(f"- 生成时间:{summary['now'].strftime('%Y-%m-%d %H:%M:%S UTC')}")
lines.append("")
lines.append("## 变更概览")
lines.append(f"- 已合并 PR{summary['counts']['prs_merged']}")
lines.append(f"- 最近窗口内合并 PR{len(summary['recent_merged_prs'])}")
lines.append("")
lines.append("## 变更分类")
groups = summary["pr_buckets"]
if groups:
for bucket in ("feature", "fix", "docs", "refactor", "test", "chore", "ci", "other"):
items = groups.get(bucket, [])
if not items:
continue
lines.append(f"### {bucket}")
for item in items[:10]:
merged_at = item.get("merged_at") or item.get("updated_at") or item.get("created_at")
suffix = f" ({merged_at.strftime('%Y-%m-%d')})" if isinstance(merged_at, datetime) else ""
lines.append(f"- {item['title']}{suffix}")
lines.append("")
else:
lines.append("- 无")
lines.append("")
lines.append("## 发布说明")
if summary["actions"]:
for action in summary["actions"]:
lines.append(f"- {action}")
else:
lines.append("- 当前未发现明显风险。")
return "\n".join(lines).rstrip() + "\n"
def render_publish_comment(
summary: dict[str, Any],
report_path: Path,
release_notes_path: Path | None = None,
) -> str:
repo = summary["repo"]
counts = summary["counts"]
lines = [
f"## {repo['name'] or 'GitLink 仓库'} 自动化周报摘要",
"",
f"- 时间窗:近 {summary['window_days']}",
f"- Issues{counts['issues_open']} 个打开,{counts['issues_stale']} 个超窗",
f"- PR{counts['prs_open']} 个打开,{counts['prs_merged']} 个已合并",
f"- Release{counts['releases_total']}",
"",
f"完整报告已生成:`{report_path.as_posix()}`",
]
if release_notes_path is not None:
lines.append(f"Release Notes 草稿:`{release_notes_path.as_posix()}`")
if summary["actions"]:
lines.append("")
lines.append("### 建议动作")
for action in summary["actions"][:3]:
lines.append(f"- {action}")
return "\n".join(lines).rstrip()
def build_issue_comment_command(issue_number: int, comment: str) -> list[str]:
return ["issue", "+comment", "--number", str(issue_number), "--body", comment]
def safe_fetch(
label: str,
func,
warnings: list[str],
default: Any,
) -> Any:
try:
return func()
except Exception as exc: # noqa: BLE001
warnings.append(f"{label} 失败:{exc}")
return default
def shutil_which(name: str) -> str | None:
from shutil import which
return which(name)
def build_artifacts(
owner: str,
repo: str,
window_days: int,
output_dir: Path,
now: datetime,
publish_issue_id: int | None,
skip_releases: bool,
) -> tuple[dict[str, Any], Path, Path, Path, list[str]]:
warnings: list[str] = []
repo_info = safe_fetch(
"repo +info",
lambda: normalize_repo_info(run_gitlink_cli(["repo", "+info"], owner, repo)),
warnings,
{"name": repo, "description": "", "default_branch": "", "language": "", "raw": {}},
)
issues = safe_fetch("issue +list", lambda: fetch_issues(owner, repo), warnings, [])
prs = safe_fetch("pr +list", lambda: fetch_prs(owner, repo), warnings, [])
releases = [] if skip_releases else safe_fetch(
"release +list",
lambda: fetch_releases(owner, repo),
warnings,
[],
)
summary = summarize_workflow(repo_info, issues, prs, releases, now, window_days)
summary["warnings"] = warnings
summary["owner"] = owner
summary["repo_name"] = repo
summary["publish_issue_id"] = publish_issue_id
output_dir.mkdir(parents=True, exist_ok=True)
stamp = now.strftime("%Y%m%d_%H%M%S")
repo_slug = sanitize_repo_name(repo)
base_name = f"{owner}_{repo_slug}_{stamp}"
report_path = output_dir / f"{base_name}_report.md"
summary_path = output_dir / f"{base_name}_summary.json"
release_notes_path = output_dir / f"{base_name}_release_notes.md"
report_text = render_markdown_report(summary)
release_notes_text = render_release_notes(summary)
report_path.write_text(report_text, encoding="utf-8")
release_notes_path.write_text(release_notes_text, encoding="utf-8")
summary_path.write_text(
json.dumps(
{
**summary,
"now": summary["now"].isoformat(),
"cutoff": summary["cutoff"].isoformat(),
"artifacts": {
"report": report_path.as_posix(),
"summary": summary_path.as_posix(),
"release_notes": release_notes_path.as_posix(),
},
},
ensure_ascii=False,
indent=2,
default=str,
),
encoding="utf-8",
)
if publish_issue_id is not None:
comment = render_publish_comment(summary, report_path, release_notes_path)
try:
run_gitlink_cli(
build_issue_comment_command(publish_issue_id, comment),
owner,
repo,
)
except Exception as exc: # noqa: BLE001
warnings.append(f"issue +comment 失败:{exc}")
return summary, report_path, summary_path, release_notes_path, warnings
def main(argv: list[str] | None = None) -> int:
args = parse_args(argv)
config = load_json_file(args.config)
owner = args.owner or config.get("owner")
repo = args.repo or config.get("repo")
if not owner or not repo:
raise WorkflowError("请在配置文件或命令行中提供 owner 和 repo")
window_days = args.window_days or int(config.get("window_days", 7))
output_dir = args.output_dir or Path(config.get("output_dir", "outputs"))
now = parse_iso_now(args.now)
summary, report_path, summary_path, release_notes_path, warnings = build_artifacts(
owner=owner,
repo=repo,
window_days=window_days,
output_dir=output_dir,
now=now,
publish_issue_id=args.publish_issue_id,
skip_releases=args.skip_releases,
)
print(f"已生成报告: {report_path}")
print(f"已生成摘要: {summary_path}")
print(f"已生成 Release Notes: {release_notes_path}")
if warnings:
print("警告:")
for warning in warnings:
print(f"- {warning}")
print(
"指标概览: "
f"Issues={summary['counts']['issues_total']}, "
f"PR={summary['counts']['prs_total']}, "
f"Release={summary['counts']['releases_total']}"
)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,45 @@
param(
[string]$Config = "examples/demo_active_config.json",
[string]$Owner = "",
[string]$Repo = "",
[int]$WindowDays = 7,
[string]$OutputDir = "outputs",
[int]$PublishIssueId = 0,
[switch]$SkipReleases
)
$ErrorActionPreference = "Stop"
$cliCandidates = npm.cmd exec --yes --package=@gitlink-ai/cli -- cmd /c where gitlink-cli 2>$null
$cliPath = $cliCandidates | Where-Object { $_ -match 'gitlink-cli\.cmd$' } | Select-Object -First 1
if (-not $cliPath) {
$cliPath = $cliCandidates | Select-Object -First 1
}
if (-not $cliPath) {
throw "未能通过 npm exec 找到 gitlink-cli"
}
$cliDir = Split-Path -Parent $cliPath
$env:PATH = "$cliDir;$env:PATH"
$args = @(
"scripts\gitlink_workflow.py",
"--config", $Config,
"--window-days", "$WindowDays",
"--output-dir", $OutputDir
)
if ($Owner) {
$args += @("--owner", $Owner)
}
if ($Repo) {
$args += @("--repo", $Repo)
}
if ($PublishIssueId -gt 0) {
$args += @("--publish-issue-id", "$PublishIssueId")
}
if ($SkipReleases.IsPresent) {
$args += "--skip-releases"
}
python @args

View File

@ -0,0 +1,133 @@
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from scripts.gitlink_workflow import (
build_issue_comment_command,
normalize_issues,
normalize_prs,
normalize_releases,
render_markdown_report,
render_release_notes,
summarize_workflow,
)
class WorkflowTests(unittest.TestCase):
def setUp(self) -> None:
self.now = datetime(2026, 5, 15, 12, 0, tzinfo=timezone.utc)
self.repo_info = {
"name": "forgeplus",
"description": "demo repo",
"default_branch": "master",
}
def test_normalize_issue_payload(self) -> None:
payload = {
"data": {
"issues": [
{
"project_issues_index": 1,
"subject": "feat: add report",
"status_id": 1,
"status_name": "新增",
"updated_at": "2026-05-10T10:00:00Z",
"labels": [{"name": "enhancement"}],
}
]
}
}
issues = normalize_issues(payload)
self.assertEqual(len(issues), 1)
self.assertEqual(issues[0]["title"], "feat: add report")
self.assertEqual(issues[0]["labels"], ["enhancement"])
self.assertEqual(issues[0]["state"], "open")
def test_normalize_pr_payload(self) -> None:
payload = {
"data": {
"merge_requests": [
{
"pull_request_number": 10,
"title": "fix: bug",
"pull_request_status": 1,
"merged_at": "2026-05-14T10:00:00Z",
}
]
}
}
prs = normalize_prs(payload)
self.assertEqual(len(prs), 1)
self.assertTrue(prs[0]["merged"])
self.assertEqual(prs[0]["state"], "merged")
def test_normalize_release_payload(self) -> None:
payload = {"data": {"releases": [{"id": 5, "name": "v1.0.0"}]}}
releases = normalize_releases(payload)
self.assertEqual(len(releases), 1)
self.assertEqual(releases[0]["title"], "v1.0.0")
def test_summary_and_report(self) -> None:
issues = [
{
"id": "1",
"title": "feat: add report",
"state": "open",
"created_at": datetime(2026, 5, 5, 12, 0, tzinfo=timezone.utc),
"updated_at": datetime(2026, 5, 10, 12, 0, tzinfo=timezone.utc),
"labels": ["enhancement"],
},
{
"id": "2",
"title": "fix: stale issue",
"state": "open",
"created_at": datetime(2026, 4, 20, 12, 0, tzinfo=timezone.utc),
"updated_at": datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc),
"labels": ["bug"],
},
]
prs = [
{
"id": "10",
"title": "feat: workflow",
"state": "merged",
"created_at": datetime(2026, 5, 12, 12, 0, tzinfo=timezone.utc),
"updated_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc),
"merged_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc),
"merged": True,
"labels": [],
},
{
"id": "11",
"title": "chore: cleanup",
"state": "open",
"created_at": datetime(2026, 5, 1, 12, 0, tzinfo=timezone.utc),
"updated_at": datetime(2026, 5, 2, 12, 0, tzinfo=timezone.utc),
"merged_at": None,
"merged": False,
"labels": [],
},
]
releases = [{"id": "1", "title": "v1.0.0", "created_at": datetime(2026, 5, 14, 12, 0, tzinfo=timezone.utc)}]
summary = summarize_workflow(self.repo_info, issues, prs, releases, self.now, 7)
report = render_markdown_report(summary)
self.assertIn("# forgeplus 自动化周报", report)
self.assertIn("Issues 总数", report)
self.assertIn("超窗 Issue", report)
self.assertIn("feature", report)
release_notes = render_release_notes(summary)
self.assertIn("Release Notes", release_notes)
self.assertIn("变更分类", release_notes)
self.assertEqual(summary["counts"]["issues_stale"], 1)
self.assertEqual(summary["counts"]["prs_merged"], 1)
self.assertIn("feature", summary["pr_buckets"])
def test_issue_comment_command_uses_number_flag(self) -> None:
command = build_issue_comment_command(2, "demo")
self.assertEqual(command, ["issue", "+comment", "--number", "2", "--body", "demo"])
self.assertNotIn("-i", command)
if __name__ == "__main__":
unittest.main()

View File

@ -42,6 +42,8 @@ func New() (*Client, error) {
}
func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) {
path = normalizeAPIPath(c.BaseURL, path)
// Append .json suffix if not already present (GitLink API convention)
// Handle paths that may already contain query strings (e.g., /path?key=val)
if idx := strings.Index(path, "?"); idx != -1 {
@ -158,6 +160,18 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o
return output.SuccessEnvelope(raw, meta), nil
}
func normalizeAPIPath(baseURL, path string) string {
if strings.HasSuffix(strings.TrimRight(baseURL, "/"), "/api") {
switch {
case path == "/api":
return ""
case strings.HasPrefix(path, "/api/"):
return strings.TrimPrefix(path, "/api")
}
}
return path
}
func (c *Client) Get(path string, query url.Values) (*output.Envelope, error) {
return c.Do("GET", path, nil, query)
}

View File

@ -0,0 +1,27 @@
package client
import "testing"
func TestNormalizeAPIPathStripsDuplicateAPIPrefix(t *testing.T) {
got := normalizeAPIPath("https://www.gitlink.org.cn/api", "/api/v1/repos/Gitlink/gitlink-cli/contents/README.md")
want := "/v1/repos/Gitlink/gitlink-cli/contents/README.md"
if got != want {
t.Fatalf("normalizeAPIPath() = %q, want %q", got, want)
}
}
func TestNormalizeAPIPathKeepsRegularPath(t *testing.T) {
got := normalizeAPIPath("https://www.gitlink.org.cn/api", "/projects")
want := "/projects"
if got != want {
t.Fatalf("normalizeAPIPath() = %q, want %q", got, want)
}
}
func TestNormalizeAPIPathKeepsAPIPrefixForNonAPIBaseURL(t *testing.T) {
got := normalizeAPIPath("https://www.gitlink.org.cn", "/api/v1/repos/Gitlink/gitlink-cli")
want := "/api/v1/repos/Gitlink/gitlink-cli"
if got != want {
t.Fatalf("normalizeAPIPath() = %q, want %q", got, want)
}
}

View File

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

View File

@ -0,0 +1,83 @@
package compare
import (
"encoding/base64"
"fmt"
"net/url"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "view",
Description: "Compare two branches, tags, or commits",
Flags: []common.Flag{
{Name: "head", Usage: "Source branch, tag, or commit", Required: true},
{Name: "base", Usage: "Target branch, tag, or commit", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
head, err := ctx.RequireArg("head")
if err != nil {
return err
}
base, err := ctx.RequireArg("base")
if err != nil {
return err
}
env, err := ctx.CallAPI("GET", comparePath(ctx, head, base), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "files",
Description: "List changed files between two refs",
Flags: []common.Flag{
{Name: "head", Usage: "Source branch, tag, or commit", Required: true},
{Name: "base", Usage: "Target branch, tag, or commit", Required: true},
{Name: "file", Short: "f", Usage: "Filter by file path"},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
head, err := ctx.RequireArg("head")
if err != nil {
return err
}
base, err := ctx.RequireArg("base")
if err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
if file := ctx.Arg("file"); file != "" {
q.Set("filepath", file)
}
env, err := ctx.CallAPIWithQuery("GET", "/v1"+comparePath(ctx, head, base)+"/files", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
}
}
func comparePath(ctx *common.RuntimeContext, head, base string) string {
return fmt.Sprintf("%s/compare/%s...%s", ctx.RepoPath(), encodeRef(head), encodeRef(base))
}
func encodeRef(ref string) string {
return base64.RawURLEncoding.EncodeToString([]byte(ref))
}

View File

@ -0,0 +1,107 @@
package compare
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestCompareViewEncodesRefs(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calledPath = r.URL.Path
if r.Method != "GET" || r.URL.Path != "/owner/repo/compare/ZmVhdHVyZS9hcGk...bWFzdGVy.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
writeJSON(t, w, map[string]interface{}{"commits_count": 1})
}))
defer server.Close()
err := runCompareShortcut(t, server, "view", map[string]string{
"head": "feature/api",
"base": "master",
})
if err != nil {
t.Fatalf("view shortcut failed: %v", err)
}
if calledPath == "" {
t.Fatal("server was not called")
}
}
func TestCompareFilesUsesV1EndpointWithFilters(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/compare/YnVnZml4...cmVsZWFzZS92MQ/files.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("filepath"); got != "cmd/api/api.go" {
t.Fatalf("filepath query = %q, want cmd/api/api.go", got)
}
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 != "50" {
t.Fatalf("limit query = %q, want 50", got)
}
writeJSON(t, w, map[string]interface{}{"files": []interface{}{}})
}))
defer server.Close()
err := runCompareShortcut(t, server, "files", map[string]string{
"head": "bugfix",
"base": "release/v1",
"file": "cmd/api/api.go",
"page": "2",
"limit": "50",
})
if err != nil {
t.Fatalf("files shortcut failed: %v", err)
}
}
func TestEncodeRefUsesRawURLBase64(t *testing.T) {
got := encodeRef("feature/api")
want := "ZmVhdHVyZS9hcGk"
if got != want {
t.Fatalf("encodeRef() = %q, want %q", got, want)
}
}
func runCompareShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findCompareShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
func findCompareShortcut(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 writeJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
t.Helper()
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(payload); err != nil {
t.Fatalf("failed to write response: %v", err)
}
}

View File

@ -0,0 +1,239 @@
package milestone
import (
"fmt"
"net/url"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func Shortcuts() []*common.Shortcut {
return []*common.Shortcut{
{
Name: "list",
Description: "List milestones",
Flags: []common.Flag{
{Name: "keyword", Short: "k", Usage: "Search keyword"},
{Name: "category", Short: "c", Usage: "Filter by category: opening, closed"},
{Name: "only-name", Usage: "Return only milestone id and name: true or false"},
{Name: "sort-by", Usage: "Sort field: created_on, updated_on, effective_date, issues_count, percent"},
{Name: "sort-direction", Usage: "Sort direction: asc or desc"},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
setQueryIfPresent(q, "keyword", ctx.Arg("keyword"))
setQueryIfPresent(q, "category", ctx.Arg("category"))
setQueryIfPresent(q, "only_name", ctx.Arg("only-name"))
setQueryIfPresent(q, "sort_by", ctx.Arg("sort-by"))
setQueryIfPresent(q, "sort_direction", ctx.Arg("sort-direction"))
env, err := ctx.CallAPIWithQuery("GET", milestonePath(ctx), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Create a milestone",
Flags: []common.Flag{
{Name: "name", Short: "n", Usage: "Milestone name", Required: true},
{Name: "description", Short: "d", Usage: "Milestone description", Required: true},
{Name: "due-date", Usage: "Due date in YYYY-MM-DD format", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
payload, err := milestonePayload(ctx, true)
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", milestonePath(ctx), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "view",
Description: "View milestone details and linked issues",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
{Name: "category", Short: "c", Usage: "Filter issues by category: all, opened, closed"},
{Name: "author-id", Usage: "Filter issues by author ID"},
{Name: "assigner-id", Usage: "Filter issues by assignee ID"},
{Name: "issue-tag-ids", Usage: "Comma-separated issue tag IDs"},
{Name: "sort-by", Usage: "Sort field: issues.created_on, issues.updated_on, issue_priorities.position"},
{Name: "sort-direction", Usage: "Sort direction: asc or desc"},
{Name: "page", Short: "p", Usage: "Page number", Default: "1"},
{Name: "limit", Short: "l", Usage: "Items per page", Default: "20"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
q := url.Values{}
q.Set("page", ctx.Arg("page"))
q.Set("limit", ctx.Arg("limit"))
setQueryIfPresent(q, "category", ctx.Arg("category"))
setQueryIfPresent(q, "author_id", ctx.Arg("author-id"))
setQueryIfPresent(q, "assigner_id", ctx.Arg("assigner-id"))
setQueryIfPresent(q, "issue_tag_ids", normalizeCSV(ctx.Arg("issue-tag-ids")))
setQueryIfPresent(q, "sort_by", ctx.Arg("sort-by"))
setQueryIfPresent(q, "sort_direction", ctx.Arg("sort-direction"))
env, err := ctx.CallAPIWithQuery("GET", milestoneItemPath(ctx, id), q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "update",
Description: "Update a milestone",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
{Name: "name", Short: "n", Usage: "Milestone name"},
{Name: "description", Short: "d", Usage: "Milestone description"},
{Name: "due-date", Usage: "Due date in YYYY-MM-DD format"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
payload, err := milestonePayload(ctx, false)
if err != nil {
return err
}
env, err := ctx.CallAPI("PATCH", milestoneItemPath(ctx, id), payload)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "delete",
Description: "Delete a milestone",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("DELETE", milestoneItemPath(ctx, id), nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
newStatusShortcut("close", "Close a milestone", "closed"),
newStatusShortcut("reopen", "Reopen a milestone", "open"),
}
}
func milestonePath(ctx *common.RuntimeContext) string {
return fmt.Sprintf("/v1/%s/%s/milestones", ctx.Owner, ctx.Repo)
}
func milestoneItemPath(ctx *common.RuntimeContext, id string) string {
return fmt.Sprintf("%s/%s", milestonePath(ctx), url.PathEscape(id))
}
func milestoneStatusPath(ctx *common.RuntimeContext, id string) string {
return fmt.Sprintf("%s/milestones/%s/update_status", ctx.RepoPath(), url.PathEscape(id))
}
func milestonePayload(ctx *common.RuntimeContext, requireAll bool) (map[string]interface{}, error) {
payload := map[string]interface{}{}
if name := ctx.Arg("name"); name != "" {
payload["name"] = name
}
if description := ctx.Arg("description"); description != "" {
payload["description"] = description
}
if dueDate := ctx.Arg("due-date"); dueDate != "" {
payload["effective_date"] = dueDate
}
if requireAll {
for _, name := range []string{"name", "description", "due-date"} {
if _, err := ctx.RequireArg(name); err != nil {
return nil, err
}
}
return payload, nil
}
if len(payload) == 0 {
return nil, fmt.Errorf("at least one of --name, --description, or --due-date is required")
}
return payload, nil
}
func newStatusShortcut(name, description, status string) *common.Shortcut {
return &common.Shortcut{
Name: name,
Description: description,
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "Milestone ID", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", milestoneStatusPath(ctx, id), map[string]interface{}{
"status": status,
})
if err != nil {
return err
}
return ctx.Output(env)
},
}
}
func setQueryIfPresent(q url.Values, name, value string) {
if value != "" {
q.Set(name, value)
}
}
func normalizeCSV(value string) string {
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 strings.Join(result, ",")
}

View File

@ -0,0 +1,207 @@
package milestone
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestMilestoneList(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/v1/owner/repo/milestones.json")
assertEqual(t, r.URL.Query().Get("category"), "opening")
assertEqual(t, r.URL.Query().Get("keyword"), "v1")
assertEqual(t, r.URL.Query().Get("page"), "2")
assertEqual(t, r.URL.Query().Get("limit"), "50")
writeJSON(t, w, map[string]interface{}{"total_count": 0, "milestones": []interface{}{}})
}))
defer server.Close()
err := runMilestoneShortcut(t, server, "list", map[string]string{
"category": "opening",
"keyword": "v1",
"page": "2",
"limit": "50",
})
if err != nil {
t.Fatalf("list shortcut failed: %v", err)
}
}
func TestMilestoneCreatePayload(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "POST", "/v1/owner/repo/milestones.json")
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
}))
defer server.Close()
err := runMilestoneShortcut(t, server, "create", map[string]string{
"name": "v1.0",
"description": "first release",
"due-date": "2026-07-01",
})
if err != nil {
t.Fatalf("create shortcut failed: %v", err)
}
assertEqual(t, payload["name"], "v1.0")
assertEqual(t, payload["description"], "first release")
assertEqual(t, payload["effective_date"], "2026-07-01")
}
func TestMilestoneViewWithIssueFilters(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "GET", "/v1/owner/repo/milestones/7.json")
assertEqual(t, r.URL.Query().Get("category"), "opened")
assertEqual(t, r.URL.Query().Get("author_id"), "11")
assertEqual(t, r.URL.Query().Get("assigner_id"), "22")
assertEqual(t, r.URL.Query().Get("issue_tag_ids"), "1,2,3")
writeJSON(t, w, map[string]interface{}{"milestone": map[string]interface{}{"id": 7}})
}))
defer server.Close()
err := runMilestoneShortcut(t, server, "view", map[string]string{
"id": "7",
"category": "opened",
"author-id": "11",
"assigner-id": "22",
"issue-tag-ids": "1, 2,3",
})
if err != nil {
t.Fatalf("view shortcut failed: %v", err)
}
}
func TestMilestoneUpdatePayload(t *testing.T) {
var payload map[string]interface{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "PATCH", "/v1/owner/repo/milestones/7.json")
payload = decodeJSON(t, r)
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
}))
defer server.Close()
err := runMilestoneShortcut(t, server, "update", map[string]string{
"id": "7",
"due-date": "2026-08-01",
})
if err != nil {
t.Fatalf("update shortcut failed: %v", err)
}
if _, ok := payload["name"]; ok {
t.Fatal("update payload should omit empty name")
}
assertEqual(t, payload["effective_date"], "2026-08-01")
}
func TestMilestoneUpdateRequiresChange(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
t.Fatalf("server should not be called when update payload is empty: %s %s", r.Method, r.URL.Path)
}))
defer server.Close()
err := runMilestoneShortcut(t, server, "update", map[string]string{"id": "7"})
if err == nil {
t.Fatal("expected update without fields to return an error")
}
}
func TestMilestoneDelete(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "DELETE", "/v1/owner/repo/milestones/7.json")
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
}))
defer server.Close()
if err := runMilestoneShortcut(t, server, "delete", map[string]string{"id": "7"}); err != nil {
t.Fatalf("delete shortcut failed: %v", err)
}
}
func TestMilestoneCloseAndReopen(t *testing.T) {
gotStatuses := []string{}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assertRequest(t, r, "POST", "/owner/repo/milestones/7/update_status.json")
payload := decodeJSON(t, r)
gotStatuses = append(gotStatuses, payload["status"].(string))
writeJSON(t, w, map[string]interface{}{"status": 0, "message": "success"})
}))
defer server.Close()
if err := runMilestoneShortcut(t, server, "close", map[string]string{"id": "7"}); err != nil {
t.Fatalf("close shortcut failed: %v", err)
}
if err := runMilestoneShortcut(t, server, "reopen", map[string]string{"id": "7"}); err != nil {
t.Fatalf("reopen shortcut failed: %v", err)
}
assertEqual(t, gotStatuses[0], "closed")
assertEqual(t, gotStatuses[1], "open")
}
func runMilestoneShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findMilestoneShortcut(t, name)
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
if ctx.Args == nil {
ctx.Args = map[string]string{}
}
return shortcut.Run(ctx)
}
func findMilestoneShortcut(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 assertRequest(t *testing.T, r *http.Request, method, path string) {
t.Helper()
if r.Method != method || r.URL.Path != path {
t.Fatalf("got request %s %s, want %s %s", r.Method, r.URL.Path, method, path)
}
}
func decodeJSON(t *testing.T, r *http.Request) map[string]interface{} {
t.Helper()
var payload map[string]interface{}
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
t.Fatalf("failed to decode request body: %v", err)
}
return payload
}
func writeJSON(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)
}
}
func assertEqual(t *testing.T, got interface{}, want interface{}) {
t.Helper()
if got != want {
t.Fatalf("got %v (%T), want %v (%T)", got, got, want, want)
}
}

View File

@ -131,6 +131,27 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "reopen",
Description: "Reopen a closed pull request",
Flags: []common.Flag{
{Name: "id", Short: "i", Usage: "PR number", Required: true},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
id, err := ctx.RequireArg("id")
if err != nil {
return err
}
env, err := ctx.CallAPI("POST", prV1Path(ctx, id)+"/reopen", nil)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "files",
Description: "List changed files in a pull request",

View File

@ -263,6 +263,29 @@ func TestPRReviewRejectsInvalidStatus(t *testing.T) {
}
}
func TestPRReopenUsesV1Endpoint(t *testing.T) {
var calledPath string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" || r.URL.Path != "/v1/owner/repo/pulls/13/reopen.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
calledPath = r.URL.Path
writeJSON(t, w, map[string]interface{}{
"status": 0,
"message": "success",
})
}))
defer server.Close()
err := runPRShortcut(t, server, "reopen", map[string]string{
"id": "13",
})
if err != nil {
t.Fatalf("reopen shortcut failed: %v", err)
}
assertEqual(t, calledPath, "/v1/owner/repo/pulls/13/reopen.json")
}
func runPRShortcut(t *testing.T, server *httptest.Server, name string, args map[string]string) error {
t.Helper()
shortcut := findPRShortcut(t, name)

View File

@ -6,8 +6,10 @@ import (
"github.com/gitlink-org/gitlink-cli/shortcuts/branch"
"github.com/gitlink-org/gitlink-cli/shortcuts/ci"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
"github.com/gitlink-org/gitlink-cli/shortcuts/compare"
"github.com/gitlink-org/gitlink-cli/shortcuts/issue"
"github.com/gitlink-org/gitlink-cli/shortcuts/member"
"github.com/gitlink-org/gitlink-cli/shortcuts/milestone"
"github.com/gitlink-org/gitlink-cli/shortcuts/org"
"github.com/gitlink-org/gitlink-cli/shortcuts/pr"
"github.com/gitlink-org/gitlink-cli/shortcuts/release"
@ -15,36 +17,43 @@ 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(),
"member": member.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(),
"member": member.Shortcuts(),
"milestone": milestone.Shortcuts(),
"pr": pr.Shortcuts(),
"release": release.Shortcuts(),
"branch": branch.Shortcuts(),
"org": org.Shortcuts(),
"user": user.Shortcuts(),
"search": search.Shortcuts(),
"ci": ci.Shortcuts(),
"compare": compare.Shortcuts(),
"webhook": webhook.Shortcuts(),
"workflow": workflow.Shortcuts(),
}
descriptions := map[string]string{
"repo": "Repository operations",
"issue": "Issue operations",
"member": "Repository member 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",
"member": "Repository member operations",
"milestone": "Milestone operations",
"pr": "Pull request operations",
"release": "Release operations",
"branch": "Branch operations",
"org": "Organization operations",
"user": "User operations",
"search": "Search operations",
"ci": "CI/CD operations",
"compare": "Compare branches, tags, or commits",
"webhook": "Webhook operations",
"workflow": "AI agent workflow analysis",
}
for name, shortcuts := range groups {

View File

@ -52,6 +52,31 @@ func Shortcuts() []*common.Shortcut {
return ctx.Output(env)
},
},
{
Name: "readme",
Description: "Show repository README content",
Flags: []common.Flag{
{Name: "ref", Usage: "Branch, tag, or commit SHA"},
{Name: "path", Usage: "README directory path"},
},
Run: func(ctx *common.RuntimeContext) error {
if err := ctx.ResolveOwnerRepo(); err != nil {
return err
}
q := url.Values{}
if ref := ctx.Arg("ref"); ref != "" {
q.Set("ref", ref)
}
if path := ctx.Arg("path"); path != "" {
q.Set("filepath", path)
}
env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/readme", q)
if err != nil {
return err
}
return ctx.Output(env)
},
},
{
Name: "create",
Description: "Create a new repository",

View File

@ -0,0 +1,63 @@
package repo
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/gitlink-org/gitlink-cli/internal/client"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
func TestRepoReadmeUsesRepositoryReadmeEndpoint(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/owner/repo/readme.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("ref"); got != "main" {
t.Fatalf("ref query = %q, want main", got)
}
if got := r.URL.Query().Get("filepath"); got != "docs" {
t.Fatalf("filepath query = %q, want docs", got)
}
w.Header().Set("Content-Type", "application/json")
if err := json.NewEncoder(w).Encode(map[string]interface{}{
"type": "file",
"name": "README.md",
"content": "# docs\n",
}); err != nil {
t.Fatalf("write response: %v", err)
}
}))
defer server.Close()
err := runRepoShortcut(server, "readme", map[string]string{
"ref": "main",
"path": "docs",
})
if err != nil {
t.Fatalf("readme shortcut failed: %v", err)
}
}
func runRepoShortcut(server *httptest.Server, name string, args map[string]string) error {
for _, shortcut := range Shortcuts() {
if shortcut.Name != name {
continue
}
ctx := &common.RuntimeContext{
Client: &client.Client{
HTTP: server.Client(),
BaseURL: server.URL,
},
Owner: "owner",
Repo: "repo",
Format: "json",
Args: args,
}
return shortcut.Run(ctx)
}
return fmt.Errorf("shortcut %q not found", name)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,422 @@
package workflow
import (
"bytes"
"encoding/json"
"fmt"
"io"
"sort"
"strings"
"text/tabwriter"
)
func renderTriageReport(w io.Writer, report TriageReport, format string) error {
switch normalizeFormat(format) {
case "json":
return writeJSON(w, report)
case "markdown":
return writeTriageMarkdown(w, report)
case "table":
return writeTriageTable(w, report)
default:
return fmt.Errorf("unsupported workflow output format %q", format)
}
}
func renderHealthResult(w io.Writer, result HealthResult, format string) error {
switch normalizeFormat(format) {
case "json":
return writeJSON(w, result)
case "markdown":
return writeHealthMarkdown(w, result)
case "table":
return writeHealthTable(w, result)
default:
return fmt.Errorf("unsupported workflow output format %q", format)
}
}
func RenderPRSummary(result PRSummaryResult, format string, lang string) (string, error) {
var buf bytes.Buffer
switch normalizeFormat(format) {
case "json":
if err := writeJSON(&buf, result); err != nil {
return "", err
}
case "markdown":
if err := writePRSummaryMarkdown(&buf, result, lang); err != nil {
return "", err
}
case "table":
if err := writePRSummaryTable(&buf, result); err != nil {
return "", err
}
default:
return "", fmt.Errorf("unsupported workflow output format %q", format)
}
return buf.String(), nil
}
func RenderRepoReport(result RepoReportResult, format string, lang string) (string, error) {
var buf bytes.Buffer
switch normalizeFormat(format) {
case "json":
if err := writeJSON(&buf, result); err != nil {
return "", err
}
case "markdown":
if err := writeRepoReportMarkdown(&buf, result, lang); err != nil {
return "", err
}
case "table":
if err := writeRepoReportTable(&buf, result, lang); err != nil {
return "", err
}
default:
return "", fmt.Errorf("unsupported workflow output format %q", format)
}
return buf.String(), nil
}
func normalizeFormat(format string) string {
format = strings.ToLower(strings.TrimSpace(format))
if format == "" {
return "json"
}
return format
}
func writeJSON(w io.Writer, data interface{}) error {
encoded, err := json.MarshalIndent(data, "", " ")
if err != nil {
return err
}
_, err = fmt.Fprintln(w, string(encoded))
return err
}
func writeTriageTable(w io.Writer, report TriageReport) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(tw, "NUMBER\tTYPE\tPRIORITY\tCONFIDENCE\tMISSING\tACTION"); err != nil {
return err
}
for _, result := range report.Results {
missing := "-"
if len(result.MissingInformation) > 0 {
missing = strings.Join(result.MissingInformation, ",")
}
if _, err := fmt.Fprintf(tw, "%d\t%s\t%s\t%d\t%s\t%s\n",
result.Issue.Number,
result.DetectedType,
result.Priority,
result.Confidence,
missing,
result.RecommendedAction,
); err != nil {
return err
}
}
return tw.Flush()
}
func writePRSummaryTable(w io.Writer, result PRSummaryResult) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintln(tw, "PR\tTITLE\tTYPE\tRISK\tFILES\tCOMMITS\tSOURCE"); err != nil {
return err
}
if _, err := fmt.Fprintf(tw, "#%d\t%s\t%s\t%s\t%d\t%d\t%s\n",
result.Number,
truncateTableText(result.Title, 72),
result.ChangeType,
result.RiskLevel,
result.ChangedFilesCount,
result.CommitCount,
result.Source,
); err != nil {
return err
}
return tw.Flush()
}
func writeHealthTable(w io.Writer, result HealthResult) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
if _, err := fmt.Fprintf(tw, "REPOSITORY\tSCORE\tRISK\n%s\t%d\t%s\n\n", result.Repository, result.HealthScore, result.RiskLevel); err != nil {
return err
}
if _, err := fmt.Fprintln(tw, "METRIC\tSTATUS\tSCORE\tMAX\tREASON"); err != nil {
return err
}
for _, metric := range result.Metrics {
if _, err := fmt.Fprintf(tw, "%s\t%s\t%d\t%d\t%s\n", metric.Name, metric.Status, metric.Score, metric.MaxScore, metric.Reason); err != nil {
return err
}
}
return tw.Flush()
}
func writeRepoReportTable(w io.Writer, result RepoReportResult, lang string) error {
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
healthScore := repoReportText(lang, "not_available")
if result.Health != nil {
healthScore = fmt.Sprintf("%d", result.Health.HealthScore)
}
topRecommendation := repoReportText(lang, "not_available")
if len(result.Recommendations) > 0 {
topRecommendation = truncateTableText(result.Recommendations[0], 96)
}
if _, err := fmt.Fprintln(tw, "REPOSITORY\tREPORT_SCORE\tRISK\tHEALTH_SCORE\tISSUES\tHIGH_RISK_ISSUES\tPRS\tHIGH_RISK_PRS\tTOP_RECOMMENDATION"); err != nil {
return err
}
if _, err := fmt.Fprintf(tw, "%s\t%d\t%s\t%s\t%d\t%d\t%d\t%d\t%s\n",
result.Repository,
result.ReportScore,
result.RiskLevel,
healthScore,
result.IssueSummary.Total,
result.IssueSummary.HighRisk,
result.PRSummary.Total,
result.PRSummary.HighRisk,
topRecommendation,
); err != nil {
return err
}
return tw.Flush()
}
func writeTriageMarkdown(w io.Writer, report TriageReport) error {
if _, err := fmt.Fprintf(w, "# Issue Triage Report\n\nRepository: `%s`\n\n", report.Repository); err != nil {
return err
}
if _, err := fmt.Fprintln(w, "| Issue | Type | Priority | Confidence | Action | Missing Information |"); err != nil {
return err
}
if _, err := fmt.Fprintln(w, "| --- | --- | --- | ---: | --- | --- |"); err != nil {
return err
}
for _, result := range report.Results {
missing := "-"
if len(result.MissingInformation) > 0 {
missing = strings.Join(result.MissingInformation, ", ")
}
if _, err := fmt.Fprintf(w, "| #%d | %s | %s | %d | %s | %s |\n",
result.Issue.Number,
result.DetectedType,
result.Priority,
result.Confidence,
result.RecommendedAction,
missing,
); err != nil {
return err
}
}
return nil
}
func writeRepoReportMarkdown(w io.Writer, result RepoReportResult, lang string) error {
lang = normalizeLang(lang)
if _, err := fmt.Fprintf(w, "# %s\n\n", repoReportText(lang, "title")); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "## %s\n\n", repoReportText(lang, "overview")); err != nil {
return err
}
healthScore := repoReportText(lang, "not_available")
if result.Health != nil {
healthScore = fmt.Sprintf("%d", result.Health.HealthScore)
}
overview := []string{
fmt.Sprintf("- Repository: `%s`", result.Repository),
fmt.Sprintf("- Report score: `%d`", result.ReportScore),
fmt.Sprintf("- Risk level: `%s`", result.RiskLevel),
fmt.Sprintf("- Health score: `%s`", healthScore),
fmt.Sprintf("- Issues analyzed: `%d`", result.IssueSummary.Total),
fmt.Sprintf("- Pull requests analyzed: `%d`", result.PRSummary.Total),
fmt.Sprintf("- Source: `%s`", result.Source),
}
for _, line := range overview {
if _, err := fmt.Fprintln(w, line); err != nil {
return err
}
}
if _, err := fmt.Fprintf(w, "\n## %s\n\n", repoReportText(lang, "health")); err != nil {
return err
}
if result.Health == nil {
if _, err := fmt.Fprintf(w, "- %s\n", repoReportText(lang, "not_available")); err != nil {
return err
}
} else {
if _, err := fmt.Fprintf(w, "- Score: `%d`\n- Risk: `%s`\n", result.Health.HealthScore, result.Health.RiskLevel); err != nil {
return err
}
}
if _, err := fmt.Fprintf(w, "\n## %s\n\n", repoReportText(lang, "issues")); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "- Total: `%d`\n- High risk: `%d`\n- Missing information: `%d`\n", result.IssueSummary.Total, result.IssueSummary.HighRisk, result.IssueSummary.MissingInfo); err != nil {
return err
}
writeCountMapMarkdown(w, "By type", result.IssueSummary.ByType)
writeCountMapMarkdown(w, "By priority", result.IssueSummary.ByPriority)
if _, err := fmt.Fprintf(w, "\n## %s\n\n", repoReportText(lang, "prs")); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "- Total: `%d`\n- High risk: `%d`\n", result.PRSummary.Total, result.PRSummary.HighRisk); err != nil {
return err
}
writeCountMapMarkdown(w, "By type", result.PRSummary.ByType)
writeCountMapMarkdown(w, "By risk", result.PRSummary.ByRisk)
if len(result.PRSummary.ReviewFocus) > 0 {
if _, err := fmt.Fprintln(w, "- Review focus:"); err != nil {
return err
}
for _, focus := range result.PRSummary.ReviewFocus {
if _, err := fmt.Fprintf(w, " - %s\n", focus); err != nil {
return err
}
}
}
if err := writeRepoReportMarkdownList(w, repoReportText(lang, "recommendations"), result.Recommendations, repoReportText(lang, "not_available")); err != nil {
return err
}
return writeRepoReportMarkdownList(w, repoReportText(lang, "reasoning"), result.Reasoning, repoReportText(lang, "not_available"))
}
func writeCountMapMarkdown(w io.Writer, title string, values map[string]int) error {
if len(values) == 0 {
return nil
}
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
if _, err := fmt.Fprintf(w, "- %s:\n", title); err != nil {
return err
}
for _, key := range keys {
if _, err := fmt.Fprintf(w, " - `%s`: `%d`\n", key, values[key]); err != nil {
return err
}
}
return nil
}
func writeRepoReportMarkdownList(w io.Writer, title string, values []string, fallback string) error {
if _, err := fmt.Fprintf(w, "\n## %s\n\n", title); err != nil {
return err
}
if len(values) == 0 {
_, err := fmt.Fprintf(w, "- %s\n", fallback)
return err
}
for _, value := range values {
if _, err := fmt.Fprintf(w, "- %s\n", value); err != nil {
return err
}
}
return nil
}
func writePRSummaryMarkdown(w io.Writer, result PRSummaryResult, lang string) error {
lang = normalizeLang(lang)
if _, err := fmt.Fprintf(w, "# %s\n\n", message(lang, "pr_summary_title")); err != nil {
return err
}
if _, err := fmt.Fprintf(w, "## %s\n\n", message(lang, "pr_summary_overview")); err != nil {
return err
}
lines := []string{
fmt.Sprintf("- Repository: `%s`", result.Repository),
fmt.Sprintf("- PR: `#%d` %s", result.Number, result.Title),
fmt.Sprintf("- Author: `%s`", result.Author),
fmt.Sprintf("- State: `%s`", result.State),
fmt.Sprintf("- Base branch: `%s`", result.BaseBranch),
fmt.Sprintf("- Head branch: `%s`", result.HeadBranch),
fmt.Sprintf("- Change type: `%s`", result.ChangeType),
fmt.Sprintf("- Risk level: `%s`", result.RiskLevel),
fmt.Sprintf("- Changed files: `%d`", result.ChangedFilesCount),
fmt.Sprintf("- Commits: `%d`", result.CommitCount),
fmt.Sprintf("- Source: `%s`", result.Source),
}
for _, line := range lines {
if _, err := fmt.Fprintln(w, line); err != nil {
return err
}
}
if err := writePRSummaryMarkdownList(w, message(lang, "pr_summary_review_focus"), result.ReviewFocus, message(lang, "pr_summary_no_focus")); err != nil {
return err
}
if err := writePRSummaryMarkdownList(w, message(lang, "pr_summary_test_suggestions"), result.TestSuggestions, message(lang, "pr_summary_no_suggestions")); err != nil {
return err
}
if err := writePRSummaryMarkdownList(w, message(lang, "pr_summary_merge_checklist"), result.MergeChecklist, message(lang, "pr_summary_no_checklist")); err != nil {
return err
}
return writePRSummaryMarkdownList(w, message(lang, "pr_summary_reasoning"), result.Reasoning, message(lang, "pr_summary_no_reasoning"))
}
func writePRSummaryMarkdownList(w io.Writer, title string, values []string, fallback string) error {
if _, err := fmt.Fprintf(w, "\n## %s\n\n", title); err != nil {
return err
}
if len(values) == 0 {
_, err := fmt.Fprintf(w, "- %s\n", fallback)
return err
}
for _, value := range values {
if _, err := fmt.Fprintf(w, "- %s\n", value); err != nil {
return err
}
}
return nil
}
func writeHealthMarkdown(w io.Writer, result HealthResult) error {
if _, err := fmt.Fprintf(w, "# Repository Health Report\n\nRepository: `%s`\n\nHealth score: **%d**\n\nRisk level: **%s**\n\n", result.Repository, result.HealthScore, result.RiskLevel); err != nil {
return err
}
if _, err := fmt.Fprintln(w, "| Metric | Status | Score | Max | Reason |"); err != nil {
return err
}
if _, err := fmt.Fprintln(w, "| --- | --- | ---: | ---: | --- |"); err != nil {
return err
}
for _, metric := range result.Metrics {
if _, err := fmt.Fprintf(w, "| %s | %s | %d | %d | %s |\n", metric.Name, metric.Status, metric.Score, metric.MaxScore, metric.Reason); err != nil {
return err
}
}
if len(result.Recommendations) == 0 {
return nil
}
if _, err := fmt.Fprintln(w, "\n## Recommendations"); err != nil {
return err
}
if _, err := fmt.Fprintln(w); err != nil {
return err
}
for _, recommendation := range result.Recommendations {
if _, err := fmt.Fprintf(w, "- %s\n", recommendation); err != nil {
return err
}
}
return nil
}
func truncateTableText(value string, max int) string {
value = strings.Join(strings.Fields(value), " ")
runes := []rune(value)
if max <= 0 || len(runes) <= max {
return value
}
if max <= 3 {
return string(runes[:max])
}
return string(runes[:max-3]) + "..."
}

View File

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

View File

@ -0,0 +1,404 @@
package workflow
import (
"encoding/json"
"fmt"
"os"
"sort"
"strings"
"github.com/gitlink-org/gitlink-cli/cmd/cmdutil"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
type RepoReportInput struct {
Repository string `json:"repository"`
Health *HealthInput `json:"health,omitempty"`
Issues []IssueInput `json:"issues,omitempty"`
PullRequests []PRSummaryInput `json:"pull_requests,omitempty"`
Source string `json:"source"`
}
type RepoReportResult struct {
Repository string `json:"repository"`
Health *HealthResult `json:"health,omitempty"`
IssueSummary RepoIssueSummary `json:"issue_summary"`
PRSummary RepoPRSummary `json:"pr_summary"`
Recommendations []string `json:"recommendations"`
RiskLevel string `json:"risk_level"`
ReportScore int `json:"report_score"`
Sections []string `json:"sections"`
Reasoning []string `json:"reasoning"`
Source string `json:"source"`
}
type RepoIssueSummary struct {
Total int `json:"total"`
ByType map[string]int `json:"by_type"`
ByPriority map[string]int `json:"by_priority"`
HighRisk int `json:"high_risk"`
MissingInfo int `json:"missing_info"`
}
type RepoPRSummary struct {
Total int `json:"total"`
ByType map[string]int `json:"by_type"`
ByRisk map[string]int `json:"by_risk"`
HighRisk int `json:"high_risk"`
ReviewFocus []string `json:"review_focus"`
}
func newRepoReportShortcut() *common.Shortcut {
return &common.Shortcut{
Name: "repo-report",
Description: "Generate a read-only repository workflow report",
Flags: []common.Flag{
{Name: "from", Usage: "Read repository report input from a JSON file"},
{Name: "issue-limit", Usage: "Maximum issues to fetch and analyze", Default: "20"},
{Name: "pr-limit", Usage: "Maximum pull requests to fetch and summarize", Default: "10"},
{Name: "stale-days", Usage: "Days before an issue or PR is considered stale", Default: "30"},
{Name: "include-issues", Usage: "Include issue triage summary", Bool: true, Default: "true"},
{Name: "include-prs", Usage: "Include pull request summary", Bool: true, Default: "true"},
{Name: "include-health", Usage: "Include repository health summary", Bool: true, Default: "true"},
{Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN},
},
Run: runRepoReport,
}
}
func runRepoReport(ctx *common.RuntimeContext) error {
lang := normalizeLang(ctx.Arg("lang"))
input, notes, err := collectRepoReportInput(ctx)
if err != nil {
return err
}
result := AnalyzeRepoReport(input, lang)
for _, note := range notes {
if note.Metric == "" && note.Note == "" {
continue
}
result.Reasoning = append(result.Reasoning, fmt.Sprintf("%s: %s", note.Metric, note.Note))
}
format := ctx.Format
if strings.TrimSpace(cmdutil.Format) == "" {
format = "markdown"
}
rendered, err := RenderRepoReport(result, format, lang)
if err != nil {
return err
}
_, err = fmt.Fprint(os.Stdout, rendered)
return err
}
func collectRepoReportInput(ctx *common.RuntimeContext) (RepoReportInput, []ScoringNote, error) {
if path := strings.TrimSpace(ctx.Arg("from")); path != "" {
input, err := readRepoReportInput(path)
if err != nil {
return RepoReportInput{}, nil, err
}
if strings.TrimSpace(input.Source) == "" {
input.Source = "local-json"
}
return input, nil, nil
}
issueLimit, err := parseIntArg(ctx.Arg("issue-limit"), 20, "issue-limit")
if err != nil {
return RepoReportInput{}, nil, err
}
prLimit, err := parseIntArg(ctx.Arg("pr-limit"), 10, "pr-limit")
if err != nil {
return RepoReportInput{}, nil, err
}
staleDays, err := parseIntArg(ctx.Arg("stale-days"), 30, "stale-days")
if err != nil {
return RepoReportInput{}, nil, err
}
return FetchRepoReportInput(ctx, RepoReportFetchOptions{
IssueLimit: issueLimit,
PRLimit: prLimit,
StaleDays: staleDays,
IncludeIssues: parseBoolArg(ctx.Arg("include-issues")),
IncludePRs: parseBoolArg(ctx.Arg("include-prs")),
IncludeHealth: parseBoolArg(ctx.Arg("include-health")),
})
}
func readRepoReportInput(path string) (RepoReportInput, error) {
data, err := os.ReadFile(path)
if err != nil {
return RepoReportInput{}, fmt.Errorf("read repo report input: %w", err)
}
var input RepoReportInput
if err := json.Unmarshal(data, &input); err != nil {
return RepoReportInput{}, fmt.Errorf("parse repo report input: %w", err)
}
if strings.TrimSpace(input.Repository) == "" && input.Health == nil && len(input.Issues) == 0 && len(input.PullRequests) == 0 {
return RepoReportInput{}, fmt.Errorf("parse repo report input: expected RepoReportInput root object")
}
return input, nil
}
func AnalyzeRepoReport(input RepoReportInput, lang string) RepoReportResult {
lang = normalizeLang(lang)
source := strings.TrimSpace(input.Source)
if source == "" {
source = "local"
}
repository := strings.TrimSpace(input.Repository)
sections := []string{}
reasoning := []string{}
var healthResult *HealthResult
baseScore := 70
if input.Health != nil {
health := *input.Health
if repository == "" {
repository = health.Repository
}
scored := ScoreHealth(health, lang)
healthResult = &scored
baseScore = scored.HealthScore
sections = append(sections, "health")
reasoning = append(reasoning, fmt.Sprintf("health score: %d", scored.HealthScore))
} else {
reasoning = append(reasoning, repoReportText(lang, "health_missing"))
}
issueSummary, issueResults := summarizeRepoIssues(input.Issues, lang)
if len(input.Issues) > 0 {
sections = append(sections, "issues")
reasoning = append(reasoning, fmt.Sprintf("issues analyzed: %d", len(input.Issues)))
}
prSummary, prResults := summarizeRepoPRs(input.PullRequests, lang)
if len(input.PullRequests) > 0 {
sections = append(sections, "pull_requests")
reasoning = append(reasoning, fmt.Sprintf("pull requests analyzed: %d", len(input.PullRequests)))
}
reportScore := computeRepoReportScore(baseScore, input.Health != nil, issueSummary, prSummary)
risk := riskLevel(reportScore)
if hasSecurityP0(issueResults) || hasCriticalPR(prResults) {
risk = "critical"
reportScore = minInt(reportScore, 39)
reasoning = append(reasoning, repoReportText(lang, "critical_signal"))
}
recommendations := buildRepoReportRecommendations(lang, healthResult, issueSummary, prSummary, risk)
if repository == "" {
repository = "local"
}
return RepoReportResult{
Repository: repository,
Health: healthResult,
IssueSummary: issueSummary,
PRSummary: prSummary,
Recommendations: recommendations,
RiskLevel: risk,
ReportScore: reportScore,
Sections: sections,
Reasoning: uniqueStrings(reasoning),
Source: source,
}
}
func summarizeRepoIssues(issues []IssueInput, lang string) (RepoIssueSummary, []TriageResult) {
summary := RepoIssueSummary{
ByType: map[string]int{},
ByPriority: map[string]int{},
}
results := make([]TriageResult, 0, len(issues))
for _, issue := range issues {
result := AnalyzeIssue(issue, lang)
results = append(results, result)
summary.Total++
summary.ByType[result.DetectedType]++
summary.ByPriority[result.Priority]++
if result.Priority == PriorityP0 || result.Priority == PriorityP1 || containsString(result.RiskFlags, RiskSecuritySensitive) {
summary.HighRisk++
}
if len(result.MissingInformation) > 0 {
summary.MissingInfo++
}
}
return summary, results
}
func summarizeRepoPRs(inputs []PRSummaryInput, lang string) (RepoPRSummary, []PRSummaryResult) {
summary := RepoPRSummary{
ByType: map[string]int{},
ByRisk: map[string]int{},
}
results := make([]PRSummaryResult, 0, len(inputs))
focus := []string{}
for _, input := range inputs {
result := AnalyzePRSummary(input, lang)
results = append(results, result)
summary.Total++
summary.ByType[result.ChangeType]++
summary.ByRisk[result.RiskLevel]++
if result.RiskLevel == PRRiskHigh || result.RiskLevel == PRRiskCritical {
summary.HighRisk++
}
focus = append(focus, result.ReviewFocus...)
}
summary.ReviewFocus = uniqueStrings(focus)
sort.Strings(summary.ReviewFocus)
return summary, results
}
func computeRepoReportScore(baseScore int, hasHealth bool, issueSummary RepoIssueSummary, prSummary RepoPRSummary) int {
score := baseScore
if !hasHealth {
score = 70
}
score -= issueSummary.HighRisk * 8
score -= issueSummary.MissingInfo * 3
score -= prSummary.HighRisk * 8
score -= prSummary.ByRisk[PRRiskCritical] * 10
if issueSummary.Total == 0 && prSummary.Total == 0 && !hasHealth {
score = 50
}
return clampInt(score, 0, 100)
}
func hasSecurityP0(results []TriageResult) bool {
for _, result := range results {
if result.Priority == PriorityP0 || result.DetectedType == IssueTypeSecurity || containsString(result.RiskFlags, RiskSecuritySensitive) {
return true
}
}
return false
}
func hasCriticalPR(results []PRSummaryResult) bool {
for _, result := range results {
if result.RiskLevel == PRRiskCritical {
return true
}
}
return false
}
func buildRepoReportRecommendations(lang string, health *HealthResult, issueSummary RepoIssueSummary, prSummary RepoPRSummary, risk string) []string {
recommendations := []string{}
if issueSummary.ByPriority[PriorityP0] > 0 || issueSummary.ByType[IssueTypeSecurity] > 0 {
recommendations = append(recommendations, repoReportText(lang, "rec_security_issues"))
}
if issueSummary.MissingInfo > 0 {
recommendations = append(recommendations, repoReportText(lang, "rec_missing_info"))
}
if prSummary.HighRisk > 0 {
recommendations = append(recommendations, repoReportText(lang, "rec_high_risk_prs"))
}
if health != nil {
recommendations = append(recommendations, health.Recommendations...)
}
if health != nil && health.HealthScore < 65 {
recommendations = append(recommendations, repoReportText(lang, "rec_health"))
}
if risk == "low" && len(recommendations) == 0 {
recommendations = append(recommendations, repoReportText(lang, "rec_maintain_report"))
}
if len(recommendations) == 0 {
recommendations = append(recommendations, repoReportText(lang, "rec_review_report"))
}
recommendations = uniqueStrings(recommendations)
if len(recommendations) > 8 {
recommendations = recommendations[:8]
}
return recommendations
}
func repoReportText(lang, key string) string {
zh := normalizeLang(lang) == langZH
switch key {
case "title":
if zh {
return "仓库工作流报告"
}
return "Repository Workflow Report"
case "overview":
if zh {
return "总览"
}
return "Overview"
case "health":
if zh {
return "健康度摘要"
}
return "Health Summary"
case "issues":
if zh {
return "Issue 分诊摘要"
}
return "Issue Triage Summary"
case "prs":
if zh {
return "PR 审阅摘要"
}
return "PR Review Summary"
case "recommendations":
if zh {
return "建议操作"
}
return "Recommendations"
case "reasoning":
if zh {
return "判断依据"
}
return "Reasoning"
case "not_available":
if zh {
return "不可用"
}
return "Not available"
case "health_missing":
if zh {
return "未提供健康度输入。"
}
return "health input not provided"
case "critical_signal":
if zh {
return "发现安全 P0 Issue 或 critical PR整体风险上调。"
}
return "security P0 issue or critical PR raised overall risk"
case "rec_security_issues":
if zh {
return "优先处理安全相关或 P0 Issue。"
}
return "Prioritize security-related or P0 issues."
case "rec_missing_info":
if zh {
return "要求补充复现步骤、版本、命令输出或日志。"
}
return "Request missing reproduction steps, version, command output, or logs."
case "rec_high_risk_prs":
if zh {
return "优先审阅 high / critical 风险 PR。"
}
return "Prioritize high or critical risk pull requests."
case "rec_health":
if zh {
return "根据健康度建议降低仓库治理风险。"
}
return "Use the health recommendations to reduce repository governance risk."
case "rec_maintain_report":
if zh {
return "保持当前维护节奏,并定期复查仓库工作流报告。"
}
return "Maintain the current workflow and review the repository report regularly."
case "rec_review_report":
if zh {
return "复查报告中的风险项并安排下一步维护动作。"
}
return "Review report risks and schedule the next maintenance actions."
default:
return key
}
}

View File

@ -0,0 +1,138 @@
package workflow
import (
"fmt"
"net/url"
"strings"
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
)
type RepoReportFetchOptions struct {
Owner string
Repo string
IssueLimit int
PRLimit int
StaleDays int
IncludeIssues bool
IncludePRs bool
IncludeHealth bool
}
func FetchRepoReportInput(ctx *common.RuntimeContext, opts RepoReportFetchOptions) (RepoReportInput, []ScoringNote, error) {
owner, repo, err := resolveFetchRepo(ctx, opts.Owner, opts.Repo)
if err != nil {
return RepoReportInput{}, nil, fmt.Errorf("workflow +repo-report remote mode requires --owner and --repo or a Git remote: %w", err)
}
if opts.IssueLimit <= 0 {
opts.IssueLimit = 20
}
if opts.PRLimit <= 0 {
opts.PRLimit = 10
}
if opts.StaleDays <= 0 {
opts.StaleDays = 30
}
input := RepoReportInput{
Repository: fmt.Sprintf("%s/%s", owner, repo),
Source: "remote-read-only-fetch",
}
notes := []ScoringNote{}
successes := 0
if opts.IncludeHealth {
health, healthNotes, err := FetchHealthInput(ctx, HealthFetchOptions{
Owner: owner,
Repo: repo,
StaleDays: opts.StaleDays,
IncludeCI: true,
IncludeRelease: true,
IncludeDocs: true,
})
notes = append(notes, healthNotes...)
if err != nil {
notes = append(notes, ScoringNote{Metric: "repo_report_health", Note: fmt.Sprintf("health fetch failed: %v", err)})
} else {
input.Health = &health
successes++
}
}
if opts.IncludeIssues {
issues, err := FetchIssuesForTriage(ctx, TriageFetchOptions{
Owner: owner,
Repo: repo,
State: "open",
Limit: opts.IssueLimit,
Page: 1,
})
if err != nil {
notes = append(notes, ScoringNote{Metric: "repo_report_issues", Note: fmt.Sprintf("issue fetch failed: %v", err)})
} else {
input.Issues = issues
successes++
}
}
if opts.IncludePRs {
prs, err := fetchPRListForReport(ctx, owner, repo, opts.PRLimit)
if err != nil {
notes = append(notes, ScoringNote{Metric: "repo_report_prs", Note: fmt.Sprintf("pull request list fetch failed: %v", err)})
} else {
input.PullRequests = prs
successes++
if len(prs) > 0 {
notes = append(notes, ScoringNote{
Metric: "repo_report_prs",
Note: "PR report uses list metadata only; changed files and commits require workflow +pr-summary with a PR number.",
})
}
}
}
if successes == 0 {
if len(notes) == 0 {
notes = append(notes, ScoringNote{Metric: "repo_report", Note: "no report sections were enabled or fetched"})
}
return RepoReportInput{}, uniqueScoringNotes(notes), fmt.Errorf("fetch repo report: all enabled sections failed")
}
return input, uniqueScoringNotes(notes), nil
}
func fetchPRListForReport(ctx *common.RuntimeContext, owner, repo string, limit int) ([]PRSummaryInput, error) {
if limit <= 0 {
limit = 10
}
query := url.Values{}
query.Set("state", "open")
query.Set("page", "1")
query.Set("limit", fmt.Sprintf("%d", limit))
env, err := ctx.CallAPIWithQuery("GET", workflowRepoPath(owner, repo)+"/pulls", query)
if err != nil {
return nil, err
}
items := apiList(env.Data)
inputs := make([]PRSummaryInput, 0, len(items))
for _, raw := range items {
item, ok := raw.(map[string]interface{})
if !ok {
continue
}
input, ok := normalizePRSummaryItem(item)
if !ok {
continue
}
input.Repository = fmt.Sprintf("%s/%s", owner, repo)
input.Source = "remote-read-only-fetch:list-metadata"
if strings.TrimSpace(input.State) == "" {
input.State = "open"
}
inputs = append(inputs, input)
if len(inputs) >= limit {
break
}
}
return inputs, nil
}

View File

@ -0,0 +1,183 @@
package workflow
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestFetchRepoReportInputPartialPRUnavailable(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo.json":
writeWorkflowJSON(t, w, map[string]interface{}{
"updated_at": "2026-05-20T00:00:00Z",
"has_readme": true,
"has_license": true,
"has_contributing": true,
})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{
{"number": 1, "title": "Install failed", "body": "error on install", "updated_at": "2026-05-20T00:00:00Z"},
}})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json":
http.Error(w, "pulls unavailable", http.StatusServiceUnavailable)
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json":
writeWorkflowJSON(t, w, map[string]interface{}{"releases": []map[string]interface{}{{"created_at": "2026-05-01T00:00:00Z"}}})
case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json":
writeWorkflowJSON(t, w, map[string]interface{}{"builds": []map[string]interface{}{{"status": "success"}}})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
IssueLimit: 10,
PRLimit: 10,
StaleDays: 30,
IncludeHealth: true,
IncludeIssues: true,
IncludePRs: true,
})
if err != nil {
t.Fatalf("FetchRepoReportInput returned error: %v", err)
}
if input.Health == nil || len(input.Issues) != 1 {
t.Fatalf("input = %+v, want health and issues", input)
}
if len(input.PullRequests) != 0 {
t.Fatalf("len(PullRequests) = %d, want 0", len(input.PullRequests))
}
if !hasNote(notes, "repo_report_prs") {
t.Fatalf("notes = %+v, want repo_report_prs note", notes)
}
}
func TestFetchRepoReportInputHealthFailureIssuesSuccess(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch {
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo.json":
http.Error(w, "repo unavailable", http.StatusInternalServerError)
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{
{"number": 1, "title": "README typo", "body": "docs typo"},
}})
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json":
writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{}})
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json":
writeWorkflowJSON(t, w, map[string]interface{}{"releases": []map[string]interface{}{}})
case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json":
writeWorkflowJSON(t, w, map[string]interface{}{"builds": []map[string]interface{}{}})
default:
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
}))
defer server.Close()
input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
IssueLimit: 10,
IncludeHealth: true,
IncludeIssues: true,
IncludePRs: false,
})
if err != nil {
t.Fatalf("FetchRepoReportInput returned error: %v", err)
}
if input.Health == nil || len(input.Issues) != 1 {
t.Fatalf("input = %+v, want degraded health and one issue", input)
}
if !hasNote(notes, "repository") {
t.Fatalf("notes = %+v, want repository note", notes)
}
}
func TestFetchRepoReportInputAllSectionsFail(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "unavailable", http.StatusInternalServerError)
}))
defer server.Close()
_, _, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
IncludeHealth: false,
IncludeIssues: true,
IncludePRs: true,
})
if err == nil {
t.Fatal("FetchRepoReportInput returned nil error when all sections failed")
}
}
func TestFetchRepoReportInputRespectsIssueLimitAndIncludeFlags(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("limit"); got != "1" {
t.Fatalf("issue limit = %q, want 1", got)
}
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{
{"number": 1, "title": "First bug", "body": "error"},
{"number": 2, "title": "Second bug", "body": "error"},
}})
}))
defer server.Close()
input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
IssueLimit: 1,
IncludeHealth: false,
IncludeIssues: true,
IncludePRs: false,
})
if err != nil {
t.Fatalf("FetchRepoReportInput returned error: %v", err)
}
if len(notes) != 0 {
t.Fatalf("notes = %+v, want empty", notes)
}
if len(input.Issues) != 1 {
t.Fatalf("len(Issues) = %d, want 1", len(input.Issues))
}
}
func TestFetchRepoReportInputPRListMetadata(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/pulls.json" {
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
}
if got := r.URL.Query().Get("limit"); got != "1" {
t.Fatalf("PR limit = %q, want 1", got)
}
writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{
{"number": 1, "title": "feat: add report", "user": map[string]interface{}{"login": "alice"}},
{"number": 2, "title": "docs: update guide"},
}})
}))
defer server.Close()
input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
PRLimit: 1,
IncludeHealth: false,
IncludeIssues: false,
IncludePRs: true,
})
if err != nil {
t.Fatalf("FetchRepoReportInput returned error: %v", err)
}
if len(input.PullRequests) != 1 {
t.Fatalf("len(PullRequests) = %d, want 1", len(input.PullRequests))
}
if !hasNote(notes, "repo_report_prs") || !strings.Contains(notes[0].Note, "list metadata") {
t.Fatalf("notes = %+v, want list metadata note", notes)
}
}
func hasNote(notes []ScoringNote, metric string) bool {
for _, note := range notes {
if note.Metric == metric {
return true
}
}
return false
}

View File

@ -0,0 +1,195 @@
package workflow
import (
"encoding/json"
"strings"
"testing"
)
func TestAnalyzeRepoReportAggregatesHealthIssuesAndPRs(t *testing.T) {
input := sampleRepoReportInput()
result := AnalyzeRepoReport(input, "en")
if result.ReportScore < 0 || result.ReportScore > 100 {
t.Fatalf("ReportScore = %d, want 0..100", result.ReportScore)
}
if result.IssueSummary.Total != 3 {
t.Fatalf("IssueSummary.Total = %d, want 3", result.IssueSummary.Total)
}
if result.IssueSummary.ByType[IssueTypeBug] == 0 {
t.Fatalf("IssueSummary.ByType = %+v, want bug count", result.IssueSummary.ByType)
}
if result.PRSummary.Total != 3 {
t.Fatalf("PRSummary.Total = %d, want 3", result.PRSummary.Total)
}
if result.PRSummary.ByRisk[PRRiskHigh] == 0 {
t.Fatalf("PRSummary.ByRisk = %+v, want high risk count", result.PRSummary.ByRisk)
}
if len(result.Recommendations) == 0 {
t.Fatal("Recommendations empty")
}
}
func TestAnalyzeRepoReportWithSecurityIssueRaisesRisk(t *testing.T) {
input := RepoReportInput{
Repository: "owner/repo",
Issues: []IssueInput{{
Number: 1,
Title: "Token leaked in logs",
Body: "A secret token leaked from command output.",
Labels: []string{"security"},
}},
}
result := AnalyzeRepoReport(input, "en")
if result.RiskLevel != "critical" {
t.Fatalf("RiskLevel = %q, want critical", result.RiskLevel)
}
}
func TestAnalyzeRepoReportPartialInput(t *testing.T) {
input := RepoReportInput{
Repository: "owner/repo",
Health: &HealthInput{
Repository: "owner/repo",
OpenIssues: 1,
OpenPRs: 0,
RecentActivityKnown: true,
RecentActivityDays: 2,
ReleaseKnown: true,
HasRecentRelease: true,
HasReadme: true,
HasLicense: true,
HasContributing: true,
AgentReadinessKnown: true,
AgentReadinessScore: 9,
},
}
result := AnalyzeRepoReport(input, "en")
if result.Health == nil {
t.Fatal("Health result nil")
}
if len(result.Recommendations) == 0 {
t.Fatal("Recommendations empty")
}
}
func TestAnalyzeRepoReportChinese(t *testing.T) {
result := AnalyzeRepoReport(sampleRepoReportInput(), "zh-CN")
if len(result.Recommendations) == 0 {
t.Fatal("Recommendations empty")
}
rendered, err := RenderRepoReport(result, "markdown", "zh-CN")
if err != nil {
t.Fatalf("RenderRepoReport returned error: %v", err)
}
if !strings.Contains(rendered, "仓库工作流报告") {
t.Fatalf("markdown output missing Chinese title:\n%s", rendered)
}
}
func TestRenderRepoReportJSON(t *testing.T) {
rendered, err := RenderRepoReport(AnalyzeRepoReport(sampleRepoReportInput(), "en"), "json", "en")
if err != nil {
t.Fatalf("RenderRepoReport returned error: %v", err)
}
var result RepoReportResult
if err := json.Unmarshal([]byte(rendered), &result); err != nil {
t.Fatalf("json.Unmarshal returned error: %v\noutput=%s", err, rendered)
}
if result.Repository != "owner/repo" {
t.Fatalf("Repository = %q, want owner/repo", result.Repository)
}
}
func TestRenderRepoReportMarkdown(t *testing.T) {
result := AnalyzeRepoReport(sampleRepoReportInput(), "en")
rendered, err := RenderRepoReport(result, "markdown", "en")
if err != nil {
t.Fatalf("RenderRepoReport returned error: %v", err)
}
for _, want := range []string{"Repository Workflow Report", "Report score", "Recommendations"} {
if !strings.Contains(rendered, want) {
t.Fatalf("markdown output missing %q:\n%s", want, rendered)
}
}
}
func TestRenderRepoReportTable(t *testing.T) {
rendered, err := RenderRepoReport(AnalyzeRepoReport(sampleRepoReportInput(), "en"), "table", "en")
if err != nil {
t.Fatalf("RenderRepoReport returned error: %v", err)
}
if !strings.Contains(rendered, "REPORT_SCORE") || !strings.Contains(rendered, "owner/repo") {
t.Fatalf("table output = %q, want report score and repository", rendered)
}
}
func TestRenderRepoReportUnknownFormat(t *testing.T) {
_, err := RenderRepoReport(AnalyzeRepoReport(sampleRepoReportInput(), "en"), "xml", "en")
if err == nil {
t.Fatal("RenderRepoReport returned nil error for unknown format")
}
}
func TestReadRepoReportInput(t *testing.T) {
input, err := readRepoReportInput("testdata/repo_report.json")
if err != nil {
t.Fatalf("readRepoReportInput returned error: %v", err)
}
if input.Repository == "" || len(input.Issues) == 0 || len(input.PullRequests) == 0 {
t.Fatalf("input = %+v, want populated fixture", input)
}
}
func sampleRepoReportInput() RepoReportInput {
return RepoReportInput{
Repository: "owner/repo",
Health: &HealthInput{
Repository: "owner/repo",
OpenIssues: 5,
OpenPRs: 3,
StaleIssues: 1,
StalePRs: 1,
RecentActivityKnown: true,
RecentActivityDays: 2,
ReleaseKnown: true,
HasRecentRelease: true,
HasReadme: true,
HasLicense: true,
HasContributing: true,
AgentReadinessKnown: true,
AgentReadinessScore: 9,
},
Issues: []IssueInput{
{Number: 1, Title: "Install failed", Body: "error on install", Labels: []string{"bug"}},
{Number: 2, Title: "README typo", Body: "docs typo", Labels: []string{"docs"}},
{Number: 3, Title: "Crash on login", Body: "panic", Labels: []string{"bug"}},
},
PullRequests: []PRSummaryInput{
{
Repository: "owner/repo",
Number: 1,
Title: "docs: update guide",
ChangedFiles: []PRChangedFile{
{Filename: "README.md", Additions: 10, Deletions: 1, Changes: 11},
},
},
{
Repository: "owner/repo",
Number: 2,
Title: "feat: add workflow command",
ChangedFiles: []PRChangedFile{
{Filename: "shortcuts/workflow/workflow.go", Additions: 40, Deletions: 4, Changes: 44},
},
},
{
Repository: "owner/repo",
Number: 3,
Title: "fix: normalize API client errors",
ChangedFiles: []PRChangedFile{
{Filename: "internal/client/client.go", Additions: 30, Deletions: 10, Changes: 40},
},
},
},
Source: "local-json",
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -0,0 +1,134 @@
{
"repository": "Gitlink/gitlink-cli",
"health": {
"repository": "Gitlink/gitlink-cli",
"open_issues": 8,
"open_prs": 3,
"stale_issues": 2,
"stale_prs": 1,
"recent_activity_known": true,
"recent_activity_days": 3,
"release_known": true,
"has_recent_release": true,
"ci_known": false,
"ci_passing": false,
"has_readme": true,
"has_license": true,
"has_contributing": true,
"agent_readiness_known": true,
"agent_readiness_score": 9
},
"issues": [
{
"number": 1,
"title": "Install failed on Windows",
"body": "go install failed with error",
"state": "open",
"author": "alice",
"labels": ["bug"]
},
{
"number": 2,
"title": "README typo in installation guide",
"body": "The docs example has a typo.",
"state": "open",
"author": "bob",
"labels": ["docs"]
},
{
"number": 3,
"title": "Command crashes on login",
"body": "panic on login",
"state": "open",
"author": "carol",
"labels": ["bug"]
}
],
"pull_requests": [
{
"repository": "Gitlink/gitlink-cli",
"number": 11,
"title": "docs: update workflow examples",
"author": "dana",
"state": "open",
"base_branch": "master",
"head_branch": "docs/workflow",
"changed_files": [
{
"filename": "docs/workflow-agent-design.md",
"status": "modified",
"additions": 20,
"deletions": 4,
"changes": 24
}
],
"commits": [
{
"sha": "1111111",
"message": "docs: update workflow examples",
"author": "dana"
}
],
"additions": 20,
"deletions": 4,
"source": "local-json"
},
{
"repository": "Gitlink/gitlink-cli",
"number": 12,
"title": "feat: add workflow command",
"author": "erin",
"state": "open",
"base_branch": "master",
"head_branch": "feature/workflow",
"changed_files": [
{
"filename": "shortcuts/workflow/workflow.go",
"status": "modified",
"additions": 70,
"deletions": 8,
"changes": 78
}
],
"commits": [
{
"sha": "2222222",
"message": "feat: add workflow command",
"author": "erin"
}
],
"additions": 70,
"deletions": 8,
"source": "local-json"
},
{
"repository": "Gitlink/gitlink-cli",
"number": 13,
"title": "fix: normalize API client errors",
"author": "frank",
"state": "open",
"base_branch": "master",
"head_branch": "fix/client-errors",
"changed_files": [
{
"filename": "internal/client/client.go",
"status": "modified",
"additions": 30,
"deletions": 12,
"changes": 42
}
],
"commits": [
{
"sha": "3333333",
"message": "fix: normalize API client errors",
"author": "frank"
}
],
"additions": 30,
"deletions": 12,
"source": "local-json"
}
],
"source": "local-json"
}

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@ -0,0 +1,40 @@
---
name: gitlink-compare
version: 1.0.0
description: "Compare GitLink branches, tags, or commits and inspect changed files."
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli compare --help"
---
# gitlink-compare
Read [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) first for authentication, global flags, and API behavior.
## Shortcuts
| Shortcut | Description |
|----------|-------------|
| `compare +view` | Compare two branches, tags, or commits |
| `compare +files` | List changed files between two refs |
## Examples
```bash
# Compare two refs and include commit/diff summary
gitlink-cli compare +view --owner Gitlink --repo forgeplus --head feature/api --base master
# List changed files
gitlink-cli compare +files --owner Gitlink --repo forgeplus --head feature/api --base master
# Filter a single file in the file diff endpoint
gitlink-cli compare +files --owner Gitlink --repo forgeplus \
--head feature/api --base master --file cmd/api/api.go
```
## Notes
- Pass normal branch, tag, or commit names. The CLI base64-url encodes refs before calling GitLink compare endpoints.
- `compare +view` calls `/api/{owner}/{repo}/compare/{head}...{base}`.
- `compare +files` calls `/api/v1/{owner}/{repo}/compare/{head}...{base}/files`.

View File

@ -0,0 +1,66 @@
---
name: gitlink-milestone
version: 1.0.0
description: "Milestone management: list, create, view, update, delete, close, and reopen GitLink project milestones."
metadata:
requires:
bins: ["gitlink-cli"]
cliHelp: "gitlink-cli milestone --help"
---
# gitlink-milestone
**CRITICAL**: Read [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) before starting. It covers authentication, permissions, global flags, and GitLink API behavior.
**CRITICAL**: Confirm user intent before running write or destructive operations such as `+create`, `+update`, `+delete`, `+close`, or `+reopen`.
**CRITICAL**: Use `gitlink-cli` for GitLink resources. Do not use GitHub-only tools such as `gh`.
## Shortcuts
| Shortcut | Description | Operation |
|----------|-------------|-----------|
| `milestone +list` | List repository milestones | Read |
| `milestone +create` | Create a milestone | Write |
| `milestone +view` | View milestone details and linked issues | Read |
| `milestone +update` | Update milestone fields | Write |
| `milestone +delete` | Delete a milestone | Destructive |
| `milestone +close` | Close a milestone | Write |
| `milestone +reopen` | Reopen a closed milestone | Write |
## Examples
```bash
# List open milestones
gitlink-cli milestone +list --owner Gitlink --repo forgeplus --category opening
# Create a milestone
gitlink-cli milestone +create --owner Gitlink --repo forgeplus \
--name v1.0 --description "First stable release" --due-date 2026-07-01
# View milestone details and linked opened issues
gitlink-cli milestone +view --owner Gitlink --repo forgeplus --id 7 --category opened
# Update the due date
gitlink-cli milestone +update --owner Gitlink --repo forgeplus --id 7 --due-date 2026-08-01
# Close and reopen
gitlink-cli milestone +close --owner Gitlink --repo forgeplus --id 7
gitlink-cli milestone +reopen --owner Gitlink --repo forgeplus --id 7
```
## Parameters
| Command | Key parameters |
|---------|----------------|
| `+list` | `--keyword`, `--category opening,closed`, `--only-name`, `--sort-by`, `--sort-direction`, `--page`, `--limit` |
| `+create` | `--name`, `--description`, `--due-date` |
| `+view` | `--id`, `--category all,opened,closed`, `--author-id`, `--assigner-id`, `--issue-tag-ids`, `--page`, `--limit` |
| `+update` | `--id` plus at least one of `--name`, `--description`, `--due-date` |
| `+delete` | `--id` |
| `+close` / `+reopen` | `--id` |
## API Notes
- Milestone list/create/view/update/delete use `/api/v1/{owner}/{repo}/milestones`.
- Status updates use `/api/{owner}/{repo}/milestones/{id}/update_status`.
- `--due-date` maps to the GitLink API field `effective_date`.
- `--issue-tag-ids` accepts comma-separated IDs and normalizes whitespace before calling the API.

View File

@ -0,0 +1,30 @@
# pr +reopen
Reopen a closed Pull Request.
## Usage
```bash
gitlink-cli pr +reopen --id 3
gitlink-cli pr +reopen -i 3
```
## Parameters
| Parameter | Required | Description |
|-----------|----------|-------------|
| `--id` / `-i` | Yes | PR number from the web URL `/pulls/N` |
| `--owner` | No | Repository owner, auto-detected from git remote when omitted |
| `--repo` | No | Repository name, auto-detected from git remote when omitted |
| `--format` | No | Output format: `json`, `table`, or `yaml` |
## API
```text
POST /v1/{owner}/{repo}/pulls/{number}/reopen
```
## Notes
- Use `pr +view -i <id>` first to confirm the PR is currently closed.
- This command uses the PR number shown in the web URL, not the internal database ID.

View File

@ -101,6 +101,49 @@ gitlink-cli pr +list --state merged --format json
gitlink-cli api GET /:owner/:repo/activity --format json
```
## Workflow: PR Summary (Read-only)
Use `workflow +pr-summary` when a maintainer or Agent needs a structured PR review summary, review focus, test suggestions, or a markdown report that can be copied into a PR discussion.
```bash
# Read-only GitLink fetch mode
gitlink-cli workflow +pr-summary --owner Gitlink --repo gitlink-cli --number 1 --format markdown
# Local JSON input mode for Agent pipelines
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format json
```
Rules:
- Prefer `--format json` when another Agent consumes the output.
- Prefer `--format markdown` when a human maintainer needs a report.
- This command is read-only: it does not comment, approve, reject, merge, label, or close pull requests.
- Do not use LLM APIs for this workflow; it is rule-based and explainable.
## Workflow: Repo Report (Read-only)
Use `workflow +repo-report` when a maintainer or Agent needs a single repository workflow report
that aggregates health, issue triage, and PR review signals.
适用于需要生成仓库治理报告、比赛材料、维护者汇总或 Agent 综合分析的场景。
```bash
# Maintainer report
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
# Agent-readable report
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format json
# Local fixture mode
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
```
Rules:
- Prefer `--format json` when another Agent consumes the output.
- Prefer `--format markdown` for maintainer reports, competition materials, and review handoff.
- Treat remote mode as read-only aggregation only.
- Do not perform remote write operations.
- Do not comment, label, close, approve, reject, or merge from this workflow.
- PR details in remote report mode may be partial; use `workflow +pr-summary --number <n>` for a focused PR review.
## 最佳实践
- 所有工作流命令使用 `--format json` 以便解析输出