Add workflow agent triage and health commands
This commit is contained in:
parent
a46e06b78a
commit
5673a109a4
50
README.md
50
README.md
|
|
@ -290,6 +290,56 @@ gitlink-cli search +repos -k "machine learning"
|
|||
gitlink-cli search +users -k "zhangsan"
|
||||
```
|
||||
|
||||
### Workflow Agent Commands
|
||||
|
||||
`workflow` provides rule-based repository analysis for maintainers and AI Agents. It currently supports:
|
||||
|
||||
- `workflow +triage`
|
||||
- `workflow +health`
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
# Triage with local parameters
|
||||
gitlink-cli workflow +triage --title "Install failed on Windows" --body "go install failed with error" --format table
|
||||
|
||||
# Triage with JSON output
|
||||
gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --format json
|
||||
|
||||
# Triage with Chinese markdown output
|
||||
gitlink-cli workflow +triage --title "安装失败,无法登录" --body "运行命令时报错" --lang zh-CN --format markdown
|
||||
|
||||
# Triage from a local JSON file
|
||||
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format json
|
||||
|
||||
# Triage by read-only GitLink fetch
|
||||
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table
|
||||
|
||||
# Health for a healthy repository
|
||||
gitlink-cli workflow +health --repository Gitlink/gitlink-cli --open-issues 3 --open-prs 1 --has-readme --has-license --has-contributing --agent-readiness-known --agent-readiness-score 9 --format table
|
||||
|
||||
# Health for a risky repository
|
||||
gitlink-cli workflow +health --repository demo/repo --open-issues 60 --stale-issues 25 --open-prs 12 --stale-prs 6 --recent-activity-known --recent-activity-days 120 --release-known=false --format json
|
||||
|
||||
# Health with Chinese markdown output
|
||||
gitlink-cli workflow +health --repository Gitlink/gitlink-cli --open-issues 3 --open-prs 1 --has-readme --has-license --has-contributing --lang zh-CN --format markdown
|
||||
|
||||
# Health by read-only GitLink fetch
|
||||
gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --format table
|
||||
```
|
||||
|
||||
Output formats:
|
||||
|
||||
- `json` for scripts and AI Agents
|
||||
- `table` for terminal review
|
||||
- `markdown` for Issue comments, PR comments, release notes, and competition write-ups
|
||||
|
||||
Safety:
|
||||
|
||||
- Current workflow commands use local analysis by default and can also read GitLink data in read-only fetch mode.
|
||||
- They do not modify remote GitLink data.
|
||||
- They do not depend on LLM APIs.
|
||||
|
||||
### Raw API
|
||||
|
||||
For endpoints not covered by shortcuts, use the Raw API directly:
|
||||
|
|
|
|||
|
|
@ -0,0 +1,156 @@
|
|||
# GitLink CLI Workflow Agent Work Continuation
|
||||
|
||||
## Current Goal
|
||||
|
||||
Implement the first PR slice for the GitLink CLI Agent Workflow enhancement suite for `track1_2026GitLinkCli`.
|
||||
|
||||
First PR scope:
|
||||
- `gitlink-cli workflow +triage`
|
||||
- `gitlink-cli workflow +health`
|
||||
|
||||
Current implemented slice:
|
||||
- Pure workflow DTOs and rule engines.
|
||||
- Local command layer for `workflow +triage` and `workflow +health`.
|
||||
- Read-only GitLink API fetch layer for workflow triage and health.
|
||||
- Fetch layer hardened and documented.
|
||||
- No remote write behavior.
|
||||
|
||||
## Current Branch
|
||||
|
||||
- Branch: `master`
|
||||
- Remote: `origin https://gitlink.org.cn/Gitlink/gitlink-cli.git`
|
||||
- Repository path: `E:\GitLinkCLI-Competition\gitlink-cli`
|
||||
- Local Go toolchain: `E:\GitLinkCLI-Competition\tools\go1.26.1\go`
|
||||
|
||||
## Completed Content
|
||||
|
||||
- Confirmed current workspace repository is `Gitlink/gitlink-cli`.
|
||||
- Confirmed `workflow` command group did not previously exist in Go command registration.
|
||||
- Confirmed `skills/gitlink-workflow/SKILL.md` exists as workflow guidance only.
|
||||
- Read core command, shortcut, output, client, config, and test patterns.
|
||||
- Created first workflow agent design draft at `docs/workflow-agent-design.md`.
|
||||
- Workspace moved out of `C:\Users\zyc\OneDrive\Desktop\4c文档` to `E:\GitLinkCLI-Competition\gitlink-cli`.
|
||||
- Added pure workflow DTOs under `shortcuts/workflow/types.go`.
|
||||
- Added pure issue triage rules under `shortcuts/workflow/triage_rules.go`.
|
||||
- Added pure repository health scoring under `shortcuts/workflow/health_score.go`.
|
||||
- Added lightweight language messages under `shortcuts/workflow/messages.go`.
|
||||
- Added unit tests for triage, health, messages, renderers, and local workflow command helpers.
|
||||
- Installed Go 1.26.1 locally for Windows amd64 after verifying the machine is Intel x64.
|
||||
- Added `workflow.Shortcuts()` with `+triage` and `+health`.
|
||||
- Registered the `workflow` shortcut group in `shortcuts/register.go`.
|
||||
- Added workflow-local JSON, table, and markdown renderers.
|
||||
- Added local input support:
|
||||
- `workflow +triage`: single issue flags or `--from` JSON file.
|
||||
- `workflow +health`: explicit metric flags or `--from` JSON file.
|
||||
- Verified both commands run locally without GitLink API access.
|
||||
- Added read-only workflow API fetch helpers and mock tests.
|
||||
- Added command-level fetch-path smoke tests for `runTriage` and `runHealth`.
|
||||
- Added README workflow command usage section.
|
||||
- Added `docs/workflow-agent-test-report.md`.
|
||||
- Added `docs/competition-solution.md`.
|
||||
- Added `docs/pr-draft.md`.
|
||||
- Added workflow testdata fixtures under `shortcuts/workflow/testdata/`.
|
||||
- Expanded fetch-layer boundary coverage for empty responses, label/author normalization, error-in-body handling, alternative activity timestamps, release shapes, and CI unavailability.
|
||||
|
||||
## Current Go Toolchain Status
|
||||
|
||||
- `where go`: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\go.exe`
|
||||
- `where gofmt`: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\gofmt.exe`
|
||||
- `go version`: `go version go1.26.1 windows/amd64`
|
||||
- Temporary PATH change: applied only in shell commands.
|
||||
- GOPROXY used for tests: `https://goproxy.cn,direct`
|
||||
- Go toolchain status: available.
|
||||
- gofmt status: available.
|
||||
|
||||
## Current Test Status
|
||||
|
||||
- `gofmt` on `shortcuts/workflow/*.go` and `shortcuts/register.go`: passed.
|
||||
- `go test ./shortcuts/workflow`: passed.
|
||||
- `go test ./...`: passed.
|
||||
- Smoke command passed:
|
||||
- `go run . --format json workflow +triage --title "Token leaked in logs" --body "secret token leaked" --number 1 --labels security`
|
||||
- Smoke command passed:
|
||||
- `go run . --format table workflow +health --repository owner/repo --open-issues 2 --open-prs 1 --recent-activity-known --recent-activity-days 3 --release-known --has-recent-release --has-readme --has-license --has-contributing --agent-readiness-known --agent-readiness-score 9`
|
||||
- Remote read-only smoke command passed:
|
||||
- `go run . --format table workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5`
|
||||
- Remote read-only smoke command passed:
|
||||
- `go run . --format markdown --lang zh-CN workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30`
|
||||
- Documentation examples now cover both local-parameter and local-JSON-file usage.
|
||||
- `docs/pr-draft.md`: present and current.
|
||||
|
||||
## Recent Changed Files
|
||||
|
||||
- `WORK_CONTINUATION.md`
|
||||
- `docs/workflow-agent-design.md`
|
||||
- `docs/competition-solution.md`
|
||||
- `docs/pr-draft.md`
|
||||
- `shortcuts/register.go`
|
||||
- `shortcuts/workflow/types.go`
|
||||
- `shortcuts/workflow/messages.go`
|
||||
- `shortcuts/workflow/triage_rules.go`
|
||||
- `shortcuts/workflow/health_score.go`
|
||||
- `shortcuts/workflow/render.go`
|
||||
- `shortcuts/workflow/workflow.go`
|
||||
- `shortcuts/workflow/api_types.go`
|
||||
- `shortcuts/workflow/triage_fetch.go`
|
||||
- `shortcuts/workflow/health_fetch.go`
|
||||
- `shortcuts/workflow/triage_rules_test.go`
|
||||
- `shortcuts/workflow/health_score_test.go`
|
||||
- `shortcuts/workflow/messages_test.go`
|
||||
- `shortcuts/workflow/workflow_test.go`
|
||||
- `shortcuts/workflow/triage_fetch_test.go`
|
||||
- `shortcuts/workflow/health_fetch_test.go`
|
||||
- `shortcuts/workflow/testdata/issue_bug.json`
|
||||
- `shortcuts/workflow/testdata/issue_security.json`
|
||||
- `shortcuts/workflow/testdata/health_good.json`
|
||||
- `shortcuts/workflow/testdata/health_risky.json`
|
||||
- `README.md`
|
||||
- `docs/workflow-agent-test-report.md`
|
||||
|
||||
## Uncompleted Content
|
||||
|
||||
- `workflow +pr-summary` is not implemented.
|
||||
- `workflow +release-notes` is not implemented.
|
||||
- `workflow +stale` is not implemented.
|
||||
- Remote write operations remain intentionally deferred.
|
||||
|
||||
## Known Issues
|
||||
|
||||
- `codex status` is unavailable from the non-interactive shell: `stdin is not a terminal`.
|
||||
- Quota reset time unavailable.
|
||||
- Workflow commands support both local input and read-only GitLink fetch mode.
|
||||
- Existing global help says default format is table, but shortcut runtime defaults to json when `--format` is omitted.
|
||||
- Existing output formatter supports `json`, `yaml`, and `table`; workflow-local renderers currently support `json`, `table`, and `markdown`.
|
||||
- Workflow Skill examples use some older flag names such as `--id`, while current issue commands use `--number` for issues and PR commands use `--id`.
|
||||
- API response shapes vary across endpoints and should be normalized behind workflow-specific fetch/parsing helpers.
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- No new dependency was added.
|
||||
- `workflow` is a new shortcut group under `shortcuts/workflow`.
|
||||
- JSON schemas use explicit workflow DTOs.
|
||||
- Workflow renderers are local to the workflow package; global formatter was not changed.
|
||||
- All remote-write behavior remains out of scope.
|
||||
- `+triage` supports local single-issue flags, JSON file input, and read-only GitLink fetch mode.
|
||||
- `+health` supports local metric flags, JSON file input, and read-only GitLink fetch mode.
|
||||
- Treat unavailable future API metrics as `unknown` and include them in `scoring_notes`.
|
||||
- Workflow-local renderers keep json/table/markdown output isolated from the global formatter.
|
||||
|
||||
## Next Minimal Executable Task
|
||||
|
||||
Design workflow +pr-summary: read-only PR metadata, changed files, and commits; implement with httptest mock first; do not add LLM or write operations.
|
||||
|
||||
## How To Continue After Interruption
|
||||
|
||||
1. Open `WORK_CONTINUATION.md`.
|
||||
2. Run `git status --short --branch`.
|
||||
3. Use temporary PATH: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin`.
|
||||
4. Set temporary GOPROXY if dependency download fails: `https://goproxy.cn,direct`.
|
||||
5. Run `go test ./shortcuts/workflow`.
|
||||
6. Run `go test ./...`.
|
||||
7. Start `workflow +pr-summary` design only after confirming the existing workflow tests still pass.
|
||||
8. Keep all new workflow commands read-only by default.
|
||||
|
||||
## Recommended Next Codex Instruction
|
||||
|
||||
Design workflow +pr-summary with read-only PR metadata, changed files, and commits; implement with httptest mock first; do not add LLM or write operations.
|
||||
|
|
@ -0,0 +1,121 @@
|
|||
# GitLink CLI Agent Workflow Enhancement Suite
|
||||
|
||||
## 1. Background
|
||||
|
||||
GitLink CLI serves both human maintainers and AI Agents. The competition focuses on intelligent open-source contribution workflows, where structured analysis, stable output, and safe automation matter more than raw command count.
|
||||
|
||||
## 2. Problem
|
||||
|
||||
Open-source maintenance often suffers from:
|
||||
|
||||
- Issue backlog and delayed triage
|
||||
- High PR review cost
|
||||
- Repetitive release note preparation
|
||||
- Lack of structured repository health evaluation
|
||||
- AI Agents needing stable, machine-readable output
|
||||
|
||||
## 3. Solution
|
||||
|
||||
This project extends GitLink CLI with the **GitLink CLI Agent Workflow Enhancement Suite**.
|
||||
|
||||
Implemented now:
|
||||
|
||||
- `workflow +triage`
|
||||
- `workflow +health`
|
||||
- read-only GitLink fetch layer for workflow triage and health
|
||||
- expanded fetch boundary tests for empty responses, label and author normalization, error-in-body handling, alternative activity timestamps, release shapes, and CI unavailability
|
||||
- local-first analysis with no LLM dependency
|
||||
- stable Agent-facing JSON / table / markdown output
|
||||
|
||||
Planned next:
|
||||
|
||||
- `workflow +pr-summary`
|
||||
- `workflow +release-notes`
|
||||
- `workflow +stale`
|
||||
|
||||
## 4. Technical Route
|
||||
|
||||
- Go + Cobra + existing shortcut architecture
|
||||
- rule-based analysis
|
||||
- stable DTOs
|
||||
- `json` / `table` / `markdown` renderers
|
||||
- `en` / `zh-CN` message mapping
|
||||
- no LLM dependency
|
||||
- local-first, dry-run-safe workflow design
|
||||
|
||||
## 5. Implemented Features
|
||||
|
||||
### workflow +triage
|
||||
|
||||
- issue type detection
|
||||
- priority scoring
|
||||
- confidence scoring
|
||||
- missing information detection
|
||||
- risk flags
|
||||
- recommended action
|
||||
- suggested comment
|
||||
- reasoning and matched rules
|
||||
|
||||
### workflow +health
|
||||
|
||||
- health score
|
||||
- risk level
|
||||
- metrics
|
||||
- scoring notes
|
||||
- recommendations
|
||||
- unknown metric tolerance
|
||||
|
||||
## 6. Innovation Points
|
||||
|
||||
- Agent-native structured output
|
||||
- rule-based intelligence without external LLM dependency
|
||||
- explainable workflow decisions
|
||||
- safety-first local analysis
|
||||
- bilingual command output
|
||||
- extensible workflow command design
|
||||
- competition-friendly incremental PR path
|
||||
|
||||
## 7. Testing and Verification
|
||||
|
||||
- Unit tests cover triage, health scoring, messages, rendering, and command helpers.
|
||||
- Fetch-layer tests cover issue normalization and repository health probing with `httptest`.
|
||||
- Boundary tests cover empty responses, label and author normalization, error-in-body handling, alternative activity timestamps, release response shapes, and CI unavailability.
|
||||
- Local command examples were executed successfully.
|
||||
- Full repository testing passed in the current environment.
|
||||
- Automated tests use `httptest` and do not depend on real remote API availability.
|
||||
|
||||
## 8. Demonstration Plan
|
||||
|
||||
### Official repository
|
||||
|
||||
Use `Gitlink/gitlink-cli` as the reference repository:
|
||||
|
||||
1. `workflow +triage` with English table output
|
||||
2. `workflow +triage` with security JSON output
|
||||
3. `workflow +triage` with Chinese markdown output
|
||||
4. `workflow +health` with table output
|
||||
5. `workflow +health` with risky JSON output
|
||||
6. Explain how agents consume stable JSON
|
||||
|
||||
### Self-built test repository
|
||||
|
||||
Use a small demo repository to show:
|
||||
|
||||
- bug triage
|
||||
- security triage
|
||||
- docs triage
|
||||
- healthy repo score
|
||||
- risky repo score
|
||||
|
||||
## 9. Roadmap
|
||||
|
||||
- Phase 1: local workflow prototype, completed
|
||||
- Phase 2: API fetch and normalization, completed
|
||||
- Phase 3: `pr-summary`, `release-notes`, `stale`
|
||||
|
||||
## 10. PR Plan
|
||||
|
||||
- PR 1: workflow rule engine and local commands
|
||||
- PR 2: documentation and tests
|
||||
- PR 3: API fetch layer
|
||||
- PR 4: `pr-summary` / `release-notes`
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
# PR Draft: Add workflow agent commands for issue triage and repository health analysis
|
||||
|
||||
## Summary
|
||||
|
||||
This PR adds `workflow +triage` and `workflow +health` with three execution modes:
|
||||
|
||||
- local flags
|
||||
- local JSON input
|
||||
- read-only GitLink fetch mode
|
||||
|
||||
It also adds stable `json`, `table`, and `markdown` rendering for Agent consumption.
|
||||
|
||||
## Motivation
|
||||
|
||||
- Help maintainers triage issues faster
|
||||
- Provide a structured repository health overview
|
||||
- Give AI Agents stable machine-readable output
|
||||
- Keep the workflow local-first and safe by default
|
||||
- Avoid any dependency on external LLM APIs
|
||||
|
||||
## Changes
|
||||
|
||||
- New `shortcuts/workflow` rule engine and DTOs
|
||||
- Local command layer for `workflow +triage` and `workflow +health`
|
||||
- Workflow-local renderer for `json`, `table`, and `markdown`
|
||||
- Read-only GitLink fetch and normalization helpers
|
||||
- Unit tests for rules, fetch normalization, rendering, and command wiring
|
||||
- Competition and test documentation updates
|
||||
|
||||
## Safety
|
||||
|
||||
- Remote mode is read-only
|
||||
- No comment, label, close, merge, or release write actions
|
||||
- Health scoring tolerates unknown or unavailable metrics
|
||||
- Test fixtures do not contain secrets or tokens
|
||||
|
||||
## Tests
|
||||
|
||||
- `gofmt -w shortcuts/workflow/*.go shortcuts/register.go`
|
||||
- `go test ./shortcuts/workflow`
|
||||
- `go test ./...`
|
||||
- `httptest` coverage for API normalization and fetch tolerance
|
||||
- Manual command examples in local and remote read-only modes
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Real API response shapes may still require minor normalization tweaks
|
||||
- Write operations are intentionally deferred to a later PR
|
||||
- `pr-summary`, `release-notes`, and `stale` are planned next
|
||||
|
||||
## Screenshots or Examples
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --format json
|
||||
gitlink-cli workflow +health --repository Gitlink/gitlink-cli --open-issues 3 --open-prs 1 --has-readme --has-license --has-contributing --agent-readiness-known --agent-readiness-score 9 --format table
|
||||
gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table
|
||||
```
|
||||
|
|
@ -0,0 +1,422 @@
|
|||
# GitLink CLI Workflow Agent Design
|
||||
|
||||
## Background
|
||||
|
||||
`gitlink-cli` already provides low-level and shortcut operations for GitLink repositories, issues, pull requests, releases, CI, organizations, search, and users. The repository also includes `skills/gitlink-workflow/SKILL.md`, which describes AI workflow patterns such as Issue triage, PR review, and Release Notes generation.
|
||||
|
||||
The current Go command tree does not include a `workflow` command group. The first competition PR should turn the documented workflow concept into concrete, deterministic CLI commands that can be used by human maintainers and AI Agents without calling an external LLM.
|
||||
|
||||
## Goals
|
||||
|
||||
First PR:
|
||||
- Add `gitlink-cli workflow +triage`.
|
||||
- Add `gitlink-cli workflow +health`.
|
||||
- Keep write behavior dry-run by default.
|
||||
- Produce stable JSON for Agents.
|
||||
- Produce concise table output for terminal users.
|
||||
- Produce markdown output for reports, PR comments, Issue comments, and competition materials.
|
||||
- Support `--lang en` and `--lang zh-CN` with a lightweight message helper.
|
||||
|
||||
Later PRs:
|
||||
- `workflow +pr-summary`
|
||||
- `workflow +release-notes`
|
||||
- `workflow +stale`
|
||||
|
||||
Current implementation status:
|
||||
- Rule engine: done
|
||||
- Local command layer: done
|
||||
- API fetch layer: done
|
||||
- Boundary tests: expanded for empty responses, field normalization, unknown tolerance, and read-only error handling
|
||||
|
||||
## Current Repository Findings
|
||||
|
||||
Command registration:
|
||||
- `cmd/root.go` registers global flags and calls `shortcuts.RegisterAll(rootCmd)`.
|
||||
- `shortcuts/register.go` maps command groups to shortcut slices.
|
||||
- Each group exposes `Shortcuts() []*common.Shortcut`.
|
||||
- `common.MountShortcut` maps a `Shortcut` into a Cobra command named `+<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:
|
||||
- `--id`
|
||||
- optional `--lang`
|
||||
|
||||
Data:
|
||||
- PR details
|
||||
- changed files
|
||||
- commits
|
||||
|
||||
Output:
|
||||
- `change_type`
|
||||
- `risk_level`
|
||||
- `summary`
|
||||
- `review_focus`
|
||||
- `test_suggestions`
|
||||
- `merge_checklist`
|
||||
|
||||
### `workflow +release-notes`
|
||||
|
||||
Inputs:
|
||||
- `--from`
|
||||
- `--to`
|
||||
- optional `--tag`
|
||||
- optional `--lang`
|
||||
|
||||
Data:
|
||||
- PR titles
|
||||
- commit messages
|
||||
|
||||
Markdown categories:
|
||||
- Features
|
||||
- Bug Fixes
|
||||
- Documentation
|
||||
- Tests
|
||||
- Refactoring
|
||||
- Chores
|
||||
- Breaking Changes
|
||||
|
||||
### `workflow +stale`
|
||||
|
||||
Inputs:
|
||||
- `--stale-days`
|
||||
- `--state`
|
||||
- `--dry-run`
|
||||
|
||||
Behavior:
|
||||
- Identify stale issues and PRs.
|
||||
- Generate suggested comments or labels.
|
||||
- Do not mutate remote state by default.
|
||||
|
||||
## API Fetch Layer
|
||||
|
||||
The current fetch layer uses:
|
||||
|
||||
- `triage_fetch.go`
|
||||
- `health_fetch.go`
|
||||
|
||||
Design goals already applied:
|
||||
|
||||
- tolerate unknown or partial API fields
|
||||
- map GitLink response shapes into stable workflow DTOs
|
||||
- continue operating when optional signals fail
|
||||
- keep remote-write actions disabled until explicitly enabled later
|
||||
|
||||
Planned fetch-layer extension:
|
||||
|
||||
- `triage_fetch.go` and `health_fetch.go` remain the normalization boundary for remote mode.
|
||||
- Future `pr-summary` and `release-notes` should reuse the same stable DTO and message patterns.
|
||||
- Unknown or missing fields should stay explicit in JSON output so Agents can decide how to proceed.
|
||||
|
||||
## Implementation Order
|
||||
|
||||
1. Pure DTOs and rule engine.
|
||||
2. Pure health scoring.
|
||||
3. Workflow renderers.
|
||||
4. Command registration.
|
||||
5. API fetch and normalization.
|
||||
6. Tests.
|
||||
7. README updates.
|
||||
8. Competition docs and test report.
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
# Workflow Agent Test Report
|
||||
|
||||
## Scope
|
||||
|
||||
This phase covers:
|
||||
|
||||
- Issue triage rules
|
||||
- health scoring rules
|
||||
- local command execution
|
||||
- API fetch boundary tests
|
||||
- remote read-only manual verification
|
||||
- `json` / `table` / `markdown` rendering
|
||||
- language handling
|
||||
- mock tests do not depend on the real remote API
|
||||
|
||||
## Environment
|
||||
|
||||
- OS: Windows
|
||||
- Go version: `go1.26.1 windows/amd64`
|
||||
- Go path: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\go.exe`
|
||||
- gofmt path: `E:\GitLinkCLI-Competition\tools\go1.26.1\go\bin\gofmt.exe`
|
||||
|
||||
## Test Commands
|
||||
|
||||
Executed:
|
||||
|
||||
```bash
|
||||
gofmt -w shortcuts/workflow/*.go shortcuts/register.go
|
||||
go test ./shortcuts/workflow
|
||||
go test ./...
|
||||
```
|
||||
|
||||
Results:
|
||||
|
||||
- `go test ./shortcuts/workflow` passed.
|
||||
- `go test ./...` passed.
|
||||
|
||||
## Unit Tests
|
||||
|
||||
- triage rules tests
|
||||
- health score tests
|
||||
- messages tests
|
||||
- render tests
|
||||
- command tests
|
||||
- fetch boundary tests
|
||||
|
||||
## API Fetch Boundary Tests
|
||||
|
||||
- empty issue responses return a clear error instead of panicking
|
||||
- missing issue titles still allow body-only issues to be normalized
|
||||
- label normalization supports string arrays, object arrays, and title/name variants
|
||||
- author normalization supports string, `user`, and `creator` shapes
|
||||
- GitLink error-in-body responses return readable errors
|
||||
- health activity timestamps accept `updated_at`, `updatedAt`, `last_activity_at`, `merged_at`, and `closed_at`
|
||||
- release responses accept `releases`, `data`, and direct array shapes
|
||||
- CI unavailability is recorded as `unknown` without failing the whole health run
|
||||
- stale-days values `0` and negative values fall back to the default `30`
|
||||
|
||||
## Manual Command Examples
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +triage --title "Install failed on Windows" --body "go install failed with error" --format table
|
||||
gitlink-cli workflow +triage --title "Token leaked in logs" --body "The access token appears in command output" --format json
|
||||
gitlink-cli workflow +triage --title "安装失败,无法登录" --body "运行命令时报错" --lang zh-CN --format markdown
|
||||
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format json
|
||||
gitlink-cli workflow +health --repository Gitlink/gitlink-cli --open-issues 3 --open-prs 1 --has-readme --has-license --has-contributing --agent-readiness-known --agent-readiness-score 9 --format table
|
||||
gitlink-cli workflow +health --repository demo/repo --open-issues 60 --stale-issues 25 --open-prs 12 --stale-prs 6 --recent-activity-known --recent-activity-days 120 --release-known=false --format json
|
||||
gitlink-cli workflow +health --repository Gitlink/gitlink-cli --open-issues 3 --open-prs 1 --has-readme --has-license --has-contributing --lang zh-CN --format markdown
|
||||
```
|
||||
|
||||
## Remote Manual Verification
|
||||
|
||||
- Command: `gitlink-cli workflow +triage --owner Gitlink --repo gitlink-cli --state open --limit 5 --format table`
|
||||
- Result: succeeded, returned five issues in table form.
|
||||
- Command: `gitlink-cli workflow +health --owner Gitlink --repo gitlink-cli --stale-days 30 --lang zh-CN --format markdown`
|
||||
- Result: succeeded, returned a markdown health report with score `58` and risk level `high`.
|
||||
- Remote writes: `No`
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- Current workflow commands support local analysis and read-only GitLink fetch mode.
|
||||
- `workflow +triage` still supports local parameters or a local JSON file via `--from`.
|
||||
- `workflow +health` still supports local parameters or a local JSON file via `--from`.
|
||||
- `json/table/markdown` are rendered inside the workflow package, not by the global formatter.
|
||||
- Fetch-layer tests use `httptest` and do not depend on the real remote API.
|
||||
|
||||
## Conclusion
|
||||
|
||||
The rule-based Agent Workflow prototype, including the read-only fetch layer, is implemented, tested, and locally runnable.
|
||||
|
|
@ -14,34 +14,37 @@ import (
|
|||
"github.com/gitlink-org/gitlink-cli/shortcuts/search"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/user"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/webhook"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/workflow"
|
||||
)
|
||||
|
||||
// RegisterAll mounts all shortcut groups onto the root command.
|
||||
func RegisterAll(root *cobra.Command) {
|
||||
groups := map[string][]*common.Shortcut{
|
||||
"repo": repo.Shortcuts(),
|
||||
"issue": issue.Shortcuts(),
|
||||
"pr": pr.Shortcuts(),
|
||||
"release": release.Shortcuts(),
|
||||
"branch": branch.Shortcuts(),
|
||||
"org": org.Shortcuts(),
|
||||
"user": user.Shortcuts(),
|
||||
"search": search.Shortcuts(),
|
||||
"ci": ci.Shortcuts(),
|
||||
"webhook": webhook.Shortcuts(),
|
||||
"repo": repo.Shortcuts(),
|
||||
"issue": issue.Shortcuts(),
|
||||
"pr": pr.Shortcuts(),
|
||||
"release": release.Shortcuts(),
|
||||
"branch": branch.Shortcuts(),
|
||||
"org": org.Shortcuts(),
|
||||
"user": user.Shortcuts(),
|
||||
"search": search.Shortcuts(),
|
||||
"ci": ci.Shortcuts(),
|
||||
"webhook": webhook.Shortcuts(),
|
||||
"workflow": workflow.Shortcuts(),
|
||||
}
|
||||
|
||||
descriptions := map[string]string{
|
||||
"repo": "Repository operations",
|
||||
"issue": "Issue operations",
|
||||
"pr": "Pull request operations",
|
||||
"release": "Release operations",
|
||||
"branch": "Branch operations",
|
||||
"org": "Organization operations",
|
||||
"user": "User operations",
|
||||
"search": "Search operations",
|
||||
"ci": "CI/CD operations",
|
||||
"webhook": "Webhook operations",
|
||||
"repo": "Repository operations",
|
||||
"issue": "Issue operations",
|
||||
"pr": "Pull request operations",
|
||||
"release": "Release operations",
|
||||
"branch": "Branch operations",
|
||||
"org": "Organization operations",
|
||||
"user": "User operations",
|
||||
"search": "Search operations",
|
||||
"ci": "CI/CD operations",
|
||||
"webhook": "Webhook operations",
|
||||
"workflow": "AI agent workflow analysis",
|
||||
}
|
||||
|
||||
for name, shortcuts := range groups {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,363 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type TriageFetchOptions struct {
|
||||
Owner string
|
||||
Repo string
|
||||
State string
|
||||
Limit int
|
||||
Page int
|
||||
Labels []string
|
||||
Since string
|
||||
}
|
||||
|
||||
type HealthFetchOptions struct {
|
||||
Owner string
|
||||
Repo string
|
||||
StaleDays int
|
||||
IncludeCI bool
|
||||
IncludeRelease bool
|
||||
IncludeDocs bool
|
||||
}
|
||||
|
||||
func workflowRepoPath(owner, repo string) string {
|
||||
return fmt.Sprintf("/v1/%s/%s", strings.TrimSpace(owner), strings.TrimSpace(repo))
|
||||
}
|
||||
|
||||
func resolveFetchRepo(ctx *common.RuntimeContext, owner, repo string) (string, string, error) {
|
||||
if strings.TrimSpace(owner) != "" && strings.TrimSpace(repo) != "" {
|
||||
return strings.TrimSpace(owner), strings.TrimSpace(repo), nil
|
||||
}
|
||||
if strings.TrimSpace(ctx.Owner) != "" && strings.TrimSpace(ctx.Repo) != "" {
|
||||
return strings.TrimSpace(ctx.Owner), strings.TrimSpace(ctx.Repo), nil
|
||||
}
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
if strings.TrimSpace(ctx.Owner) == "" || strings.TrimSpace(ctx.Repo) == "" {
|
||||
return "", "", fmt.Errorf("repository owner/repo is required; use --owner and --repo or run inside a GitLink repository")
|
||||
}
|
||||
return strings.TrimSpace(ctx.Owner), strings.TrimSpace(ctx.Repo), nil
|
||||
}
|
||||
|
||||
func normalizeAPIData(data interface{}) (interface{}, error) {
|
||||
switch v := data.(type) {
|
||||
case nil:
|
||||
return nil, nil
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(v)
|
||||
if trimmed == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var decoded interface{}
|
||||
if err := json.Unmarshal([]byte(trimmed), &decoded); err != nil {
|
||||
return v, nil
|
||||
}
|
||||
return decoded, nil
|
||||
case json.RawMessage:
|
||||
if len(v) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
var decoded interface{}
|
||||
if err := json.Unmarshal(v, &decoded); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decoded, nil
|
||||
default:
|
||||
return v, nil
|
||||
}
|
||||
}
|
||||
|
||||
func apiObject(data interface{}) map[string]interface{} {
|
||||
normalized, err := normalizeAPIData(data)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
switch v := normalized.(type) {
|
||||
case map[string]interface{}:
|
||||
return v
|
||||
case []interface{}:
|
||||
if len(v) == 1 {
|
||||
if item, ok := v[0].(map[string]interface{}); ok {
|
||||
return item
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiList(data interface{}) []interface{} {
|
||||
normalized, err := normalizeAPIData(data)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
switch v := normalized.(type) {
|
||||
case []interface{}:
|
||||
return v
|
||||
case map[string]interface{}:
|
||||
for _, key := range []string{"issues", "pulls", "releases", "builds", "items", "records", "data"} {
|
||||
if raw, ok := v[key]; ok {
|
||||
if items := apiList(raw); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
}
|
||||
}
|
||||
if looksLikeIssueOrRepoItem(v) {
|
||||
return []interface{}{v}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func looksLikeIssueOrRepoItem(v map[string]interface{}) bool {
|
||||
_, hasTitle := v["title"]
|
||||
_, hasSubject := v["subject"]
|
||||
_, hasNumber := v["number"]
|
||||
_, hasID := v["id"]
|
||||
_, hasIID := v["iid"]
|
||||
_, hasIssueNumber := v["issue_number"]
|
||||
_, hasProjectIndex := v["project_issues_index"]
|
||||
return hasTitle || hasSubject || hasNumber || hasID || hasIID || hasIssueNumber || hasProjectIndex
|
||||
}
|
||||
|
||||
func apiString(v interface{}) string {
|
||||
switch value := v.(type) {
|
||||
case string:
|
||||
return value
|
||||
case fmt.Stringer:
|
||||
return value.String()
|
||||
case float64:
|
||||
return trimTrailingZero(fmt.Sprintf("%f", value))
|
||||
case float32:
|
||||
return trimTrailingZero(fmt.Sprintf("%f", value))
|
||||
case int:
|
||||
return strconv.Itoa(value)
|
||||
case int64:
|
||||
return strconv.FormatInt(value, 10)
|
||||
case int32:
|
||||
return strconv.FormatInt(int64(value), 10)
|
||||
case uint64:
|
||||
return strconv.FormatUint(value, 10)
|
||||
case uint32:
|
||||
return strconv.FormatUint(uint64(value), 10)
|
||||
case json.Number:
|
||||
return value.String()
|
||||
case bool:
|
||||
return strconv.FormatBool(value)
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
func trimTrailingZero(value string) string {
|
||||
value = strings.TrimSuffix(value, "000000")
|
||||
value = strings.TrimSuffix(value, ".000000")
|
||||
value = strings.TrimSuffix(value, ".0")
|
||||
value = strings.TrimSuffix(value, ".")
|
||||
return value
|
||||
}
|
||||
|
||||
func apiInt(v interface{}) int {
|
||||
switch value := v.(type) {
|
||||
case int:
|
||||
return value
|
||||
case int8:
|
||||
return int(value)
|
||||
case int16:
|
||||
return int(value)
|
||||
case int32:
|
||||
return int(value)
|
||||
case int64:
|
||||
return int(value)
|
||||
case uint:
|
||||
return int(value)
|
||||
case uint8:
|
||||
return int(value)
|
||||
case uint16:
|
||||
return int(value)
|
||||
case uint32:
|
||||
return int(value)
|
||||
case uint64:
|
||||
return int(value)
|
||||
case float32:
|
||||
return int(value)
|
||||
case float64:
|
||||
return int(value)
|
||||
case json.Number:
|
||||
n, _ := value.Int64()
|
||||
return int(n)
|
||||
case string:
|
||||
if value == "" {
|
||||
return 0
|
||||
}
|
||||
if n, err := strconv.Atoi(value); err == nil {
|
||||
return n
|
||||
}
|
||||
if n, err := strconv.ParseFloat(value, 64); err == nil {
|
||||
return int(n)
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func apiBool(v interface{}) bool {
|
||||
switch value := v.(type) {
|
||||
case bool:
|
||||
return value
|
||||
case string:
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
return err == nil && parsed
|
||||
case float64:
|
||||
return value != 0
|
||||
case int:
|
||||
return value != 0
|
||||
case json.Number:
|
||||
n, err := value.Int64()
|
||||
return err == nil && n != 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func apiTime(v interface{}) time.Time {
|
||||
switch value := v.(type) {
|
||||
case time.Time:
|
||||
return value
|
||||
case string:
|
||||
return parseAPIStringTime(value)
|
||||
case float64:
|
||||
return parseAPINumericTime(int64(value))
|
||||
case float32:
|
||||
return parseAPINumericTime(int64(value))
|
||||
case int:
|
||||
return parseAPINumericTime(int64(value))
|
||||
case int64:
|
||||
return parseAPINumericTime(value)
|
||||
case json.Number:
|
||||
if n, err := value.Int64(); err == nil {
|
||||
return parseAPINumericTime(n)
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func parseAPIStringTime(value string) time.Time {
|
||||
trimmed := strings.TrimSpace(value)
|
||||
if trimmed == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
layouts := []string{
|
||||
time.RFC3339,
|
||||
time.RFC3339Nano,
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05",
|
||||
"2006-01-02",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if parsed, err := time.Parse(layout, trimmed); err == nil {
|
||||
return parsed
|
||||
}
|
||||
}
|
||||
if n, err := strconv.ParseInt(trimmed, 10, 64); err == nil {
|
||||
return parseAPINumericTime(n)
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func parseAPINumericTime(n int64) time.Time {
|
||||
if n <= 0 {
|
||||
return time.Time{}
|
||||
}
|
||||
if n > 1_000_000_000_000 {
|
||||
return time.Unix(0, n*int64(time.Millisecond))
|
||||
}
|
||||
return time.Unix(n, 0)
|
||||
}
|
||||
|
||||
func apiStringSlice(v interface{}) []string {
|
||||
switch value := v.(type) {
|
||||
case nil:
|
||||
return nil
|
||||
case []string:
|
||||
return append([]string(nil), value...)
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(value))
|
||||
for _, item := range value {
|
||||
if s := apiStringValue(item); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case string:
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func apiStringValue(v interface{}) string {
|
||||
switch value := v.(type) {
|
||||
case map[string]interface{}:
|
||||
for _, key := range []string{"name", "title", "login", "label", "text"} {
|
||||
if s := apiString(value[key]); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
default:
|
||||
return apiString(v)
|
||||
}
|
||||
}
|
||||
|
||||
func apiAuthor(v interface{}) string {
|
||||
switch value := v.(type) {
|
||||
case map[string]interface{}:
|
||||
for _, key := range []string{"login", "name", "username", "full_name", "display_name"} {
|
||||
if s := apiString(value[key]); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
default:
|
||||
return apiString(v)
|
||||
}
|
||||
}
|
||||
|
||||
func apiLatestTime(values ...time.Time) time.Time {
|
||||
var latest time.Time
|
||||
for _, value := range values {
|
||||
if value.IsZero() {
|
||||
continue
|
||||
}
|
||||
if latest.IsZero() || value.After(latest) {
|
||||
latest = value
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func apiAgeInDays(value time.Time) int {
|
||||
if value.IsZero() {
|
||||
return -1
|
||||
}
|
||||
return int(time.Since(value).Hours() / 24)
|
||||
}
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func FetchHealthInput(ctx *common.RuntimeContext, opts HealthFetchOptions) (HealthInput, []ScoringNote, error) {
|
||||
owner, repo, err := resolveFetchRepo(ctx, opts.Owner, opts.Repo)
|
||||
if err != nil {
|
||||
return HealthInput{}, nil, err
|
||||
}
|
||||
|
||||
input := HealthInput{
|
||||
Repository: fmt.Sprintf("%s/%s", owner, repo),
|
||||
}
|
||||
notes := []ScoringNote{}
|
||||
staleDays := opts.StaleDays
|
||||
if staleDays <= 0 {
|
||||
staleDays = 30
|
||||
}
|
||||
|
||||
repoInfo, repoErr := fetchRepoInfo(ctx, owner, repo)
|
||||
if repoErr != nil {
|
||||
notes = append(notes, ScoringNote{Metric: "repository", Note: repoErr.Error()})
|
||||
} else {
|
||||
applyRepoSignals(&input, repoInfo)
|
||||
}
|
||||
|
||||
if issues, err := fetchAllListItems(ctx, workflowRepoPath(owner, repo)+"/issues", issueListQuery("open"), 100); err != nil {
|
||||
notes = append(notes, ScoringNote{Metric: "open_issues", Note: fmt.Sprintf("issue probe failed: %v", err)})
|
||||
} else {
|
||||
input.OpenIssues = len(issues)
|
||||
input.StaleIssues = countStaleItems(issues, staleDays)
|
||||
input.RecentActivityKnown, input.RecentActivityDays, input = updateRecentActivity(input, latestTimeFromItems(issues, input.RecentActivityDays))
|
||||
}
|
||||
|
||||
if prs, err := fetchAllListItems(ctx, workflowRepoPath(owner, repo)+"/pulls", issueListQuery("open"), 100); err != nil {
|
||||
notes = append(notes, ScoringNote{Metric: "open_prs", Note: fmt.Sprintf("pull request probe failed: %v", err)})
|
||||
} else {
|
||||
input.OpenPRs = len(prs)
|
||||
input.StalePRs = countStaleItems(prs, staleDays)
|
||||
input.RecentActivityKnown, input.RecentActivityDays, input = updateRecentActivity(input, latestTimeFromItems(prs, input.RecentActivityDays))
|
||||
}
|
||||
|
||||
if opts.IncludeRelease {
|
||||
if releases, err := fetchAllListItems(ctx, ctx.RepoPath()+"/releases", nil, 100); err != nil {
|
||||
input.ReleaseKnown = false
|
||||
notes = append(notes, ScoringNote{Metric: "release_status", Note: fmt.Sprintf("release probe failed: %v", err)})
|
||||
} else {
|
||||
input.ReleaseKnown = true
|
||||
input.HasRecentRelease = len(releases) > 0
|
||||
input.RecentActivityKnown, input.RecentActivityDays, input = updateRecentActivity(input, latestTimeFromItems(releases, input.RecentActivityDays))
|
||||
}
|
||||
}
|
||||
|
||||
if opts.IncludeCI {
|
||||
if builds, err := fetchAllListItems(ctx, ctx.RepoPath()+"/builds", queryWithPageLimit(nil, 1, 20), 20); err != nil {
|
||||
input.CIKnown = false
|
||||
notes = append(notes, ScoringNote{Metric: "ci_status", Note: fmt.Sprintf("ci probe failed: %v", err)})
|
||||
} else {
|
||||
input.CIKnown = true
|
||||
input.CIPassing = len(builds) > 0 && buildPassing(builds[0])
|
||||
}
|
||||
}
|
||||
|
||||
if opts.IncludeDocs {
|
||||
applyDocSignals(&input, repoInfo, ¬es)
|
||||
}
|
||||
|
||||
applyAgentReadinessEstimate(&input)
|
||||
|
||||
if !input.RecentActivityKnown {
|
||||
notes = append(notes, ScoringNote{Metric: "recent_activity", Note: "recent activity unavailable; scored conservatively"})
|
||||
}
|
||||
if !input.ReleaseKnown {
|
||||
notes = append(notes, ScoringNote{Metric: "release_status", Note: "release status unavailable; scored conservatively"})
|
||||
}
|
||||
if !input.CIKnown {
|
||||
notes = append(notes, ScoringNote{Metric: "ci_status", Note: "ci status unavailable; scored conservatively"})
|
||||
}
|
||||
|
||||
return input, uniqueScoringNotes(notes), nil
|
||||
}
|
||||
|
||||
func fetchRepoInfo(ctx *common.RuntimeContext, owner, repo string) (map[string]interface{}, error) {
|
||||
env, err := ctx.CallAPI("GET", workflowRepoPath(owner, repo), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
info := apiObject(env.Data)
|
||||
if info == nil {
|
||||
return nil, fmt.Errorf("repository response did not contain an object")
|
||||
}
|
||||
return info, nil
|
||||
}
|
||||
|
||||
func applyRepoSignals(input *HealthInput, repoInfo map[string]interface{}) {
|
||||
if repoInfo == nil {
|
||||
return
|
||||
}
|
||||
if t := apiTime(repoInfo["updated_at"]); !t.IsZero() {
|
||||
input.RecentActivityKnown = true
|
||||
input.RecentActivityDays = apiAgeInDays(t)
|
||||
}
|
||||
applyDocSignals(input, repoInfo, nil)
|
||||
}
|
||||
|
||||
func applyDocSignals(input *HealthInput, repoInfo map[string]interface{}, notes *[]ScoringNote) {
|
||||
if repoInfo == nil {
|
||||
return
|
||||
}
|
||||
hasReadme, readmeOK := repoInfo["has_readme"]
|
||||
hasLicense, licenseOK := repoInfo["has_license"]
|
||||
hasContributing, contribOK := repoInfo["has_contributing"]
|
||||
if readmeOK {
|
||||
input.HasReadme = apiBool(hasReadme)
|
||||
} else if notes != nil {
|
||||
*notes = append(*notes, ScoringNote{Metric: "documentation", Note: "README probe unavailable; scored conservatively"})
|
||||
}
|
||||
if licenseOK {
|
||||
input.HasLicense = apiBool(hasLicense)
|
||||
} else if notes != nil {
|
||||
*notes = append(*notes, ScoringNote{Metric: "license_status", Note: "license probe unavailable; scored conservatively"})
|
||||
}
|
||||
if contribOK {
|
||||
input.HasContributing = apiBool(hasContributing)
|
||||
} else if notes != nil {
|
||||
*notes = append(*notes, ScoringNote{Metric: "contribution_status", Note: "contributing probe unavailable; scored conservatively"})
|
||||
}
|
||||
}
|
||||
|
||||
func applyAgentReadinessEstimate(input *HealthInput) {
|
||||
score := 4
|
||||
if input.HasReadme {
|
||||
score += 2
|
||||
}
|
||||
if input.HasLicense {
|
||||
score += 2
|
||||
}
|
||||
if input.HasContributing {
|
||||
score += 2
|
||||
}
|
||||
if input.RecentActivityKnown {
|
||||
score++
|
||||
}
|
||||
if input.ReleaseKnown {
|
||||
score++
|
||||
}
|
||||
input.AgentReadinessKnown = true
|
||||
input.AgentReadinessScore = clampInt(score, 0, 10)
|
||||
}
|
||||
|
||||
func countStaleItems(items []map[string]interface{}, staleDays int) int {
|
||||
if staleDays <= 0 {
|
||||
staleDays = 30
|
||||
}
|
||||
count := 0
|
||||
for _, item := range items {
|
||||
if apiAgeInDays(itemActivityTime(item)) >= staleDays {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func itemActivityTime(item map[string]interface{}) time.Time {
|
||||
if item == nil {
|
||||
return time.Time{}
|
||||
}
|
||||
return apiLatestTime(
|
||||
apiTime(item["updated_at"]),
|
||||
apiTime(item["updatedAt"]),
|
||||
apiTime(item["created_at"]),
|
||||
apiTime(item["last_updated_at"]),
|
||||
apiTime(item["lastUpdatedAt"]),
|
||||
apiTime(item["last_activity_at"]),
|
||||
apiTime(item["lastActivityAt"]),
|
||||
apiTime(item["merged_at"]),
|
||||
apiTime(item["mergedAt"]),
|
||||
apiTime(item["closed_at"]),
|
||||
apiTime(item["closedAt"]),
|
||||
)
|
||||
}
|
||||
|
||||
func latestTimeFromItems(items []map[string]interface{}, currentDays int) time.Time {
|
||||
latest := time.Time{}
|
||||
for _, item := range items {
|
||||
latest = apiLatestTime(latest, itemActivityTime(item))
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func updateRecentActivity(input HealthInput, latest time.Time) (bool, int, HealthInput) {
|
||||
if latest.IsZero() {
|
||||
return input.RecentActivityKnown, input.RecentActivityDays, input
|
||||
}
|
||||
days := apiAgeInDays(latest)
|
||||
if !input.RecentActivityKnown || days < input.RecentActivityDays || input.RecentActivityDays == 0 {
|
||||
input.RecentActivityKnown = true
|
||||
input.RecentActivityDays = days
|
||||
}
|
||||
return input.RecentActivityKnown, input.RecentActivityDays, input
|
||||
}
|
||||
|
||||
func queryWithPageLimit(base url.Values, page, limit int) url.Values {
|
||||
if base == nil {
|
||||
base = url.Values{}
|
||||
}
|
||||
if page > 0 {
|
||||
base.Set("page", fmt.Sprintf("%d", page))
|
||||
}
|
||||
if limit > 0 {
|
||||
base.Set("limit", fmt.Sprintf("%d", limit))
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
func issueListQuery(state string) url.Values {
|
||||
q := url.Values{}
|
||||
q.Set("state", state)
|
||||
return q
|
||||
}
|
||||
|
||||
func fetchAllListItems(ctx *common.RuntimeContext, path string, baseQuery url.Values, pageSize int) ([]map[string]interface{}, error) {
|
||||
if pageSize <= 0 {
|
||||
pageSize = 100
|
||||
}
|
||||
all := []map[string]interface{}{}
|
||||
for page := 1; ; page++ {
|
||||
query := cloneValues(baseQuery)
|
||||
query.Set("page", fmt.Sprintf("%d", page))
|
||||
query.Set("limit", fmt.Sprintf("%d", pageSize))
|
||||
|
||||
env, err := ctx.CallAPIWithQuery("GET", path, query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := apiList(env.Data)
|
||||
pageItems := make([]map[string]interface{}, 0, len(items))
|
||||
for _, raw := range items {
|
||||
if item, ok := raw.(map[string]interface{}); ok {
|
||||
pageItems = append(pageItems, item)
|
||||
}
|
||||
}
|
||||
if len(pageItems) == 0 {
|
||||
break
|
||||
}
|
||||
all = append(all, pageItems...)
|
||||
if len(pageItems) < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func cloneValues(values url.Values) url.Values {
|
||||
if values == nil {
|
||||
return url.Values{}
|
||||
}
|
||||
out := url.Values{}
|
||||
for key, list := range values {
|
||||
out[key] = append([]string(nil), list...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func buildPassing(item map[string]interface{}) bool {
|
||||
for _, key := range []string{"status", "state", "result", "conclusion", "status_text"} {
|
||||
if value := strings.ToLower(strings.TrimSpace(apiString(item[key]))); value != "" {
|
||||
switch value {
|
||||
case "success", "passed", "pass", "ok", "done", "succeeded", "build passed":
|
||||
return true
|
||||
case "failed", "failure", "error", "canceled", "cancelled", "running", "pending":
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
if apiBool(item["success"]) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func uniqueScoringNotes(notes []ScoringNote) []ScoringNote {
|
||||
seen := map[string]struct{}{}
|
||||
out := make([]ScoringNote, 0, len(notes))
|
||||
for _, note := range notes {
|
||||
key := note.Metric + "|" + note.Note
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
out = append(out, note)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
|
@ -0,0 +1,321 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestFetchHealthInputCollectsSignals(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
old := now.AddDate(0, 0, -45)
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"name": "repo",
|
||||
"updated_at": now.AddDate(0, 0, -2).Format(time.RFC3339),
|
||||
"has_readme": true,
|
||||
"has_license": true,
|
||||
"has_contributing": true,
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{
|
||||
{"id": 1, "subject": "fresh issue", "updated_at": now.AddDate(0, 0, -1).Format(time.RFC3339)},
|
||||
{"id": 2, "subject": "stale issue", "updated_at": old.Format(time.RFC3339)},
|
||||
}})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{
|
||||
{"id": 3, "title": "stale pr", "updated_at": old.Format(time.RFC3339)},
|
||||
}})
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"releases": []map[string]interface{}{
|
||||
{"id": 4, "name": "v1.0.0", "created_at": now.AddDate(0, 0, -3).Format(time.RFC3339)},
|
||||
}})
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"builds": []map[string]interface{}{
|
||||
{"id": 5, "status": "success"},
|
||||
}})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input, notes, err := FetchHealthInput(workflowTestContext(server), HealthFetchOptions{
|
||||
StaleDays: 30,
|
||||
IncludeCI: true,
|
||||
IncludeRelease: true,
|
||||
IncludeDocs: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchHealthInput returned error: %v", err)
|
||||
}
|
||||
if len(notes) != 0 {
|
||||
t.Fatalf("notes = %v, want empty", notes)
|
||||
}
|
||||
if input.Repository != "owner/repo" {
|
||||
t.Fatalf("Repository = %q, want owner/repo", input.Repository)
|
||||
}
|
||||
if input.OpenIssues != 2 || input.StaleIssues != 1 {
|
||||
t.Fatalf("issues = open %d stale %d, want open 2 stale 1", input.OpenIssues, input.StaleIssues)
|
||||
}
|
||||
if input.OpenPRs != 1 || input.StalePRs != 1 {
|
||||
t.Fatalf("prs = open %d stale %d, want open 1 stale 1", input.OpenPRs, input.StalePRs)
|
||||
}
|
||||
if !input.ReleaseKnown || !input.HasRecentRelease {
|
||||
t.Fatalf("release signals = known %v recent %v, want true true", input.ReleaseKnown, input.HasRecentRelease)
|
||||
}
|
||||
if !input.CIKnown || !input.CIPassing {
|
||||
t.Fatalf("ci signals = known %v passing %v, want true true", input.CIKnown, input.CIPassing)
|
||||
}
|
||||
if !input.HasReadme || !input.HasLicense || !input.HasContributing {
|
||||
t.Fatalf("doc signals = readme %v license %v contributing %v, want all true", input.HasReadme, input.HasLicense, input.HasContributing)
|
||||
}
|
||||
if !input.RecentActivityKnown || input.RecentActivityDays > 3 {
|
||||
t.Fatalf("recent activity = known %v days %d, want known and <= 3", input.RecentActivityKnown, input.RecentActivityDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchHealthInputToleratesOptionalProbeFailures(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"name": "repo",
|
||||
"has_readme": true,
|
||||
"has_license": true,
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{}})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{}})
|
||||
case r.Method == "GET" && (r.URL.Path == "/owner/repo/releases.json" || r.URL.Path == "/owner/repo/builds.json"):
|
||||
http.Error(w, "temporary failure", http.StatusInternalServerError)
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input, notes, err := FetchHealthInput(workflowTestContext(server), HealthFetchOptions{
|
||||
StaleDays: 30,
|
||||
IncludeCI: true,
|
||||
IncludeRelease: true,
|
||||
IncludeDocs: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchHealthInput returned error: %v", err)
|
||||
}
|
||||
if input.Repository != "owner/repo" {
|
||||
t.Fatalf("Repository = %q, want owner/repo", input.Repository)
|
||||
}
|
||||
if input.ReleaseKnown {
|
||||
t.Fatal("ReleaseKnown = true, want false after release probe failure")
|
||||
}
|
||||
if input.CIKnown {
|
||||
t.Fatal("CIKnown = true, want false after CI probe failure")
|
||||
}
|
||||
if len(notes) == 0 {
|
||||
t.Fatal("notes is empty, want scoring notes for failed optional probes")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchHealthInputHandlesMissingRepoActivityAndDocGaps(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"name": "repo",
|
||||
"has_readme": true,
|
||||
"has_license": false,
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{}})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{}})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input, notes, err := FetchHealthInput(workflowTestContext(server), HealthFetchOptions{
|
||||
StaleDays: 30,
|
||||
IncludeCI: false,
|
||||
IncludeDocs: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchHealthInput returned error: %v", err)
|
||||
}
|
||||
if input.RecentActivityKnown {
|
||||
t.Fatal("RecentActivityKnown = true, want false when updated_at is missing and no activity lists carry timestamps")
|
||||
}
|
||||
if len(notes) == 0 {
|
||||
t.Fatal("notes is empty, want scoring notes for missing repo signals")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchHealthInputUsesAlternativeActivityFieldsAndDefaultStaleDays(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
fresh := now.AddDate(0, 0, -2)
|
||||
stale := now.AddDate(0, 0, -40)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"name": "repo"})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{
|
||||
{"id": 1, "updatedAt": fresh.Format(time.RFC3339)},
|
||||
{"id": 2, "last_activity_at": stale.Format(time.RFC3339)},
|
||||
}})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{
|
||||
{"id": 3, "merged_at": fresh.Format(time.RFC3339)},
|
||||
{"id": 4, "closed_at": stale.Format(time.RFC3339)},
|
||||
}})
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"releases": []map[string]interface{}{}})
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"builds": []map[string]interface{}{
|
||||
{"id": 5, "status": "success"},
|
||||
}})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
for _, staleDays := range []int{0, -5} {
|
||||
t.Run("stale-days", func(t *testing.T) {
|
||||
input, notes, err := FetchHealthInput(workflowTestContext(server), HealthFetchOptions{
|
||||
StaleDays: staleDays,
|
||||
IncludeRelease: true,
|
||||
IncludeCI: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchHealthInput returned error: %v", err)
|
||||
}
|
||||
if input.StaleIssues != 1 {
|
||||
t.Fatalf("StaleIssues = %d, want 1", input.StaleIssues)
|
||||
}
|
||||
if input.StalePRs != 1 {
|
||||
t.Fatalf("StalePRs = %d, want 1", input.StalePRs)
|
||||
}
|
||||
if !input.RecentActivityKnown {
|
||||
t.Fatal("RecentActivityKnown = false, want true")
|
||||
}
|
||||
if input.RecentActivityDays > 3 {
|
||||
t.Fatalf("RecentActivityDays = %d, want <= 3", input.RecentActivityDays)
|
||||
}
|
||||
if len(notes) != 0 {
|
||||
t.Fatalf("notes = %v, want empty when release and CI probes succeed", notes)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchHealthInputSupportsReleaseShapeVariants(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
releasePayloads := []map[string]interface{}{
|
||||
{"releases": []map[string]interface{}{{"id": 1, "name": "v1.0.0", "created_at": now.AddDate(0, 0, -1).Format(time.RFC3339)}}},
|
||||
{"data": []map[string]interface{}{{"id": 2, "name": "v1.0.1", "updated_at": now.AddDate(0, 0, -1).Format(time.RFC3339)}}},
|
||||
}
|
||||
|
||||
for i, payload := range releasePayloads {
|
||||
t.Run("shape", func(t *testing.T) {
|
||||
payload := payload
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"name": "repo",
|
||||
"updated_at": now.Format(time.RFC3339),
|
||||
"has_readme": true,
|
||||
"has_license": true,
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{}})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{}})
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json":
|
||||
writeWorkflowJSON(t, w, payload)
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"builds": []map[string]interface{}{
|
||||
{"id": 3, "status": "success"},
|
||||
}})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input, notes, err := FetchHealthInput(workflowTestContext(server), HealthFetchOptions{
|
||||
StaleDays: 30,
|
||||
IncludeRelease: true,
|
||||
IncludeCI: true,
|
||||
IncludeDocs: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchHealthInput returned error: %v", err)
|
||||
}
|
||||
if !input.ReleaseKnown || !input.HasRecentRelease {
|
||||
t.Fatalf("release signals = known %v recent %v, want true true", input.ReleaseKnown, input.HasRecentRelease)
|
||||
}
|
||||
if input.RecentActivityDays > 1 {
|
||||
t.Fatalf("RecentActivityDays = %d, want <= 1", input.RecentActivityDays)
|
||||
}
|
||||
if len(notes) != 0 {
|
||||
t.Fatalf("notes = %v, want empty for supported release shape %d", notes, i)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchHealthInputReportsCIUnavailable(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"name": "repo",
|
||||
"has_readme": true,
|
||||
"has_license": true,
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{}})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{}})
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json":
|
||||
http.Error(w, "not found", http.StatusNotFound)
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input, notes, err := FetchHealthInput(workflowTestContext(server), HealthFetchOptions{
|
||||
StaleDays: 30,
|
||||
IncludeCI: true,
|
||||
IncludeRelease: false,
|
||||
IncludeDocs: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchHealthInput returned error: %v", err)
|
||||
}
|
||||
if input.CIKnown {
|
||||
t.Fatal("CIKnown = true, want false for CI probe failure")
|
||||
}
|
||||
if len(notes) == 0 {
|
||||
t.Fatal("notes is empty, want note for unavailable CI")
|
||||
}
|
||||
joined := ""
|
||||
for _, note := range notes {
|
||||
joined += note.Metric + " " + note.Note + "\n"
|
||||
}
|
||||
if !strings.Contains(joined, "ci_status") {
|
||||
t.Fatalf("notes = %v, want ci_status note", notes)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
package workflow
|
||||
|
||||
func ScoreHealth(input HealthInput, lang string) HealthResult {
|
||||
lang = normalizeLang(lang)
|
||||
|
||||
metrics := []HealthMetric{}
|
||||
notes := []ScoringNote{}
|
||||
recommendations := []string{}
|
||||
|
||||
issueMetric := scoreIssueBacklog(input, lang)
|
||||
metrics = append(metrics, issueMetric)
|
||||
if issueMetric.Score < issueMetric.MaxScore {
|
||||
recommendations = append(recommendations, message(lang, "rec_reduce_issues"))
|
||||
}
|
||||
|
||||
prMetric := scorePRBacklog(input, lang)
|
||||
metrics = append(metrics, prMetric)
|
||||
if prMetric.Score < prMetric.MaxScore {
|
||||
recommendations = append(recommendations, message(lang, "rec_reduce_prs"))
|
||||
}
|
||||
|
||||
recentMetric, recentNote := scoreRecentActivity(input, lang)
|
||||
metrics = append(metrics, recentMetric)
|
||||
if recentNote.Note != "" {
|
||||
notes = append(notes, recentNote)
|
||||
}
|
||||
if recentMetric.Score < recentMetric.MaxScore/2 {
|
||||
recommendations = append(recommendations, message(lang, "rec_restore_activity"))
|
||||
}
|
||||
|
||||
releaseMetric, releaseNote := scoreReleaseStatus(input, lang)
|
||||
metrics = append(metrics, releaseMetric)
|
||||
if releaseNote.Note != "" {
|
||||
notes = append(notes, releaseNote)
|
||||
}
|
||||
if releaseMetric.Score < releaseMetric.MaxScore/2 {
|
||||
recommendations = append(recommendations, message(lang, "rec_release"))
|
||||
}
|
||||
|
||||
docMetric := scoreDocumentation(input, lang)
|
||||
metrics = append(metrics, docMetric)
|
||||
if docMetric.Score < docMetric.MaxScore {
|
||||
recommendations = append(recommendations, message(lang, "rec_docs"))
|
||||
}
|
||||
|
||||
licenseMetric := scoreLicenseAndContributing(input, lang)
|
||||
metrics = append(metrics, licenseMetric)
|
||||
if licenseMetric.Score < licenseMetric.MaxScore {
|
||||
recommendations = append(recommendations, message(lang, "rec_license"))
|
||||
}
|
||||
|
||||
agentMetric, agentNote := scoreAgentReadiness(input, lang)
|
||||
metrics = append(metrics, agentMetric)
|
||||
if agentNote.Note != "" {
|
||||
notes = append(notes, agentNote)
|
||||
}
|
||||
if agentMetric.Score < agentMetric.MaxScore/2 {
|
||||
recommendations = append(recommendations, message(lang, "rec_agent"))
|
||||
}
|
||||
|
||||
ciMetric, ciNote := scoreCIStatus(input, lang)
|
||||
metrics = append(metrics, ciMetric)
|
||||
if ciNote.Note != "" {
|
||||
notes = append(notes, ciNote)
|
||||
}
|
||||
|
||||
total := 0
|
||||
maxTotal := 0
|
||||
for _, metric := range metrics {
|
||||
total += metric.Score
|
||||
maxTotal += metric.MaxScore
|
||||
}
|
||||
|
||||
healthScore := 0
|
||||
if maxTotal > 0 {
|
||||
healthScore = clampInt(total*100/maxTotal, 0, 100)
|
||||
}
|
||||
if len(recommendations) == 0 {
|
||||
recommendations = append(recommendations, message(lang, "rec_maintain"))
|
||||
}
|
||||
|
||||
return HealthResult{
|
||||
Repository: input.Repository,
|
||||
HealthScore: healthScore,
|
||||
RiskLevel: riskLevel(healthScore),
|
||||
Metrics: metrics,
|
||||
Recommendations: uniqueStrings(recommendations),
|
||||
ScoringNotes: notes,
|
||||
}
|
||||
}
|
||||
|
||||
func scoreIssueBacklog(input HealthInput, lang string) HealthMetric {
|
||||
score := 20
|
||||
score -= minInt(input.StaleIssues*3, 14)
|
||||
if input.OpenIssues > 50 {
|
||||
score -= 8
|
||||
} else if input.OpenIssues > 20 {
|
||||
score -= 5
|
||||
} else if input.OpenIssues > 10 {
|
||||
score -= 2
|
||||
}
|
||||
score = clampInt(score, 0, 20)
|
||||
|
||||
status := "good"
|
||||
reason := message(lang, "health_issue_backlog_good")
|
||||
if score < 14 {
|
||||
status = "attention"
|
||||
reason = message(lang, "health_issue_backlog_attention")
|
||||
}
|
||||
return HealthMetric{Name: "issue_backlog_and_response", Status: status, Score: score, MaxScore: 20, Reason: reason}
|
||||
}
|
||||
|
||||
func scorePRBacklog(input HealthInput, lang string) HealthMetric {
|
||||
score := 20
|
||||
score -= minInt(input.StalePRs*5, 15)
|
||||
if input.OpenPRs > 20 {
|
||||
score -= 8
|
||||
} else if input.OpenPRs > 10 {
|
||||
score -= 5
|
||||
} else if input.OpenPRs > 5 {
|
||||
score -= 2
|
||||
}
|
||||
score = clampInt(score, 0, 20)
|
||||
|
||||
status := "good"
|
||||
reason := message(lang, "health_pr_backlog_good")
|
||||
if score < 14 {
|
||||
status = "attention"
|
||||
reason = message(lang, "health_pr_backlog_attention")
|
||||
}
|
||||
return HealthMetric{Name: "pr_backlog_and_merge_state", Status: status, Score: score, MaxScore: 20, Reason: reason}
|
||||
}
|
||||
|
||||
func scoreRecentActivity(input HealthInput, lang string) (HealthMetric, ScoringNote) {
|
||||
if !input.RecentActivityKnown {
|
||||
return HealthMetric{Name: "recent_activity", Status: "unknown", Score: 8, MaxScore: 15, Reason: message(lang, "health_recent_unknown")}, ScoringNote{Metric: "recent_activity", Note: message(lang, "health_recent_unknown")}
|
||||
}
|
||||
if input.RecentActivityDays <= 7 {
|
||||
return HealthMetric{Name: "recent_activity", Status: "good", Score: 15, MaxScore: 15, Reason: "recent activity within 7 days"}, ScoringNote{}
|
||||
}
|
||||
if input.RecentActivityDays <= 30 {
|
||||
return HealthMetric{Name: "recent_activity", Status: "attention", Score: 10, MaxScore: 15, Reason: "recent activity within 30 days"}, ScoringNote{}
|
||||
}
|
||||
if input.RecentActivityDays <= 90 {
|
||||
return HealthMetric{Name: "recent_activity", Status: "attention", Score: 6, MaxScore: 15, Reason: "recent activity older than 30 days"}, ScoringNote{}
|
||||
}
|
||||
return HealthMetric{Name: "recent_activity", Status: "risk", Score: 2, MaxScore: 15, Reason: "recent activity older than 90 days"}, ScoringNote{}
|
||||
}
|
||||
|
||||
func scoreReleaseStatus(input HealthInput, lang string) (HealthMetric, ScoringNote) {
|
||||
if !input.ReleaseKnown {
|
||||
return HealthMetric{Name: "release_status", Status: "unknown", Score: 8, MaxScore: 15, Reason: message(lang, "health_release_unknown")}, ScoringNote{Metric: "release_status", Note: message(lang, "health_release_unknown")}
|
||||
}
|
||||
if input.HasRecentRelease {
|
||||
return HealthMetric{Name: "release_status", Status: "good", Score: 15, MaxScore: 15, Reason: "recent release found"}, ScoringNote{}
|
||||
}
|
||||
return HealthMetric{Name: "release_status", Status: "risk", Score: 4, MaxScore: 15, Reason: "no recent release found"}, ScoringNote{}
|
||||
}
|
||||
|
||||
func scoreDocumentation(input HealthInput, lang string) HealthMetric {
|
||||
score := 0
|
||||
if input.HasReadme {
|
||||
score += 7
|
||||
}
|
||||
if input.HasContributing {
|
||||
score += 3
|
||||
}
|
||||
status := "attention"
|
||||
reason := message(lang, "rec_docs")
|
||||
if score == 10 {
|
||||
status = "good"
|
||||
reason = "README and contribution guidance are present"
|
||||
}
|
||||
return HealthMetric{Name: "documentation", Status: status, Score: score, MaxScore: 10, Reason: reason}
|
||||
}
|
||||
|
||||
func scoreLicenseAndContributing(input HealthInput, lang string) HealthMetric {
|
||||
score := 0
|
||||
if input.HasLicense {
|
||||
score += 6
|
||||
}
|
||||
if input.HasContributing {
|
||||
score += 4
|
||||
}
|
||||
status := "attention"
|
||||
reason := message(lang, "rec_license")
|
||||
if score == 10 {
|
||||
status = "good"
|
||||
reason = "LICENSE and CONTRIBUTING are present"
|
||||
}
|
||||
return HealthMetric{Name: "license_and_contributing", Status: status, Score: score, MaxScore: 10, Reason: reason}
|
||||
}
|
||||
|
||||
func scoreAgentReadiness(input HealthInput, lang string) (HealthMetric, ScoringNote) {
|
||||
if !input.AgentReadinessKnown {
|
||||
return HealthMetric{Name: "agent_readiness", Status: "unknown", Score: 5, MaxScore: 10, Reason: message(lang, "health_agent_unknown")}, ScoringNote{Metric: "agent_readiness", Note: message(lang, "health_agent_unknown")}
|
||||
}
|
||||
score := clampInt(input.AgentReadinessScore, 0, 10)
|
||||
status := "attention"
|
||||
if score >= 8 {
|
||||
status = "good"
|
||||
} else if score < 4 {
|
||||
status = "risk"
|
||||
}
|
||||
return HealthMetric{Name: "agent_readiness", Status: status, Score: score, MaxScore: 10, Reason: "agent readiness score provided"}, ScoringNote{}
|
||||
}
|
||||
|
||||
func scoreCIStatus(input HealthInput, lang string) (HealthMetric, ScoringNote) {
|
||||
if !input.CIKnown {
|
||||
return HealthMetric{Name: "ci_status", Status: "unknown", Score: 0, MaxScore: 0, Reason: message(lang, "health_ci_unknown")}, ScoringNote{Metric: "ci_status", Note: message(lang, "health_ci_unknown")}
|
||||
}
|
||||
if input.CIPassing {
|
||||
return HealthMetric{Name: "ci_status", Status: "good", Score: 0, MaxScore: 0, Reason: "CI status is passing"}, ScoringNote{}
|
||||
}
|
||||
return HealthMetric{Name: "ci_status", Status: "risk", Score: 0, MaxScore: 0, Reason: "CI status is failing"}, ScoringNote{}
|
||||
}
|
||||
|
||||
func riskLevel(score int) string {
|
||||
switch {
|
||||
case score >= 85:
|
||||
return "low"
|
||||
case score >= 65:
|
||||
return "medium"
|
||||
case score >= 40:
|
||||
return "high"
|
||||
default:
|
||||
return "critical"
|
||||
}
|
||||
}
|
||||
|
||||
func clampInt(value int, minValue int, maxValue int) int {
|
||||
if value < minValue {
|
||||
return minValue
|
||||
}
|
||||
if value > maxValue {
|
||||
return maxValue
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func minInt(a int, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScoreHealthLowRisk(t *testing.T) {
|
||||
result := ScoreHealth(HealthInput{
|
||||
Repository: "Gitlink/gitlink-cli",
|
||||
OpenIssues: 4,
|
||||
OpenPRs: 2,
|
||||
StaleIssues: 1,
|
||||
StalePRs: 0,
|
||||
RecentActivityKnown: true,
|
||||
RecentActivityDays: 3,
|
||||
ReleaseKnown: true,
|
||||
HasRecentRelease: true,
|
||||
CIKnown: true,
|
||||
CIPassing: true,
|
||||
HasReadme: true,
|
||||
HasLicense: true,
|
||||
HasContributing: true,
|
||||
AgentReadinessKnown: true,
|
||||
AgentReadinessScore: 9,
|
||||
}, "en")
|
||||
|
||||
if result.HealthScore < 85 {
|
||||
t.Fatalf("HealthScore = %d, want >= 85", result.HealthScore)
|
||||
}
|
||||
if result.RiskLevel != "low" {
|
||||
t.Fatalf("RiskLevel = %q, want low", result.RiskLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreHealthHighRisk(t *testing.T) {
|
||||
result := ScoreHealth(HealthInput{
|
||||
Repository: "Gitlink/gitlink-cli",
|
||||
OpenIssues: 80,
|
||||
OpenPRs: 25,
|
||||
StaleIssues: 20,
|
||||
StalePRs: 10,
|
||||
RecentActivityKnown: true,
|
||||
RecentActivityDays: 120,
|
||||
ReleaseKnown: true,
|
||||
HasRecentRelease: false,
|
||||
CIKnown: true,
|
||||
CIPassing: false,
|
||||
HasReadme: false,
|
||||
HasLicense: false,
|
||||
HasContributing: false,
|
||||
AgentReadinessKnown: true,
|
||||
AgentReadinessScore: 2,
|
||||
}, "en")
|
||||
|
||||
if result.HealthScore >= 65 {
|
||||
t.Fatalf("HealthScore = %d, want < 65", result.HealthScore)
|
||||
}
|
||||
if result.RiskLevel != "high" && result.RiskLevel != "critical" {
|
||||
t.Fatalf("RiskLevel = %q, want high or critical", result.RiskLevel)
|
||||
}
|
||||
if len(result.Recommendations) == 0 {
|
||||
t.Fatal("Recommendations is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreHealthUnknownMetrics(t *testing.T) {
|
||||
result := ScoreHealth(HealthInput{
|
||||
Repository: "Gitlink/gitlink-cli",
|
||||
RecentActivityKnown: false,
|
||||
ReleaseKnown: false,
|
||||
CIKnown: false,
|
||||
HasReadme: true,
|
||||
HasLicense: true,
|
||||
HasContributing: false,
|
||||
AgentReadinessKnown: false,
|
||||
}, "en")
|
||||
|
||||
if len(result.ScoringNotes) == 0 {
|
||||
t.Fatal("ScoringNotes is empty")
|
||||
}
|
||||
if result.HealthScore < 0 || result.HealthScore > 100 {
|
||||
t.Fatalf("HealthScore = %d, want between 0 and 100", result.HealthScore)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScoreHealthChinese(t *testing.T) {
|
||||
result := ScoreHealth(HealthInput{
|
||||
Repository: "Gitlink/gitlink-cli",
|
||||
OpenIssues: 40,
|
||||
OpenPRs: 12,
|
||||
StaleIssues: 8,
|
||||
StalePRs: 4,
|
||||
RecentActivityKnown: true,
|
||||
RecentActivityDays: 45,
|
||||
ReleaseKnown: true,
|
||||
HasRecentRelease: false,
|
||||
CIKnown: false,
|
||||
HasReadme: false,
|
||||
HasLicense: false,
|
||||
HasContributing: false,
|
||||
AgentReadinessKnown: true,
|
||||
AgentReadinessScore: 3,
|
||||
}, "zh-CN")
|
||||
|
||||
if len(result.Recommendations) == 0 {
|
||||
t.Fatal("Recommendations is empty")
|
||||
}
|
||||
joined := strings.Join(result.Recommendations, "")
|
||||
if !strings.Contains(joined, "建议") && !strings.Contains(joined, "补充") && !strings.Contains(joined, "减少") {
|
||||
t.Fatalf("Recommendations = %v, want Chinese content", result.Recommendations)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
package workflow
|
||||
|
||||
func normalizeLang(lang string) string {
|
||||
switch lang {
|
||||
case "", langEN:
|
||||
return langEN
|
||||
case langZH:
|
||||
return langZH
|
||||
default:
|
||||
return langEN
|
||||
}
|
||||
}
|
||||
|
||||
func message(lang string, key string) string {
|
||||
lang = normalizeLang(lang)
|
||||
if value, ok := messages[lang][key]; ok {
|
||||
return value
|
||||
}
|
||||
if value, ok := messages[langEN][key]; ok {
|
||||
return value
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
var messages = map[string]map[string]string{
|
||||
langEN: {
|
||||
"missing_reproduction_steps": "reproduction_steps",
|
||||
"missing_expected_behavior": "expected_behavior",
|
||||
"missing_actual_behavior": "actual_behavior",
|
||||
"missing_version": "version",
|
||||
"missing_os_or_platform": "os_or_platform",
|
||||
"missing_command_output_or_logs": "command_output_or_logs",
|
||||
"comment_more_info": "Thanks for the report. Please add the missing information so maintainers can reproduce and investigate it: %s.",
|
||||
"comment_security": "Thanks for the security report. Please avoid sharing secrets publicly and rotate any exposed credentials. Maintainers should verify the sensitive details in a private channel.",
|
||||
"comment_docs": "Thanks for the documentation report. Please point to the affected document or example if possible.",
|
||||
"comment_default": "Thanks for the report. Maintainers can use the triage result above to decide the next step.",
|
||||
"health_issue_backlog_good": "Issue backlog is under control.",
|
||||
"health_issue_backlog_attention": "Reduce stale or excessive open issues.",
|
||||
"health_pr_backlog_good": "Pull request backlog is under control.",
|
||||
"health_pr_backlog_attention": "Review stale or excessive open pull requests.",
|
||||
"health_recent_unknown": "Recent activity is unknown and was scored conservatively.",
|
||||
"health_release_unknown": "Release status is unknown and was scored conservatively.",
|
||||
"health_ci_unknown": "CI status is unknown and is reported without changing the score.",
|
||||
"health_agent_unknown": "Agent readiness is unknown and was scored conservatively.",
|
||||
"rec_maintain": "Maintain the current workflow and keep metadata up to date.",
|
||||
"rec_reduce_issues": "Reduce stale issues and add response labels or next actions.",
|
||||
"rec_reduce_prs": "Review stale pull requests and clarify merge blockers.",
|
||||
"rec_restore_activity": "Create recent maintenance activity or document project status.",
|
||||
"rec_release": "Publish or document a recent release cadence.",
|
||||
"rec_docs": "Add or improve README and contribution guidance.",
|
||||
"rec_license": "Add LICENSE and CONTRIBUTING files for contributor clarity.",
|
||||
"rec_agent": "Improve agent readiness with stable docs, examples, and machine-readable outputs.",
|
||||
},
|
||||
langZH: {
|
||||
"missing_reproduction_steps": "复现步骤",
|
||||
"missing_expected_behavior": "期望行为",
|
||||
"missing_actual_behavior": "实际行为",
|
||||
"missing_version": "版本信息",
|
||||
"missing_os_or_platform": "操作系统或平台",
|
||||
"missing_command_output_or_logs": "命令输出或日志",
|
||||
"comment_more_info": "感谢反馈。请补充以下信息,方便维护者复现和定位问题:%s。",
|
||||
"comment_security": "感谢安全反馈。请不要公开扩散密钥或敏感信息,并尽快轮换可能泄露的凭据。维护者应优先通过私密渠道确认细节。",
|
||||
"comment_docs": "感谢文档反馈。请尽量说明受影响的文档、示例或章节位置。",
|
||||
"comment_default": "感谢反馈。维护者可以根据上面的分诊结果安排下一步处理。",
|
||||
"health_issue_backlog_good": "Issue 积压处于可控状态。",
|
||||
"health_issue_backlog_attention": "建议减少长期未处理或数量过多的开放 Issue。",
|
||||
"health_pr_backlog_good": "PR 积压处于可控状态。",
|
||||
"health_pr_backlog_attention": "建议审查长期未处理或数量过多的开放 PR。",
|
||||
"health_recent_unknown": "最近活跃度未知,已按保守方式评分。",
|
||||
"health_release_unknown": "Release 状态未知,已按保守方式评分。",
|
||||
"health_ci_unknown": "CI 状态未知,仅记录为说明,不影响总分。",
|
||||
"health_agent_unknown": "Agent 友好度未知,已按保守方式评分。",
|
||||
"rec_maintain": "保持当前维护节奏,并持续更新仓库元信息。",
|
||||
"rec_reduce_issues": "减少长期未处理的 Issue,并补充响应标签或下一步动作。",
|
||||
"rec_reduce_prs": "审查长期未处理的 PR,并明确合并阻塞点。",
|
||||
"rec_restore_activity": "恢复近期维护活动,或在文档中说明项目状态。",
|
||||
"rec_release": "发布近期版本,或在文档中说明发布节奏。",
|
||||
"rec_docs": "补充或改进 README 与贡献指南。",
|
||||
"rec_license": "补充 LICENSE 和 CONTRIBUTING,降低贡献者理解成本。",
|
||||
"rec_agent": "通过稳定文档、示例和机器可读输出提升 Agent 友好度。",
|
||||
},
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
package workflow
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestNormalizeLang(t *testing.T) {
|
||||
if got := normalizeLang(""); got != langEN {
|
||||
t.Fatalf("normalizeLang(\"\") = %q, want %q", got, langEN)
|
||||
}
|
||||
if got := normalizeLang(langZH); got != langZH {
|
||||
t.Fatalf("normalizeLang(%q) = %q, want %q", langZH, got, langZH)
|
||||
}
|
||||
if got := normalizeLang("fr"); got != langEN {
|
||||
t.Fatalf("normalizeLang(\"fr\") = %q, want %q", got, langEN)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageFallback(t *testing.T) {
|
||||
if got := message("fr", "rec_maintain"); got == "" || got == "rec_maintain" {
|
||||
t.Fatalf("message fallback = %q, want English message", got)
|
||||
}
|
||||
if got := message(langEN, "not_found_key"); got != "not_found_key" {
|
||||
t.Fatalf("message unknown key = %q, want key", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
)
|
||||
|
||||
func renderTriageReport(w io.Writer, report TriageReport, format string) error {
|
||||
switch normalizeFormat(format) {
|
||||
case "json":
|
||||
return writeJSON(w, report)
|
||||
case "markdown":
|
||||
return writeTriageMarkdown(w, report)
|
||||
case "table":
|
||||
return writeTriageTable(w, report)
|
||||
default:
|
||||
return fmt.Errorf("unsupported workflow output format %q", format)
|
||||
}
|
||||
}
|
||||
|
||||
func renderHealthResult(w io.Writer, result HealthResult, format string) error {
|
||||
switch normalizeFormat(format) {
|
||||
case "json":
|
||||
return writeJSON(w, result)
|
||||
case "markdown":
|
||||
return writeHealthMarkdown(w, result)
|
||||
case "table":
|
||||
return writeHealthTable(w, result)
|
||||
default:
|
||||
return fmt.Errorf("unsupported workflow output format %q", format)
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeFormat(format string) string {
|
||||
format = strings.ToLower(strings.TrimSpace(format))
|
||||
if format == "" {
|
||||
return "json"
|
||||
}
|
||||
return format
|
||||
}
|
||||
|
||||
func writeJSON(w io.Writer, data interface{}) error {
|
||||
encoded, err := json.MarshalIndent(data, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = fmt.Fprintln(w, string(encoded))
|
||||
return err
|
||||
}
|
||||
|
||||
func writeTriageTable(w io.Writer, report TriageReport) error {
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
if _, err := fmt.Fprintln(tw, "NUMBER\tTYPE\tPRIORITY\tCONFIDENCE\tMISSING\tACTION"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, result := range report.Results {
|
||||
missing := "-"
|
||||
if len(result.MissingInformation) > 0 {
|
||||
missing = strings.Join(result.MissingInformation, ",")
|
||||
}
|
||||
if _, err := fmt.Fprintf(tw, "%d\t%s\t%s\t%d\t%s\t%s\n",
|
||||
result.Issue.Number,
|
||||
result.DetectedType,
|
||||
result.Priority,
|
||||
result.Confidence,
|
||||
missing,
|
||||
result.RecommendedAction,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func writeHealthTable(w io.Writer, result HealthResult) error {
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
if _, err := fmt.Fprintf(tw, "REPOSITORY\tSCORE\tRISK\n%s\t%d\t%s\n\n", result.Repository, result.HealthScore, result.RiskLevel); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(tw, "METRIC\tSTATUS\tSCORE\tMAX\tREASON"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, metric := range result.Metrics {
|
||||
if _, err := fmt.Fprintf(tw, "%s\t%s\t%d\t%d\t%s\n", metric.Name, metric.Status, metric.Score, metric.MaxScore, metric.Reason); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
func writeTriageMarkdown(w io.Writer, report TriageReport) error {
|
||||
if _, err := fmt.Fprintf(w, "# Issue Triage Report\n\nRepository: `%s`\n\n", report.Repository); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, "| Issue | Type | Priority | Confidence | Action | Missing Information |"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, "| --- | --- | --- | ---: | --- | --- |"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, result := range report.Results {
|
||||
missing := "-"
|
||||
if len(result.MissingInformation) > 0 {
|
||||
missing = strings.Join(result.MissingInformation, ", ")
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "| #%d | %s | %s | %d | %s | %s |\n",
|
||||
result.Issue.Number,
|
||||
result.DetectedType,
|
||||
result.Priority,
|
||||
result.Confidence,
|
||||
result.RecommendedAction,
|
||||
missing,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeHealthMarkdown(w io.Writer, result HealthResult) error {
|
||||
if _, err := fmt.Fprintf(w, "# Repository Health Report\n\nRepository: `%s`\n\nHealth score: **%d**\n\nRisk level: **%s**\n\n", result.Repository, result.HealthScore, result.RiskLevel); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, "| Metric | Status | Score | Max | Reason |"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, "| --- | --- | ---: | ---: | --- |"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, metric := range result.Metrics {
|
||||
if _, err := fmt.Fprintf(w, "| %s | %s | %d | %d | %s |\n", metric.Name, metric.Status, metric.Score, metric.MaxScore, metric.Reason); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(result.Recommendations) == 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, "\n## Recommendations"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(w); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, recommendation := range result.Recommendations {
|
||||
if _, err := fmt.Fprintf(w, "- %s\n", recommendation); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"repository": "Gitlink/gitlink-cli",
|
||||
"open_issues": 3,
|
||||
"open_prs": 1,
|
||||
"stale_issues": 0,
|
||||
"stale_prs": 0,
|
||||
"recent_activity_known": true,
|
||||
"recent_activity_days": 3,
|
||||
"release_known": true,
|
||||
"has_recent_release": true,
|
||||
"ci_known": true,
|
||||
"ci_passing": true,
|
||||
"has_readme": true,
|
||||
"has_license": true,
|
||||
"has_contributing": true,
|
||||
"agent_readiness_known": true,
|
||||
"agent_readiness_score": 9
|
||||
}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
{
|
||||
"repository": "demo/repo",
|
||||
"open_issues": 60,
|
||||
"open_prs": 12,
|
||||
"stale_issues": 25,
|
||||
"stale_prs": 6,
|
||||
"recent_activity_known": true,
|
||||
"recent_activity_days": 120,
|
||||
"release_known": true,
|
||||
"has_recent_release": false,
|
||||
"ci_known": false,
|
||||
"ci_passing": false,
|
||||
"has_readme": false,
|
||||
"has_license": false,
|
||||
"has_contributing": false,
|
||||
"agent_readiness_known": true,
|
||||
"agent_readiness_score": 2
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"number": 12,
|
||||
"title": "Install failed on Windows",
|
||||
"body": "go install failed with error. Expected behavior: install succeeds. Actual behavior: the command returns a build failure. Version: v1.0.0. OS: Windows 11. Output: build failed with exit code 1.",
|
||||
"state": "open",
|
||||
"author": "alice",
|
||||
"url": "https://example.com/issues/12",
|
||||
"labels": ["bug"]
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"number": 21,
|
||||
"title": "Token leaked in logs",
|
||||
"body": "The access token appears in command output. This looks like a possible secret leak and auth problem.",
|
||||
"state": "open",
|
||||
"author": "bob",
|
||||
"url": "https://example.com/issues/21",
|
||||
"labels": ["security", "urgent"]
|
||||
}
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func FetchIssuesForTriage(ctx *common.RuntimeContext, opts TriageFetchOptions) ([]IssueInput, error) {
|
||||
owner, repo, err := resolveFetchRepo(ctx, opts.Owner, opts.Repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
limit := opts.Limit
|
||||
if limit <= 0 {
|
||||
limit = 30
|
||||
}
|
||||
page := opts.Page
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
state := strings.TrimSpace(opts.State)
|
||||
if state == "" {
|
||||
state = "open"
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("state", state)
|
||||
query.Set("limit", fmt.Sprintf("%d", limit))
|
||||
query.Set("page", fmt.Sprintf("%d", page))
|
||||
if len(opts.Labels) > 0 {
|
||||
query.Set("labels", strings.Join(opts.Labels, ","))
|
||||
}
|
||||
if strings.TrimSpace(opts.Since) != "" {
|
||||
query.Set("since", strings.TrimSpace(opts.Since))
|
||||
}
|
||||
|
||||
env, err := ctx.CallAPIWithQuery("GET", workflowRepoPath(owner, repo)+"/issues", query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch issues for triage: %w", err)
|
||||
}
|
||||
|
||||
items := apiList(env.Data)
|
||||
issues := make([]IssueInput, 0, len(items))
|
||||
for _, raw := range items {
|
||||
issue, ok := normalizeIssueItem(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
issues = append(issues, issue)
|
||||
if len(issues) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(issues) == 0 {
|
||||
return nil, fmt.Errorf("fetch issues for triage: no issues found in API response")
|
||||
}
|
||||
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
func normalizeIssueItem(raw interface{}) (IssueInput, bool) {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
return IssueInput{}, false
|
||||
}
|
||||
|
||||
title := firstIssueString(item, "title", "subject")
|
||||
body := firstIssueString(item, "body", "description", "content")
|
||||
if strings.TrimSpace(title) == "" && strings.TrimSpace(body) == "" {
|
||||
return IssueInput{}, false
|
||||
}
|
||||
|
||||
number := firstIssueInt(item, "number", "iid", "issue_number", "project_issues_index", "id")
|
||||
id := firstIssueString(item, "id")
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("%d", number)
|
||||
}
|
||||
state := firstIssueString(item, "state", "status")
|
||||
author := firstIssueString(item, "author", "user", "creator")
|
||||
urlValue := firstIssueString(item, "html_url", "url", "web_url")
|
||||
labels := firstIssueLabels(item["labels"])
|
||||
createdAt := firstIssueTime(item, "created_at", "created")
|
||||
updatedAt := firstIssueTime(item, "updated_at", "updated", "last_updated_at")
|
||||
comments := firstIssueInt(item, "comments_count", "comments")
|
||||
|
||||
return IssueInput{
|
||||
ID: id,
|
||||
Number: number,
|
||||
Title: title,
|
||||
Body: body,
|
||||
State: state,
|
||||
Author: apiAuthor(authorValue(item, author)),
|
||||
URL: urlValue,
|
||||
Labels: labels,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
CommentsCount: comments,
|
||||
}, true
|
||||
}
|
||||
|
||||
func authorValue(item map[string]interface{}, fallback string) interface{} {
|
||||
if raw, ok := item["author"]; ok {
|
||||
return raw
|
||||
}
|
||||
if raw, ok := item["user"]; ok {
|
||||
return raw
|
||||
}
|
||||
if raw, ok := item["creator"]; ok {
|
||||
return raw
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func firstIssueString(item map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := item[key]; ok {
|
||||
if s := apiString(value); s != "" {
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func firstIssueInt(item map[string]interface{}, keys ...string) int {
|
||||
for _, key := range keys {
|
||||
if value, ok := item[key]; ok {
|
||||
if n := apiInt(value); n != 0 {
|
||||
return n
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstIssueTime(item map[string]interface{}, keys ...string) time.Time {
|
||||
for _, key := range keys {
|
||||
if value, ok := item[key]; ok {
|
||||
if t := apiTime(value); !t.IsZero() {
|
||||
return t
|
||||
}
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
|
||||
func firstIssueLabels(value interface{}) []string {
|
||||
switch labels := value.(type) {
|
||||
case []interface{}:
|
||||
out := make([]string, 0, len(labels))
|
||||
for _, label := range labels {
|
||||
if s := apiStringValue(label); s != "" {
|
||||
out = append(out, s)
|
||||
}
|
||||
}
|
||||
return out
|
||||
case []string:
|
||||
return append([]string(nil), labels...)
|
||||
case string:
|
||||
return apiStringSlice(labels)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestFetchIssuesForTriageNormalizesAPIResponse(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("state"); got != "open" {
|
||||
t.Fatalf("state query = %q, want open", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("limit"); got != "30" {
|
||||
t.Fatalf("limit query = %q, want 30", got)
|
||||
}
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"issues": []map[string]interface{}{
|
||||
{
|
||||
"id": 12345,
|
||||
"project_issues_index": 7,
|
||||
"subject": "Crash on login",
|
||||
"description": "panic error when running login",
|
||||
"status": "open",
|
||||
"author": map[string]interface{}{"login": "alice"},
|
||||
"labels": []map[string]interface{}{{"name": "bug"}, {"name": "login"}},
|
||||
"created_at": "2026-05-01T10:00:00Z",
|
||||
"updated_at": "2026-05-02T10:00:00Z",
|
||||
"comments_count": 2,
|
||||
"html_url": "https://www.gitlink.org.cn/owner/repo/issues/7",
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := workflowTestContext(server)
|
||||
issues, err := FetchIssuesForTriage(ctx, TriageFetchOptions{State: "open", Limit: 30, Page: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchIssuesForTriage returned error: %v", err)
|
||||
}
|
||||
if len(issues) != 1 {
|
||||
t.Fatalf("len(issues) = %d, want 1", len(issues))
|
||||
}
|
||||
issue := issues[0]
|
||||
if issue.ID != "12345" {
|
||||
t.Fatalf("ID = %q, want 12345", issue.ID)
|
||||
}
|
||||
if issue.Number != 7 {
|
||||
t.Fatalf("Number = %d, want 7", issue.Number)
|
||||
}
|
||||
if issue.Title != "Crash on login" {
|
||||
t.Fatalf("Title = %q, want Crash on login", issue.Title)
|
||||
}
|
||||
if issue.Author != "alice" {
|
||||
t.Fatalf("Author = %q, want alice", issue.Author)
|
||||
}
|
||||
if len(issue.Labels) != 2 || issue.Labels[0] != "bug" || issue.Labels[1] != "login" {
|
||||
t.Fatalf("Labels = %v, want [bug login]", issue.Labels)
|
||||
}
|
||||
if issue.CreatedAt.IsZero() || issue.UpdatedAt.IsZero() {
|
||||
t.Fatalf("expected parsed timestamps, got created=%v updated=%v", issue.CreatedAt, issue.UpdatedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchIssuesForTriageSupportsDataString(t *testing.T) {
|
||||
payload, err := json.Marshal([]map[string]interface{}{
|
||||
{
|
||||
"number": 3,
|
||||
"title": "README typo",
|
||||
"body": "documentation example typo",
|
||||
"state": "open",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal returned error: %v", err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"data": string(payload)})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
issues, err := FetchIssuesForTriage(workflowTestContext(server), TriageFetchOptions{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchIssuesForTriage returned error: %v", err)
|
||||
}
|
||||
if len(issues) != 1 {
|
||||
t.Fatalf("len(issues) = %d, want 1", len(issues))
|
||||
}
|
||||
if issues[0].Number != 3 || issues[0].Title != "README typo" {
|
||||
t.Fatalf("issue = %+v, want number 3 title README typo", issues[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchIssuesForTriageEmptyResponseReturnsError(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{}})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := FetchIssuesForTriage(workflowTestContext(server), TriageFetchOptions{Limit: 10})
|
||||
if err == nil {
|
||||
t.Fatal("FetchIssuesForTriage returned nil error for empty response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "no issues found") {
|
||||
t.Fatalf("error = %v, want empty-response message", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchIssuesForTriageNormalizesLabelAndAuthorShapes(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.URL.Query().Get("page"); got != "1" {
|
||||
t.Fatalf("page query = %q, want 1", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("limit"); got != "3" {
|
||||
t.Fatalf("limit query = %q, want 3", got)
|
||||
}
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"issues": []map[string]interface{}{
|
||||
{
|
||||
"number": 1,
|
||||
"body": "panic on install",
|
||||
"labels": []string{"bug", "help wanted"},
|
||||
"author": "alice",
|
||||
},
|
||||
{
|
||||
"number": 2,
|
||||
"title": "token leak",
|
||||
"labels": []map[string]interface{}{{"name": "bug"}, {"name": "security"}},
|
||||
"user": map[string]interface{}{"login": "bob"},
|
||||
},
|
||||
{
|
||||
"number": 3,
|
||||
"title": "README typo",
|
||||
"labels": []map[string]interface{}{{"title": "docs"}},
|
||||
"creator": map[string]interface{}{"name": "carol"},
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
issues, err := FetchIssuesForTriage(workflowTestContext(server), TriageFetchOptions{State: "open", Limit: 3, Page: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchIssuesForTriage returned error: %v", err)
|
||||
}
|
||||
if len(issues) != 3 {
|
||||
t.Fatalf("len(issues) = %d, want 3", len(issues))
|
||||
}
|
||||
if issues[0].Title != "" || issues[0].Body != "panic on install" {
|
||||
t.Fatalf("issue[0] = %+v, want body-only item with empty title", issues[0])
|
||||
}
|
||||
if got := strings.Join(issues[0].Labels, ","); got != "bug,help wanted" {
|
||||
t.Fatalf("issue[0].Labels = %q, want bug,help wanted", got)
|
||||
}
|
||||
if issues[0].Author != "alice" {
|
||||
t.Fatalf("issue[0].Author = %q, want alice", issues[0].Author)
|
||||
}
|
||||
if got := strings.Join(issues[1].Labels, ","); got != "bug,security" {
|
||||
t.Fatalf("issue[1].Labels = %q, want bug,security", got)
|
||||
}
|
||||
if issues[1].Author != "bob" {
|
||||
t.Fatalf("issue[1].Author = %q, want bob", issues[1].Author)
|
||||
}
|
||||
if got := strings.Join(issues[2].Labels, ","); got != "docs" {
|
||||
t.Fatalf("issue[2].Labels = %q, want docs", got)
|
||||
}
|
||||
if issues[2].Author != "carol" {
|
||||
t.Fatalf("issue[2].Author = %q, want carol", issues[2].Author)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchIssuesForTriageRespectsLimitAndReportsRequestErrors(t *testing.T) {
|
||||
requestCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestCount++
|
||||
if got := r.URL.Query().Get("page"); got != "2" {
|
||||
t.Fatalf("page query = %q, want 2", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("limit"); got != "1" {
|
||||
t.Fatalf("limit query = %q, want 1", got)
|
||||
}
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"issues": []map[string]interface{}{
|
||||
{"number": 1, "title": "first"},
|
||||
{"number": 2, "title": "second"},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
issues, err := FetchIssuesForTriage(workflowTestContext(server), TriageFetchOptions{State: "open", Limit: 1, Page: 2})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchIssuesForTriage returned error: %v", err)
|
||||
}
|
||||
if requestCount != 1 {
|
||||
t.Fatalf("requestCount = %d, want 1", requestCount)
|
||||
}
|
||||
if len(issues) != 1 {
|
||||
t.Fatalf("len(issues) = %d, want 1", len(issues))
|
||||
}
|
||||
if issues[0].Number != 1 {
|
||||
t.Fatalf("issues[0].Number = %d, want 1", issues[0].Number)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchIssuesForTriageReportsGitLinkErrorInBody(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"status": 403,
|
||||
"message": "permission denied",
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := FetchIssuesForTriage(workflowTestContext(server), TriageFetchOptions{Limit: 10})
|
||||
if err == nil {
|
||||
t.Fatal("FetchIssuesForTriage returned nil error for error-in-body response")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "permission denied") {
|
||||
t.Fatalf("error = %v, want permission denied", err)
|
||||
}
|
||||
}
|
||||
|
||||
func workflowTestContext(server *httptest.Server) *common.RuntimeContext {
|
||||
return &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: map[string]string{},
|
||||
}
|
||||
}
|
||||
|
||||
func writeWorkflowJSON(t *testing.T, w http.ResponseWriter, payload interface{}) {
|
||||
t.Helper()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err := json.NewEncoder(w).Encode(payload); err != nil {
|
||||
t.Fatalf("failed to write response: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,306 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type keywordRule struct {
|
||||
issueType string
|
||||
keywords []string
|
||||
weight int
|
||||
}
|
||||
|
||||
var triageKeywordRules = []keywordRule{
|
||||
{IssueTypeBug, []string{"crash", "error", "panic", "exception", "fail", "failed", "failure", "broken", "cannot", "bug", "报错", "崩溃", "异常", "失败", "无法", "不能", "问题"}, 10},
|
||||
{IssueTypeFeature, []string{"feature", "request", "support", "add", "implement", "enhancement", "功能", "支持", "增加", "建议", "增强"}, 8},
|
||||
{IssueTypeQuestion, []string{"how", "why", "help", "usage", "question", "如何", "怎么", "为什么", "请问", "求助"}, 7},
|
||||
{IssueTypeDocs, []string{"doc", "docs", "documentation", "readme", "typo", "example", "guide", "文档", "说明", "错别字", "示例", "教程"}, 8},
|
||||
{IssueTypeCI, []string{"ci", "build", "workflow", "action", "test failed", "pipeline", "构建", "测试失败", "流水线"}, 10},
|
||||
{IssueTypeSecurity, []string{"token", "leak", "leaked", "secret", "auth", "permission", "vulnerability", "cve", "泄露", "密钥", "权限", "漏洞", "安全"}, 12},
|
||||
{IssueTypePerformance, []string{"slow", "timeout", "latency", "memory", "cpu", "performance", "慢", "超时", "性能", "内存"}, 8},
|
||||
{IssueTypeRefactor, []string{"refactor", "cleanup", "simplify", "restructure", "重构", "清理", "简化", "结构调整"}, 6},
|
||||
}
|
||||
|
||||
var typeTieOrder = []string{
|
||||
IssueTypeSecurity,
|
||||
IssueTypeBug,
|
||||
IssueTypeCI,
|
||||
IssueTypePerformance,
|
||||
IssueTypeFeature,
|
||||
IssueTypeDocs,
|
||||
IssueTypeQuestion,
|
||||
IssueTypeRefactor,
|
||||
}
|
||||
|
||||
func AnalyzeIssue(input IssueInput, lang string) TriageResult {
|
||||
lang = normalizeLang(lang)
|
||||
text := normalizeIssueText(input)
|
||||
|
||||
scores, matchedRules, reasoning := scoreIssueTypes(text, input.Labels)
|
||||
detectedType := chooseDetectedType(scores)
|
||||
riskFlags := detectRiskFlags(text, detectedType)
|
||||
priority, priorityReason := determinePriority(text, detectedType)
|
||||
if priorityReason != "" {
|
||||
matchedRules = append(matchedRules, priorityReason)
|
||||
reasoning = append(reasoning, priorityReason)
|
||||
}
|
||||
|
||||
missingInformation := detectMissingInformation(text, detectedType, lang)
|
||||
if len(missingInformation) > 0 {
|
||||
riskFlags = append(riskFlags, RiskInsufficientInfo)
|
||||
matchedRules = append(matchedRules, "missing information detected")
|
||||
reasoning = append(reasoning, "missing information detected")
|
||||
}
|
||||
|
||||
confidence := calculateConfidence(detectedType, scores, matchedRules, input.Labels)
|
||||
return TriageResult{
|
||||
Issue: IssueRef{
|
||||
ID: input.ID,
|
||||
Number: input.Number,
|
||||
Title: input.Title,
|
||||
URL: input.URL,
|
||||
Author: input.Author,
|
||||
State: input.State,
|
||||
},
|
||||
DetectedType: detectedType,
|
||||
Priority: priority,
|
||||
Confidence: confidence,
|
||||
SuggestedLabels: suggestedLabels(detectedType, priority, riskFlags),
|
||||
MissingInformation: missingInformation,
|
||||
RiskFlags: uniqueStrings(riskFlags),
|
||||
RecommendedAction: recommendedAction(detectedType, priority, riskFlags, missingInformation),
|
||||
SuggestedComment: suggestedComment(lang, detectedType, missingInformation),
|
||||
Reasoning: uniqueStrings(reasoning),
|
||||
MatchedRules: uniqueStrings(matchedRules),
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeIssueText(input IssueInput) string {
|
||||
parts := []string{input.Title, input.Body}
|
||||
parts = append(parts, input.Labels...)
|
||||
return strings.ToLower(strings.Join(parts, "\n"))
|
||||
}
|
||||
|
||||
func scoreIssueTypes(text string, labels []string) (map[string]int, []string, []string) {
|
||||
scores := make(map[string]int)
|
||||
matchedRules := []string{}
|
||||
reasoning := []string{}
|
||||
|
||||
for _, rule := range triageKeywordRules {
|
||||
for _, keyword := range rule.keywords {
|
||||
if strings.Contains(text, strings.ToLower(keyword)) {
|
||||
scores[rule.issueType] += rule.weight
|
||||
ruleText := "matched keyword: " + keyword
|
||||
matchedRules = append(matchedRules, ruleText)
|
||||
reasoning = append(reasoning, fmt.Sprintf("%s -> %s", ruleText, rule.issueType))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _, label := range labels {
|
||||
normalized := strings.ToLower(strings.TrimSpace(label))
|
||||
for _, issueType := range typeTieOrder {
|
||||
if normalized == issueType || strings.Contains(normalized, issueType) {
|
||||
scores[issueType] += 14
|
||||
ruleText := "label matched: " + normalized
|
||||
matchedRules = append(matchedRules, ruleText)
|
||||
reasoning = append(reasoning, fmt.Sprintf("%s -> %s", ruleText, issueType))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scores, matchedRules, reasoning
|
||||
}
|
||||
|
||||
func chooseDetectedType(scores map[string]int) string {
|
||||
bestType := IssueTypeUnknown
|
||||
bestScore := 0
|
||||
for _, issueType := range typeTieOrder {
|
||||
score := scores[issueType]
|
||||
if score > bestScore {
|
||||
bestType = issueType
|
||||
bestScore = score
|
||||
}
|
||||
}
|
||||
return bestType
|
||||
}
|
||||
|
||||
func determinePriority(text string, detectedType string) (string, string) {
|
||||
if detectedType == IssueTypeSecurity || containsAny(text, []string{"token leak", "secret leak", "leaked token", "leaked secret", "auth bypass", "permission bypass", "vulnerability", "cve", "密钥泄露", "漏洞", "认证绕过", "权限绕过"}) {
|
||||
return PriorityP0, "priority rule: security sensitive token leak"
|
||||
}
|
||||
if containsAny(text, []string{"cannot login", "install failed", "installation failed", "core command unavailable", "command unavailable", "login failed", "crash", "panic", "无法登录", "安装失败", "核心命令不可用", "崩溃"}) {
|
||||
return PriorityP1, "priority rule: core blocker"
|
||||
}
|
||||
if detectedType == IssueTypeBug || detectedType == IssueTypeCI || detectedType == IssueTypePerformance {
|
||||
return PriorityP2, "priority rule: normal bug or operational failure"
|
||||
}
|
||||
if detectedType == IssueTypeFeature {
|
||||
return PriorityP2, "priority rule: feature request"
|
||||
}
|
||||
return PriorityP3, "priority rule: low risk request"
|
||||
}
|
||||
|
||||
func calculateConfidence(detectedType string, scores map[string]int, matchedRules []string, labels []string) int {
|
||||
if detectedType == IssueTypeUnknown {
|
||||
return 20
|
||||
}
|
||||
|
||||
confidence := 35 + scores[detectedType]
|
||||
if len(matchedRules) > 1 {
|
||||
confidence += minInt(len(matchedRules)*3, 18)
|
||||
}
|
||||
if len(labels) > 0 {
|
||||
confidence += 8
|
||||
}
|
||||
if detectedType == IssueTypeSecurity || detectedType == IssueTypeBug || detectedType == IssueTypeCI {
|
||||
confidence += 10
|
||||
}
|
||||
return clampInt(confidence, 0, 100)
|
||||
}
|
||||
|
||||
func detectMissingInformation(text string, detectedType string, lang string) []string {
|
||||
if detectedType != IssueTypeBug {
|
||||
return nil
|
||||
}
|
||||
|
||||
checks := []struct {
|
||||
key string
|
||||
present []string
|
||||
messageK string
|
||||
}{
|
||||
{"reproduction_steps", []string{"reproduction", "reproduce", "steps", "复现", "步骤"}, "missing_reproduction_steps"},
|
||||
{"expected_behavior", []string{"expected", "expect", "期望", "预期"}, "missing_expected_behavior"},
|
||||
{"actual_behavior", []string{"actual", "实际"}, "missing_actual_behavior"},
|
||||
{"version", []string{"version", "版本"}, "missing_version"},
|
||||
{"os_or_platform", []string{"os", "platform", "windows", "linux", "macos", "darwin", "系统", "平台"}, "missing_os_or_platform"},
|
||||
{"command_output_or_logs", []string{"output", "log", "trace", "stdout", "stderr", "输出", "日志"}, "missing_command_output_or_logs"},
|
||||
}
|
||||
|
||||
missing := []string{}
|
||||
for _, check := range checks {
|
||||
if !containsAny(text, check.present) {
|
||||
if normalizeLang(lang) == langZH {
|
||||
missing = append(missing, message(lang, check.messageK))
|
||||
} else {
|
||||
missing = append(missing, check.key)
|
||||
}
|
||||
}
|
||||
}
|
||||
return missing
|
||||
}
|
||||
|
||||
func detectRiskFlags(text string, detectedType string) []string {
|
||||
flags := []string{}
|
||||
if detectedType == IssueTypeSecurity || containsAny(text, []string{"vulnerability", "cve", "漏洞", "安全", "auth bypass", "permission bypass", "认证绕过", "权限绕过"}) {
|
||||
flags = append(flags, RiskSecuritySensitive)
|
||||
}
|
||||
if containsAny(text, []string{"token leak", "secret leak", "leaked token", "leaked secret", "token leaked", "secret leaked", "密钥泄露", "泄露"}) {
|
||||
flags = append(flags, RiskPossibleSecretLeak)
|
||||
}
|
||||
if containsAny(text, []string{"install failed", "installation failed", "安装失败"}) {
|
||||
flags = append(flags, RiskInstallationBlocker)
|
||||
}
|
||||
if containsAny(text, []string{"cannot login", "login failed", "auth failed", "无法登录", "登录失败"}) {
|
||||
flags = append(flags, RiskAuthenticationBlocker)
|
||||
}
|
||||
if detectedType == IssueTypeCI || containsAny(text, []string{"test failed", "pipeline failed", "build failed", "测试失败", "构建失败"}) {
|
||||
flags = append(flags, RiskCIBlocker)
|
||||
}
|
||||
return flags
|
||||
}
|
||||
|
||||
func suggestedLabels(detectedType string, priority string, riskFlags []string) []string {
|
||||
labels := []string{}
|
||||
if detectedType != IssueTypeUnknown {
|
||||
labels = append(labels, detectedType)
|
||||
}
|
||||
labels = append(labels, strings.ToLower(priority))
|
||||
for _, flag := range riskFlags {
|
||||
switch flag {
|
||||
case RiskSecuritySensitive, RiskPossibleSecretLeak:
|
||||
labels = append(labels, "security")
|
||||
case RiskCIBlocker:
|
||||
labels = append(labels, "ci")
|
||||
}
|
||||
}
|
||||
return uniqueStrings(labels)
|
||||
}
|
||||
|
||||
func recommendedAction(detectedType string, priority string, riskFlags []string, missingInformation []string) string {
|
||||
if containsString(riskFlags, RiskSecuritySensitive) || containsString(riskFlags, RiskPossibleSecretLeak) || detectedType == IssueTypeSecurity {
|
||||
return ActionReviewSecurity
|
||||
}
|
||||
if priority == PriorityP0 {
|
||||
return ActionPrioritizeImmediate
|
||||
}
|
||||
if len(missingInformation) > 0 {
|
||||
return ActionRequestMoreInfo
|
||||
}
|
||||
switch detectedType {
|
||||
case IssueTypeQuestion:
|
||||
return ActionConvertToDiscussion
|
||||
case IssueTypeDocs:
|
||||
return ActionUpdateDocs
|
||||
case IssueTypeCI:
|
||||
return ActionInvestigateCI
|
||||
default:
|
||||
return ActionScheduleFix
|
||||
}
|
||||
}
|
||||
|
||||
func suggestedComment(lang string, detectedType string, missingInformation []string) string {
|
||||
lang = normalizeLang(lang)
|
||||
if detectedType == IssueTypeSecurity {
|
||||
return message(lang, "comment_security")
|
||||
}
|
||||
if len(missingInformation) > 0 {
|
||||
return fmt.Sprintf(message(lang, "comment_more_info"), strings.Join(missingInformation, ", "))
|
||||
}
|
||||
if detectedType == IssueTypeDocs {
|
||||
return message(lang, "comment_docs")
|
||||
}
|
||||
return message(lang, "comment_default")
|
||||
}
|
||||
|
||||
func containsAny(text string, keywords []string) bool {
|
||||
for _, keyword := range keywords {
|
||||
if strings.Contains(text, strings.ToLower(keyword)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func containsString(values []string, needle string) bool {
|
||||
for _, value := range values {
|
||||
if value == needle {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func uniqueStrings(values []string) []string {
|
||||
seen := map[string]struct{}{}
|
||||
unique := []string{}
|
||||
for _, value := range values {
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[value]; ok {
|
||||
continue
|
||||
}
|
||||
seen[value] = struct{}{}
|
||||
unique = append(unique, value)
|
||||
}
|
||||
return unique
|
||||
}
|
||||
|
||||
func sortedStrings(values []string) []string {
|
||||
copied := append([]string(nil), values...)
|
||||
sort.Strings(copied)
|
||||
return copied
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package workflow
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAnalyzeIssueDetectsSecurityP0(t *testing.T) {
|
||||
result := AnalyzeIssue(IssueInput{
|
||||
Title: "Token leaked in command output",
|
||||
Body: "A secret token leaked and may allow permission escalation.",
|
||||
Labels: []string{"security"},
|
||||
}, "en")
|
||||
|
||||
if result.DetectedType != IssueTypeSecurity {
|
||||
t.Fatalf("DetectedType = %q, want %q", result.DetectedType, IssueTypeSecurity)
|
||||
}
|
||||
if result.Priority != PriorityP0 {
|
||||
t.Fatalf("Priority = %q, want %q", result.Priority, PriorityP0)
|
||||
}
|
||||
if !containsString(result.RiskFlags, RiskPossibleSecretLeak) && !containsString(result.RiskFlags, RiskSecuritySensitive) {
|
||||
t.Fatalf("RiskFlags = %v, want security or secret leak flag", result.RiskFlags)
|
||||
}
|
||||
if result.Confidence < 70 {
|
||||
t.Fatalf("Confidence = %d, want >= 70", result.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeIssueDetectsBugAndMissingInfo(t *testing.T) {
|
||||
result := AnalyzeIssue(IssueInput{
|
||||
Title: "CLI crash with error",
|
||||
Body: "It crashes.",
|
||||
}, "en")
|
||||
|
||||
if result.DetectedType != IssueTypeBug {
|
||||
t.Fatalf("DetectedType = %q, want %q", result.DetectedType, IssueTypeBug)
|
||||
}
|
||||
if result.Priority != PriorityP1 && result.Priority != PriorityP2 {
|
||||
t.Fatalf("Priority = %q, want P1 or P2", result.Priority)
|
||||
}
|
||||
if len(result.MissingInformation) == 0 {
|
||||
t.Fatal("MissingInformation is empty, want bug info requirements")
|
||||
}
|
||||
if !containsString(result.RiskFlags, RiskInsufficientInfo) {
|
||||
t.Fatalf("RiskFlags = %v, want %q", result.RiskFlags, RiskInsufficientInfo)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeIssueDetectsDocs(t *testing.T) {
|
||||
result := AnalyzeIssue(IssueInput{
|
||||
Title: "README typo in documentation example",
|
||||
Body: "The docs guide has a typo.",
|
||||
}, "en")
|
||||
|
||||
if result.DetectedType != IssueTypeDocs {
|
||||
t.Fatalf("DetectedType = %q, want %q", result.DetectedType, IssueTypeDocs)
|
||||
}
|
||||
if result.Priority != PriorityP3 {
|
||||
t.Fatalf("Priority = %q, want %q", result.Priority, PriorityP3)
|
||||
}
|
||||
if result.RecommendedAction != ActionUpdateDocs {
|
||||
t.Fatalf("RecommendedAction = %q, want %q", result.RecommendedAction, ActionUpdateDocs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeIssueChinese(t *testing.T) {
|
||||
result := AnalyzeIssue(IssueInput{
|
||||
Title: "安装失败并且报错,无法登录",
|
||||
Body: "执行登录命令后失败。",
|
||||
}, "zh-CN")
|
||||
|
||||
if result.DetectedType != IssueTypeBug {
|
||||
t.Fatalf("DetectedType = %q, want %q", result.DetectedType, IssueTypeBug)
|
||||
}
|
||||
if result.Priority != PriorityP1 {
|
||||
t.Fatalf("Priority = %q, want %q", result.Priority, PriorityP1)
|
||||
}
|
||||
if result.SuggestedComment == "" {
|
||||
t.Fatal("SuggestedComment is empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeIssueUnknownLowConfidence(t *testing.T) {
|
||||
result := AnalyzeIssue(IssueInput{
|
||||
Title: "General repository note",
|
||||
Body: "This is a neutral note without clear maintenance signal.",
|
||||
}, "en")
|
||||
|
||||
if result.DetectedType != IssueTypeUnknown {
|
||||
t.Fatalf("DetectedType = %q, want %q", result.DetectedType, IssueTypeUnknown)
|
||||
}
|
||||
if result.Confidence > 40 {
|
||||
t.Fatalf("Confidence = %d, want <= 40", result.Confidence)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,124 @@
|
|||
package workflow
|
||||
|
||||
import "time"
|
||||
|
||||
const (
|
||||
langEN = "en"
|
||||
langZH = "zh-CN"
|
||||
)
|
||||
|
||||
const (
|
||||
IssueTypeBug = "bug"
|
||||
IssueTypeFeature = "feature"
|
||||
IssueTypeQuestion = "question"
|
||||
IssueTypeDocs = "docs"
|
||||
IssueTypeCI = "ci"
|
||||
IssueTypeSecurity = "security"
|
||||
IssueTypePerformance = "performance"
|
||||
IssueTypeRefactor = "refactor"
|
||||
IssueTypeUnknown = "unknown"
|
||||
)
|
||||
|
||||
const (
|
||||
PriorityP0 = "P0"
|
||||
PriorityP1 = "P1"
|
||||
PriorityP2 = "P2"
|
||||
PriorityP3 = "P3"
|
||||
)
|
||||
|
||||
const (
|
||||
RiskSecuritySensitive = "security_sensitive"
|
||||
RiskPossibleSecretLeak = "possible_secret_leak"
|
||||
RiskInstallationBlocker = "installation_blocker"
|
||||
RiskAuthenticationBlocker = "authentication_blocker"
|
||||
RiskCIBlocker = "ci_blocker"
|
||||
RiskInsufficientInfo = "insufficient_information"
|
||||
)
|
||||
|
||||
const (
|
||||
ActionRequestMoreInfo = "request_more_info"
|
||||
ActionPrioritizeImmediate = "prioritize_immediately"
|
||||
ActionScheduleFix = "schedule_fix"
|
||||
ActionConvertToDiscussion = "convert_to_discussion"
|
||||
ActionUpdateDocs = "update_docs"
|
||||
ActionInvestigateCI = "investigate_ci"
|
||||
ActionReviewSecurity = "review_security"
|
||||
)
|
||||
|
||||
type IssueInput struct {
|
||||
ID string `json:"id"`
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
State string `json:"state"`
|
||||
Author string `json:"author"`
|
||||
URL string `json:"url"`
|
||||
Labels []string `json:"labels"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
CommentsCount int `json:"comments_count"`
|
||||
}
|
||||
|
||||
type IssueRef struct {
|
||||
ID string `json:"id"`
|
||||
Number int `json:"number"`
|
||||
Title string `json:"title"`
|
||||
URL string `json:"url"`
|
||||
Author string `json:"author"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
|
||||
type TriageResult struct {
|
||||
Issue IssueRef `json:"issue"`
|
||||
DetectedType string `json:"detected_type"`
|
||||
Priority string `json:"priority"`
|
||||
Confidence int `json:"confidence"`
|
||||
SuggestedLabels []string `json:"suggested_labels"`
|
||||
MissingInformation []string `json:"missing_information"`
|
||||
RiskFlags []string `json:"risk_flags"`
|
||||
RecommendedAction string `json:"recommended_action"`
|
||||
SuggestedComment string `json:"suggested_comment"`
|
||||
Reasoning []string `json:"reasoning"`
|
||||
MatchedRules []string `json:"matched_rules"`
|
||||
}
|
||||
|
||||
type HealthInput struct {
|
||||
Repository string `json:"repository"`
|
||||
OpenIssues int `json:"open_issues"`
|
||||
OpenPRs int `json:"open_prs"`
|
||||
StaleIssues int `json:"stale_issues"`
|
||||
StalePRs int `json:"stale_prs"`
|
||||
RecentActivityKnown bool `json:"recent_activity_known"`
|
||||
RecentActivityDays int `json:"recent_activity_days"`
|
||||
ReleaseKnown bool `json:"release_known"`
|
||||
HasRecentRelease bool `json:"has_recent_release"`
|
||||
CIKnown bool `json:"ci_known"`
|
||||
CIPassing bool `json:"ci_passing"`
|
||||
HasReadme bool `json:"has_readme"`
|
||||
HasLicense bool `json:"has_license"`
|
||||
HasContributing bool `json:"has_contributing"`
|
||||
AgentReadinessKnown bool `json:"agent_readiness_known"`
|
||||
AgentReadinessScore int `json:"agent_readiness_score"`
|
||||
}
|
||||
|
||||
type HealthResult struct {
|
||||
Repository string `json:"repository"`
|
||||
HealthScore int `json:"health_score"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
Metrics []HealthMetric `json:"metrics"`
|
||||
Recommendations []string `json:"recommendations"`
|
||||
ScoringNotes []ScoringNote `json:"scoring_notes"`
|
||||
}
|
||||
|
||||
type HealthMetric struct {
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
Score int `json:"score"`
|
||||
MaxScore int `json:"max_score"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type ScoringNote struct {
|
||||
Metric string `json:"metric"`
|
||||
Note string `json:"note"`
|
||||
}
|
||||
|
|
@ -0,0 +1,364 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type TriageReport struct {
|
||||
Repository string `json:"repository"`
|
||||
State string `json:"state"`
|
||||
Limit int `json:"limit"`
|
||||
DryRun bool `json:"dry_run"`
|
||||
Language string `json:"language"`
|
||||
Results []TriageResult `json:"results"`
|
||||
}
|
||||
|
||||
func Shortcuts() []*common.Shortcut {
|
||||
return []*common.Shortcut{
|
||||
newTriageShortcut(),
|
||||
newHealthShortcut(),
|
||||
}
|
||||
}
|
||||
|
||||
func newTriageShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "triage",
|
||||
Description: "Analyze issues with local workflow triage rules",
|
||||
Flags: []common.Flag{
|
||||
{Name: "from", Usage: "Read issue inputs from a JSON file. Supports a single issue, an array, or an object with an issues field"},
|
||||
{Name: "title", Short: "t", Usage: "Issue title for single-issue local analysis"},
|
||||
{Name: "body", Short: "b", Usage: "Issue body for single-issue local analysis"},
|
||||
{Name: "number", Short: "n", Usage: "Issue number for single-issue local analysis"},
|
||||
{Name: "author", Usage: "Issue author for single-issue local analysis"},
|
||||
{Name: "url", Usage: "Issue URL for single-issue local analysis"},
|
||||
{Name: "labels", Usage: "Comma-separated labels for single-issue local analysis"},
|
||||
{Name: "state", Short: "s", Usage: "Filter or assign issue state", Default: "open"},
|
||||
{Name: "page", Short: "p", Usage: "API page number for remote triage", Default: "1"},
|
||||
{Name: "limit", Short: "l", Usage: "Maximum issues to analyze", Default: "30"},
|
||||
{Name: "since", Usage: "Optional remote issue filter for updated time"},
|
||||
{Name: "dry-run", Usage: "Preview workflow recommendations without remote writes", Bool: true, Default: "true"},
|
||||
{Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN},
|
||||
},
|
||||
Run: runTriage,
|
||||
}
|
||||
}
|
||||
|
||||
func newHealthShortcut() *common.Shortcut {
|
||||
return &common.Shortcut{
|
||||
Name: "health",
|
||||
Description: "Score repository health with local workflow rules",
|
||||
Flags: []common.Flag{
|
||||
{Name: "from", Usage: "Read health input from a JSON file"},
|
||||
{Name: "repository", Usage: "Repository name, for example owner/repo"},
|
||||
{Name: "open-issues", Usage: "Open issue count", Default: "0"},
|
||||
{Name: "open-prs", Usage: "Open pull request count", Default: "0"},
|
||||
{Name: "stale-issues", Usage: "Stale issue count", Default: "0"},
|
||||
{Name: "stale-prs", Usage: "Stale pull request count", Default: "0"},
|
||||
{Name: "recent-activity-known", Usage: "Whether recent activity is known", Bool: true, Default: "false"},
|
||||
{Name: "recent-activity-days", Usage: "Days since recent activity", Default: "0"},
|
||||
{Name: "release-known", Usage: "Whether release status is known", Bool: true, Default: "false"},
|
||||
{Name: "has-recent-release", Usage: "Whether a recent release exists", Bool: true, Default: "false"},
|
||||
{Name: "ci-known", Usage: "Whether CI status is known", Bool: true, Default: "false"},
|
||||
{Name: "ci-passing", Usage: "Whether CI is passing", Bool: true, Default: "false"},
|
||||
{Name: "has-readme", Usage: "Whether README exists", Bool: true, Default: "false"},
|
||||
{Name: "has-license", Usage: "Whether LICENSE exists", Bool: true, Default: "false"},
|
||||
{Name: "has-contributing", Usage: "Whether CONTRIBUTING exists", Bool: true, Default: "false"},
|
||||
{Name: "agent-readiness-known", Usage: "Whether agent readiness score is known", Bool: true, Default: "false"},
|
||||
{Name: "agent-readiness-score", Usage: "Agent readiness score from 0 to 10", Default: "0"},
|
||||
{Name: "stale-days", Usage: "Days before an issue or PR is considered stale", Default: "30"},
|
||||
{Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN},
|
||||
},
|
||||
Run: runHealth,
|
||||
}
|
||||
}
|
||||
|
||||
func runTriage(ctx *common.RuntimeContext) error {
|
||||
lang := normalizeLang(ctx.Arg("lang"))
|
||||
limit, err := parseIntArg(ctx.Arg("limit"), 30, "limit")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state := ctx.Arg("state")
|
||||
if state == "" {
|
||||
state = "open"
|
||||
}
|
||||
|
||||
if hasLocalTriageInput(ctx) {
|
||||
issues, err := collectIssuesFromArgs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
filtered := filterIssueInputs(issues, state, limit)
|
||||
results := make([]TriageResult, 0, len(filtered))
|
||||
for _, issue := range filtered {
|
||||
results = append(results, AnalyzeIssue(issue, lang))
|
||||
}
|
||||
|
||||
report := TriageReport{
|
||||
Repository: repositoryFromContext(ctx, ""),
|
||||
State: state,
|
||||
Limit: limit,
|
||||
DryRun: parseBoolArg(ctx.Arg("dry-run")),
|
||||
Language: lang,
|
||||
Results: results,
|
||||
}
|
||||
return renderTriageReport(os.Stdout, report, ctx.Format)
|
||||
}
|
||||
|
||||
issues, err := FetchIssuesForTriage(ctx, TriageFetchOptions{
|
||||
State: state,
|
||||
Limit: limit,
|
||||
Page: mustParseInt(ctx.Arg("page"), 1),
|
||||
Labels: parseCSV(ctx.Arg("labels")),
|
||||
Since: ctx.Arg("since"),
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
results := make([]TriageResult, 0, len(issues))
|
||||
for _, issue := range issues {
|
||||
results = append(results, AnalyzeIssue(issue, lang))
|
||||
}
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("workflow +triage remote mode requires --owner and --repo or a Git remote: %w", err)
|
||||
}
|
||||
report := TriageReport{
|
||||
Repository: repositoryFromContext(ctx, ""),
|
||||
State: state,
|
||||
Limit: limit,
|
||||
DryRun: parseBoolArg(ctx.Arg("dry-run")),
|
||||
Language: lang,
|
||||
Results: results,
|
||||
}
|
||||
return renderTriageReport(os.Stdout, report, ctx.Format)
|
||||
}
|
||||
|
||||
func runHealth(ctx *common.RuntimeContext) error {
|
||||
lang := normalizeLang(ctx.Arg("lang"))
|
||||
if hasLocalHealthInput(ctx) {
|
||||
input, err := collectHealthFromArgs(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
input.Repository = repositoryFromContext(ctx, input.Repository)
|
||||
|
||||
result := ScoreHealth(input, lang)
|
||||
return renderHealthResult(os.Stdout, result, ctx.Format)
|
||||
}
|
||||
|
||||
if err := ctx.ResolveOwnerRepo(); err != nil {
|
||||
return fmt.Errorf("workflow +health remote mode requires --owner and --repo or a Git remote: %w", err)
|
||||
}
|
||||
input, notes, err := FetchHealthInput(ctx, HealthFetchOptions{
|
||||
StaleDays: mustParseInt(ctx.Arg("stale-days"), 30),
|
||||
IncludeCI: true,
|
||||
IncludeRelease: true,
|
||||
IncludeDocs: true,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
result := ScoreHealth(input, lang)
|
||||
result.ScoringNotes = append(notes, result.ScoringNotes...)
|
||||
return renderHealthResult(os.Stdout, result, ctx.Format)
|
||||
}
|
||||
|
||||
func collectIssuesFromArgs(ctx *common.RuntimeContext) ([]IssueInput, error) {
|
||||
if path := ctx.Arg("from"); path != "" {
|
||||
return readIssueInputs(path)
|
||||
}
|
||||
if strings.TrimSpace(ctx.Arg("title")) == "" {
|
||||
return nil, fmt.Errorf("workflow +triage currently requires --from issues.json or --title for local rule analysis")
|
||||
}
|
||||
number, err := parseIntArg(ctx.Arg("number"), 0, "number")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
state := ctx.Arg("state")
|
||||
if state == "" {
|
||||
state = "open"
|
||||
}
|
||||
return []IssueInput{{
|
||||
Number: number,
|
||||
Title: ctx.Arg("title"),
|
||||
Body: ctx.Arg("body"),
|
||||
State: state,
|
||||
Author: ctx.Arg("author"),
|
||||
URL: ctx.Arg("url"),
|
||||
Labels: parseCSV(ctx.Arg("labels")),
|
||||
}}, nil
|
||||
}
|
||||
|
||||
func hasLocalTriageInput(ctx *common.RuntimeContext) bool {
|
||||
return strings.TrimSpace(ctx.Arg("from")) != "" || strings.TrimSpace(ctx.Arg("title")) != ""
|
||||
}
|
||||
|
||||
func hasLocalHealthInput(ctx *common.RuntimeContext) bool {
|
||||
return strings.TrimSpace(ctx.Arg("from")) != "" || strings.TrimSpace(ctx.Arg("repository")) != ""
|
||||
}
|
||||
|
||||
func collectHealthFromArgs(ctx *common.RuntimeContext) (HealthInput, error) {
|
||||
if path := ctx.Arg("from"); path != "" {
|
||||
input, err := readHealthInput(path)
|
||||
if err != nil {
|
||||
return HealthInput{}, err
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
openIssues, err := parseIntArg(ctx.Arg("open-issues"), 0, "open-issues")
|
||||
if err != nil {
|
||||
return HealthInput{}, err
|
||||
}
|
||||
openPRs, err := parseIntArg(ctx.Arg("open-prs"), 0, "open-prs")
|
||||
if err != nil {
|
||||
return HealthInput{}, err
|
||||
}
|
||||
staleIssues, err := parseIntArg(ctx.Arg("stale-issues"), 0, "stale-issues")
|
||||
if err != nil {
|
||||
return HealthInput{}, err
|
||||
}
|
||||
stalePRs, err := parseIntArg(ctx.Arg("stale-prs"), 0, "stale-prs")
|
||||
if err != nil {
|
||||
return HealthInput{}, err
|
||||
}
|
||||
recentActivityDays, err := parseIntArg(ctx.Arg("recent-activity-days"), 0, "recent-activity-days")
|
||||
if err != nil {
|
||||
return HealthInput{}, err
|
||||
}
|
||||
agentReadinessScore, err := parseIntArg(ctx.Arg("agent-readiness-score"), 0, "agent-readiness-score")
|
||||
if err != nil {
|
||||
return HealthInput{}, err
|
||||
}
|
||||
|
||||
return HealthInput{
|
||||
Repository: ctx.Arg("repository"),
|
||||
OpenIssues: openIssues,
|
||||
OpenPRs: openPRs,
|
||||
StaleIssues: staleIssues,
|
||||
StalePRs: stalePRs,
|
||||
RecentActivityKnown: parseBoolArg(ctx.Arg("recent-activity-known")),
|
||||
RecentActivityDays: recentActivityDays,
|
||||
ReleaseKnown: parseBoolArg(ctx.Arg("release-known")),
|
||||
HasRecentRelease: parseBoolArg(ctx.Arg("has-recent-release")),
|
||||
CIKnown: parseBoolArg(ctx.Arg("ci-known")),
|
||||
CIPassing: parseBoolArg(ctx.Arg("ci-passing")),
|
||||
HasReadme: parseBoolArg(ctx.Arg("has-readme")),
|
||||
HasLicense: parseBoolArg(ctx.Arg("has-license")),
|
||||
HasContributing: parseBoolArg(ctx.Arg("has-contributing")),
|
||||
AgentReadinessKnown: parseBoolArg(ctx.Arg("agent-readiness-known")),
|
||||
AgentReadinessScore: agentReadinessScore,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readIssueInputs(path string) ([]IssueInput, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read issue inputs: %w", err)
|
||||
}
|
||||
|
||||
var issues []IssueInput
|
||||
if err := json.Unmarshal(data, &issues); err == nil {
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
var wrapper struct {
|
||||
Issues []IssueInput `json:"issues"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &wrapper); err == nil && wrapper.Issues != nil {
|
||||
return wrapper.Issues, nil
|
||||
}
|
||||
|
||||
var issue IssueInput
|
||||
if err := json.Unmarshal(data, &issue); err == nil && issue.Title != "" {
|
||||
return []IssueInput{issue}, nil
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("parse issue inputs: expected a single issue, an array, or an object with an issues field")
|
||||
}
|
||||
|
||||
func readHealthInput(path string) (HealthInput, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return HealthInput{}, fmt.Errorf("read health input: %w", err)
|
||||
}
|
||||
var input HealthInput
|
||||
if err := json.Unmarshal(data, &input); err != nil {
|
||||
return HealthInput{}, fmt.Errorf("parse health input: %w", err)
|
||||
}
|
||||
return input, nil
|
||||
}
|
||||
|
||||
func filterIssueInputs(issues []IssueInput, state string, limit int) []IssueInput {
|
||||
filtered := make([]IssueInput, 0, len(issues))
|
||||
for _, issue := range issues {
|
||||
if state != "" && state != "all" && issue.State != "" && !strings.EqualFold(issue.State, state) {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, issue)
|
||||
if limit > 0 && len(filtered) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func repositoryFromContext(ctx *common.RuntimeContext, fallback string) string {
|
||||
if ctx.Owner != "" && ctx.Repo != "" {
|
||||
return ctx.Owner + "/" + ctx.Repo
|
||||
}
|
||||
if fallback != "" {
|
||||
return fallback
|
||||
}
|
||||
return "local"
|
||||
}
|
||||
|
||||
func parseCSV(value string) []string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
parts := strings.Split(value, ",")
|
||||
result := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
result = append(result, part)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseBoolArg(value string) bool {
|
||||
parsed, err := strconv.ParseBool(strings.TrimSpace(value))
|
||||
return err == nil && parsed
|
||||
}
|
||||
|
||||
func parseIntArg(value string, defaultValue int, name string) (int, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return defaultValue, nil
|
||||
}
|
||||
parsed, err := strconv.Atoi(value)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("invalid --%s %q: must be an integer", name, value)
|
||||
}
|
||||
if parsed < 0 {
|
||||
return 0, fmt.Errorf("invalid --%s %q: must be non-negative", name, value)
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func mustParseInt(value string, defaultValue int) int {
|
||||
parsed, err := parseIntArg(value, defaultValue, "value")
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
|
@ -0,0 +1,200 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/internal/client"
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
func TestShortcutsExposesWorkflowCommands(t *testing.T) {
|
||||
shortcuts := Shortcuts()
|
||||
names := map[string]bool{}
|
||||
for _, shortcut := range shortcuts {
|
||||
names[shortcut.Name] = true
|
||||
}
|
||||
if !names["triage"] {
|
||||
t.Fatal("Shortcuts missing triage")
|
||||
}
|
||||
if !names["health"] {
|
||||
t.Fatal("Shortcuts missing health")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTriageWithSingleIssueArgs(t *testing.T) {
|
||||
ctx := &common.RuntimeContext{
|
||||
Format: "json",
|
||||
Args: map[string]string{
|
||||
"title": "Token leaked in output",
|
||||
"body": "A secret token leaked from logs.",
|
||||
"number": "7",
|
||||
"state": "open",
|
||||
"limit": "30",
|
||||
"dry-run": "true",
|
||||
"lang": "en",
|
||||
},
|
||||
}
|
||||
|
||||
issues, err := collectIssuesFromArgs(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("collectIssuesFromArgs returned error: %v", err)
|
||||
}
|
||||
if len(issues) != 1 {
|
||||
t.Fatalf("len(issues) = %d, want 1", len(issues))
|
||||
}
|
||||
|
||||
result := AnalyzeIssue(issues[0], ctx.Arg("lang"))
|
||||
if result.DetectedType != IssueTypeSecurity {
|
||||
t.Fatalf("DetectedType = %q, want %q", result.DetectedType, IssueTypeSecurity)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadIssueInputsFromJSONFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "issues.json")
|
||||
data := []IssueInput{{
|
||||
Number: 1,
|
||||
Title: "README typo",
|
||||
State: "open",
|
||||
}}
|
||||
writeJSONFixture(t, path, map[string]interface{}{"issues": data})
|
||||
|
||||
issues, err := readIssueInputs(path)
|
||||
if err != nil {
|
||||
t.Fatalf("readIssueInputs returned error: %v", err)
|
||||
}
|
||||
if len(issues) != 1 {
|
||||
t.Fatalf("len(issues) = %d, want 1", len(issues))
|
||||
}
|
||||
if issues[0].Title != "README typo" {
|
||||
t.Fatalf("Title = %q, want README typo", issues[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderHealthMarkdown(t *testing.T) {
|
||||
result := ScoreHealth(HealthInput{
|
||||
Repository: "owner/repo",
|
||||
OpenIssues: 1,
|
||||
OpenPRs: 1,
|
||||
RecentActivityKnown: true,
|
||||
RecentActivityDays: 1,
|
||||
ReleaseKnown: true,
|
||||
HasRecentRelease: true,
|
||||
HasReadme: true,
|
||||
HasLicense: true,
|
||||
HasContributing: true,
|
||||
AgentReadinessKnown: true,
|
||||
AgentReadinessScore: 9,
|
||||
}, "en")
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := renderHealthResult(&buf, result, "markdown"); err != nil {
|
||||
t.Fatalf("renderHealthResult returned error: %v", err)
|
||||
}
|
||||
if !bytes.Contains(buf.Bytes(), []byte("# Repository Health Report")) {
|
||||
t.Fatalf("markdown output missing title: %s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunTriageRemoteModeUsesFetch(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"issues": []map[string]interface{}{
|
||||
{
|
||||
"number": 7,
|
||||
"title": "Token leaked in logs",
|
||||
"body": "The access token appears in command output.",
|
||||
"state": "open",
|
||||
"author": map[string]interface{}{"login": "bob"},
|
||||
"labels": []map[string]interface{}{{"name": "security"}},
|
||||
"created_at": "2026-05-01T00:00:00Z",
|
||||
"updated_at": "2026-05-02T00:00:00Z",
|
||||
"html_url": "https://example.com/issues/7",
|
||||
"comments": 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: map[string]string{
|
||||
"limit": "10",
|
||||
"state": "open",
|
||||
"dry-run": "true",
|
||||
"lang": "en",
|
||||
},
|
||||
}
|
||||
|
||||
if err := runTriage(ctx); err != nil {
|
||||
t.Fatalf("runTriage returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunHealthRemoteModeUsesFetch(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"updated_at": "2026-05-19T00:00:00Z",
|
||||
"has_readme": true,
|
||||
"has_license": true,
|
||||
"has_contributing": true,
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"issues": []map[string]interface{}{}})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"pulls": []map[string]interface{}{}})
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/releases.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"releases": []map[string]interface{}{}})
|
||||
case r.Method == "GET" && r.URL.Path == "/owner/repo/builds.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"builds": []map[string]interface{}{}})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
ctx := &common.RuntimeContext{
|
||||
Client: &client.Client{
|
||||
HTTP: server.Client(),
|
||||
BaseURL: server.URL,
|
||||
},
|
||||
Owner: "owner",
|
||||
Repo: "repo",
|
||||
Format: "json",
|
||||
Args: map[string]string{
|
||||
"lang": "en",
|
||||
},
|
||||
}
|
||||
|
||||
if err := runHealth(ctx); err != nil {
|
||||
t.Fatalf("runHealth returned error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func writeJSONFixture(t *testing.T, path string, data interface{}) {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(data)
|
||||
if err != nil {
|
||||
t.Fatalf("json.Marshal returned error: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(path, encoded, 0600); err != nil {
|
||||
t.Fatalf("write fixture returned error: %v", err)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue