diff --git a/.devops/gitlink-cli-autodeploy.yml b/.devops/gitlink-cli-autodeploy.yml new file mode 100644 index 0000000..692e2c4 --- /dev/null +++ b/.devops/gitlink-cli-autodeploy.yml @@ -0,0 +1,78 @@ +version: 2 +name: gitlink-cli-autodeploy +description: "每次push到master时自动在服务器上构建并部署gitlink-cli" +global: + concurrent: 1 + +trigger: + webhook: gitlink@1.0.0 + event: + - ref: push + ruleset-operator: AND + condition: + param: ref + operator: include_regex + value: "^refs/heads/master$" + +workflow: + - ref: start + name: 开始 + task: start + + # 1. 准备服务器环境 + - ref: prepare_server_0 + name: 准备服务器环境 + task: ssh_cmd@1.1.1 + input: + ssh_ip: '"121.41.222.0"' + ssh_port: '"22"' + ssh_user: '"root"' + ssh_private_key: ((gitlink_cli.gitlink_cli_deploy_key)) + ssh_cmd: '"/opt/gitlink-deploy/prepare.sh"' + needs: + - start + + # 2. 在服务器上构建 + - ref: build_on_server_0 + name: 在服务器上构建 + task: ssh_cmd@1.1.1 + input: + ssh_ip: '"121.41.222.0"' + ssh_port: '"22"' + ssh_user: '"root"' + ssh_private_key: ((gitlink_cli.gitlink_cli_deploy_key)) + ssh_cmd: '"/opt/gitlink-deploy/build.sh"' + needs: + - prepare_server_0 + + # 3. 部署到服务器 + - ref: deploy_on_server_0 + name: 部署到服务器 + task: ssh_cmd@1.1.1 + input: + ssh_ip: '"121.41.222.0"' + ssh_port: '"22"' + ssh_user: '"root"' + ssh_private_key: ((gitlink_cli.gitlink_cli_deploy_key)) + ssh_cmd: '"/opt/gitlink-deploy/deploy.sh"' + needs: + - build_on_server_0 + + # 4. 验证部署 + - ref: verify_deployment_0 + name: 验证部署 + task: ssh_cmd@1.1.1 + input: + ssh_ip: '"121.41.222.0"' + ssh_port: '"22"' + ssh_user: '"root"' + ssh_private_key: ((gitlink_cli.gitlink_cli_deploy_key)) + ssh_cmd: '"gitlink-cli version"' + needs: + - deploy_on_server_0 + + - ref: end + name: 结束 + task: end + needs: + - verify_deployment_0 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 531f3b5..8d108eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,6 +23,9 @@ jobs: node-version: '20' registry-url: 'https://registry.npmjs.org' + - name: Run tests + run: go test ./... -cover -race + - name: Build binaries run: | mkdir -p dist diff --git a/Makefile b/Makefile index 7f3ef79..28df5e6 100644 --- a/Makefile +++ b/Makefile @@ -15,4 +15,4 @@ clean: rm -f $(BINARY) test: - go test ./... + go test ./... -race diff --git a/README.md b/README.md index cb2663d..d9486db 100644 --- a/README.md +++ b/README.md @@ -5,261 +5,792 @@ [![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org) [![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli) -The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for humans and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows, with 40+ commands and 12 AI Agent [Skills](./skills/). +The official [GitLink](https://www.gitlink.org.cn) CLI tool — built for developers and AI Agents. Supports **macOS, Linux, and Windows**. Covers repository management, issue tracking, pull requests, CI/CD, and AI-powered workflows, with **40+ commands** and **20+ AI Agent Skills**. **[中文文档](./README.zh-CN.md)** -[Install](#installation--quick-start) · [AI Agent Skills](#ai-agent-skills) · [Auth](#configure--use) · [Commands](#usage-examples) · [Contributing](#related-projects) +--- -## Why gitlink-cli? +## Project Overview -- **Agent-Native Design** — 12 structured [Skills](./skills/) out of the box, compatible with Claude Code, OpenClaw, and other AI platforms — Agents can operate GitLink with zero extra setup -- **Wide Coverage** — Repository, Issue, PR, Branch, Release, CI, Org, Search, User — all core domains covered -- **AI-Friendly & Optimized** — Every command is tested with real Agents, featuring concise parameters, smart defaults, and structured output -- **Cross-Platform** — Runs on macOS, Linux, and Windows (x64/arm64), install via `npm install -g @gitlink-ai/cli` in one command, binary auto-downloaded -- **Open Source, Zero Barriers** — MulanPSL-2.0 license, ready to use, just `npm install` -- **Up and Running in 3 Minutes** — Interactive login or `GITLINK_TOKEN` env var, from install to first API call in just 3 steps -- **Secure & Controllable** — OS-native keychain credential storage, `GITLINK_TOKEN` env var for CI/CD & non-interactive environments, auto git remote context resolution -- **Three-Layer Architecture** — Shortcuts (human & AI friendly) → Raw API (full coverage) → Config (configuration management) +This project delivers automation capabilities for the GitLink open-source collaboration platform across three sub-tasks. -## Features +### Sub-task 1: Extend and Enhance GitLink-CLI Capabilities (50%) + +> Focus: CLI feature expansion | Difficulty: Medium-High | Tech Stack: Go + +Build a robust, cross-platform, AI-friendly CLI tool covering the full GitLink platform. + +**Core Architecture — Three-Layer Design:** + +``` +Shortcuts (40+ commands) + → Clean commands for humans and AI Agents + → issue +create, pr +list, wiki +create ... + ↓ +Raw API (Full endpoint coverage) + → Direct GitLink OpenAPI calls + → api GET /users/me, api POST /.../issues ... + ↓ +Config & Auth (Configuration layer) + → Auth, context resolution, output formatting + → auth login, config init, --format json +``` + +**CLI Feature Matrix:** | Category | Capabilities | |----------|-------------| -| 📦 Repo | List, create, fork, delete repositories, view repo info | -| 🐛 Issue | Create, update, close, batch close, comment on issues | -| 🔀 PR | Create, merge, review pull requests, view changed files | -| 🌿 Branch | Create, delete, list, protect, unprotect branches | -| 🏷️ Release | Create, view, delete releases | -| 🏢 Org | Manage organizations, members, teams | -| 🔧 CI | View builds, logs, CI/CD operations | -| 🔍 Search | Search repositories, users | -| 👤 User | View user profiles and info | -| 📋 PM | Sprint management, kanban boards, weekly reports | -| 🤖 Workflow | AI-powered issue triage, PR review, release notes | +| Repo | List, create, fork, delete, view repo info | +| Issue | Create, update, close, comment, 6 batch operations (close/status/priority/assignee/label/create) | +| Wiki | View, create, update, delete Wiki pages | +| PR | Create, merge, review, view changed files | +| Branch | Create, delete, list, protect, unprotect | +| Release | Create, view, delete releases | +| Webhook | Create, view, update, delete, test webhooks | +| Org | Manage organizations, members, teams | +| CI | View builds, logs, CI/CD operations | +| Search | Search repositories, users | +| User | View user profiles | +| PM | Sprint management, kanban, weekly reports | +| Raw API | Call any GitLink OpenAPI endpoint directly | + +**Key Highlights:** + +- **Agent-Native Design** — Every command tested with real Agents: concise parameters, smart defaults, structured output +- **Cross-Platform** — macOS / Linux / Windows (x64/arm64), one-command install +- **Secure & Controllable** — OS-native keychain storage, `GITLINK_TOKEN` env var for CI/CD +- **Smart Context Resolution** — Auto-resolves owner/repo from `git remote origin` +- **Zero-Barrier Open Source** — MulanPSL-2.0, `npm install -g @gitlink-ai/cli` and go + +### Sub-task 2: Develop and Enrich GitLink Skills (20%) + +> Focus: Agent Skill Development | Difficulty: Medium | No Go required — Markdown + CLI calls + +Skills are structured knowledge bases designed for Claude Code and other AI Agents. Each Skill includes `SKILL.md` (command reference), `REFERENCE.md` (API reference), `TROUBLESHOOTING.md` (troubleshooting), and `examples/` (complete workflow examples). AI Agents can operate GitLink by reading Skills with zero manual documentation lookup. + +**Skills Directory Structure:** + +``` +skills/ +├── gitlink-shared/ # Auth, global params, safety rules, branch conventions +├── gitlink-repo/ # Repository management +├── gitlink-issue/ # Issue management +├── gitlink-pr/ # Pull Request +├── gitlink-branch/ # Branch management +├── gitlink-release/ # Release management +├── gitlink-wiki/ # Wiki management +├── gitlink-webhook/ # Webhook management +├── gitlink-org/ # Organization management +├── gitlink-user/ # User management +├── gitlink-ci/ # CI/CD +├── gitlink-search/ # Search functionality +├── gitlink-pm/ # Project management (Sprints / Kanban / Weekly Reports) +├── gitlink-team/ # Team management +├── gitlink-changelog/ # Release Notes / Changelog generation +├── gitlink-health/ # Project health reports (issue response time, PR merge efficiency, contributor activity) +├── gitlink-contrib/ # Contributor reports (statistics & rankings) +├── gitlink-compliance/ # Security & compliance (license scanning, secret detection, PII scanning) +├── gitlink-issue-triage/ # Issue auto-classification (tracker/priority/labels) +├── gitlink-onboard/ # New contributor onboarding (Good First Issue identification & welcome comments) +├── gitlink-workflow/ # AI workflow orchestration (issue triage, PR review, release notes) +├── gitlink-code-review/ # Intelligent code review (four-dimension scoring, structured review comments) +├── gitlink-research/ # Research assistance (project insights, trend tracking, compliance & reproducibility, collaboration matching, citation generation) +└── gitlink-code-insight/ # Feature panorama (all Shortcuts cataloged with descriptions & examples) +``` + +**All Skills at a Glance:** + +| Skill | Description | Typical Commands | +|-------|-------------|-----------------| +| **gitlink-shared** | Auth, global params, API reference, safety rules | `auth login`, `auth status` | +| **gitlink-repo** | Repository management | `repo +list`, `repo +create`, `repo +info`, `repo +fork` | +| **gitlink-issue** | Issue management | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close` | +| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +review` | +| **gitlink-branch** | Branch management | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` | +| **gitlink-release** | Release management | `release +list`, `release +create`, `release +view` | +| **gitlink-wiki** | Wiki management | `wiki +list`, `wiki +view`, `wiki +create`, `wiki +update`, `wiki +delete` | +| **gitlink-webhook** | Webhook management | `webhook +list`, `webhook +create`, `webhook +test` | +| **gitlink-org** | Organization management | `org +list`, `org +info`, `org +members` | +| **gitlink-user** | User management | `user +me`, `user +info` | +| **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` | +| **gitlink-search** | Search functionality | `search +repos`, `search +users` | +| **gitlink-pm** | Project management | Sprint mgmt, kanban, weekly reports (via Raw API) | +| **gitlink-team** | Team management | `team +list`, `team +create`, `team +add-member` | +| **gitlink-changelog** | Release Notes generation | Auto-collect commits/PRs/Issues, generate structured changelogs | +| **gitlink-health** | Project health reports | Issue response time, PR merge efficiency, contributor activity stats | +| **gitlink-contrib** | Contributor reports | `contrib +report` | +| **gitlink-compliance** | Security & compliance | `compliance +scan`, `compliance +secrets`, `compliance +license` | +| **gitlink-issue-triage** | Issue auto-classification | Auto-detect tracker/priority/labels, generate audit reports | +| **gitlink-onboard** | New contributor onboarding | `onboard +welcome` | +| **gitlink-workflow** | AI workflow orchestration | Issue triage, PR review, repo setup, sprint reports | +| **gitlink-code-review** | Intelligent code review | Four-dimension scoring (quality/security/performance/maintainability) with auto-published reviews | +| **gitlink-research** | Research assistance | Project insights, trend tracking, compliance & reproducibility, collaboration matching, citation generation | +| **gitlink-code-insight** | Feature panorama | Complete Shortcuts catalog with descriptions & examples | + +**How AI Agents Use Skills:** + +``` +User: "Create an Issue on GitLink for me" + ↓ +AI Agent reads gitlink-issue/SKILL.md + ↓ +AI Agent executes: gitlink-cli issue +create -t "..." -b "..." + ↓ +Done! +``` + +AI Agents can automatically: create/manage Issues, create/merge PRs, publish Releases, manage Wiki, triage Issues, generate Release Notes, perform code reviews, and more. + +### Sub-task 3: Build End-to-End Automation Workflows (20%) + +> Focus: Compose existing capabilities to solve real problems | Difficulty: Low-Medium | No low-level coding required + +Combine gitlink-cli commands and Skills into complete, reproducible automation scenarios. Workflows are orchestrated via Shell/PowerShell scripts and support AI Agent-driven execution. + +--- + +**Five Automation Workflows at a Glance:** + +| # | Scenario | Script | Steps | Problem Solved | +|---|----------|--------|-------|---------------| +| 1 | Community Ops Automation | `01-community-ops.sh` | 7 | Untriaged issue backlog, manual weekly reports, manual Release Notes | +| 2 | Code Quality Gatekeeper | `02-code-quality-gatekeeper.sh` | 7 | Low PR review efficiency, inconsistent quality standards, AI code review | +| 3 | One-Click Project Init | `03-project-init.sh` | 8 | Repetitive new project setup, manual README/License/CI/Issue/Milestone config | +| 4 | Multi-Repo Collaboration | `04-multi-repo-collab.sh` | 7 | Scattered cross-repo status, lack of unified dashboard | +| 5 | Contributor Growth System | `05-contributor-growth.sh` | 6 | Hard-to-track contributor activity, lack of incentive mechanisms | + +--- + +### Scenario 1: Community Ops Automation + +**Script**: `workflows/01-community-ops.sh` + +**Workflow**: + +``` +issue +list → Fetch all open Issues + ↓ +Classify by keyword: Bug / Feature / Question / Docs + ↓ +issue +label-add → Auto-apply labels + ↓ +repo +members → Get repository member list +issue +update → Rotate assignment to members + ↓ +pr +list → Count PRs merged this week +issue +list → Count Issues closed this week + ↓ +wiki +create → Publish community weekly report to Wiki + ↓ +release +create → Auto-generate Release Notes +``` + +**Chained Commands**: + +| Step | Command | Purpose | +|------|---------|---------| +| 1 | `issue +list` | Fetch all open Issues | +| 2 | `issue +label-add` | Apply labels by category (bug/feature/question/documentation) | +| 3 | `repo +members` | Fetch repository member list | +| 4 | `issue +update` | Assign owners to Bug/Feature Issues | +| 5 | `pr +list` | Count PRs merged this week | +| 6 | `wiki +create` | Publish community weekly report | +| 7 | `release +create` | Auto-generate Release Notes | + +**Output Value**: Label-based filtering (repo Issues page filterable by label), clear ownership (every Issue has an assignee), Wiki weekly report (team & community can track weekly progress in Wiki), Release Notes (no manual changelog compilation at release time). + +**How to Run**: + +```bash +bash workflows/01-community-ops.sh --owner your-org --repo your-repo +bash workflows/01-community-ops.sh --owner zzx-coder --repo gitlink-cli +``` + +--- + +### Scenario 2: Code Quality Gatekeeper + +**Script**: `workflows/02-code-quality-gatekeeper.sh` + +**Workflow**: + +``` +pr +list → Fetch all open PRs + ↓ +pr +view → Read PR details +pr +files → Get changed file list +pr +diff → Get code diff + ↓ +Load gitlink-code-review Skill: + - Review dimensions & check items + - Scoring rubric (90-100 Excellent, 75-89 Good, ...) + - Issue severity levels (CRITICAL/HIGH/MEDIUM/LOW) + ↓ +┌─────────────────────────────────────────┐ +│ AI Code Review (Claude + Skill) │ +│ Four-Dimension Scoring (25 each, 100 total):│ +│ - Code Quality: complexity, naming, comments│ +│ - Security: SQL injection, XSS, secrets │ +│ - Performance: loop efficiency, resource leaks, N+1│ +│ - Maintainability: duplication, SRP, coupling │ +│ │ +│ Output: │ +│ - Structured issue list (severity+file+│ +│ rule+description+suggestion) │ +│ - Positive practices (positive_notes) │ +│ - Recommendations (recommendations) │ +│ - Total score + PASS/FAIL │ +└─────────────────────────────────────────┘ + ↓ +api POST /reviews → Publish review comments on PR + ↓ +ci +builds → Check CI build status + ↓ +pr +merge → Score >= threshold AND CI passes → auto-merge +``` + +**Sample AI Review Output**: + +``` +Overall Score: 88 / 100 +Code Quality: 23 / 25 +Security: 25 / 25 +Performance: 20 / 25 +Maintainability: 20 / 25 + +Issues Found: + - [LOW] quality: PR entries use (@author) format, commit entries use + (author) without @ prefix — unify to (@author) format + - [LOW] maintainability: Example contributor list updated but full + changelog link still points to old repo + +Positive Notes: + + Change intent is clear, all modified files consistently follow the requirement + + Change scope is reasonable — only docs and examples, no logic changes, minimal risk + +Recommendations: + > Unify author annotation format between PR and commit entries + > Add fallback handling docs for empty author field in collect-data.md +``` + +**Chained Commands**: + +| Step | Command | Purpose | +|------|---------|---------| +| 1 | `pr +list` | Fetch open PR list | +| 2 | `pr +view` | Read PR details (title, author, status) | +| 3 | `pr +files` | Get changed file list | +| 4 | `pr +diff` | Get code diff content | +| 5 | `gitlink-code-review` | Load Skill review dimensions, checks, scoring rubric | +| 6 | `claude -p` | AI performs four-dimension code review per Skill methodology | +| 7 | `api POST .../reviews` | Publish review comments to PR | +| 8 | `pr +merge` | Auto-merge when score >= threshold and CI passes | + +**Output Value**: Structured scoring (every PR gets a 0-100 quality score, team can set a unified merge bar), AI issue checklist (auto-identifies security risks, perf issues, code quality problems), PR comments (review results posted directly on PR), auto-merge (high-quality PRs merged without manual clicks). + +**How to Run**: + +```bash +# Review all open PRs +bash workflows/02-code-quality-gatekeeper.sh --owner your-org --repo your-repo + +# Review a specific PR +bash workflows/02-code-quality-gatekeeper.sh --owner your-org --repo your-repo --pr-id 42 + +# Custom quality threshold (default: 80) +bash workflows/02-code-quality-gatekeeper.sh --owner your-org --repo your-repo --threshold 70 + +# Dry-run mode (no actual merge) +bash workflows/02-code-quality-gatekeeper.sh --owner your-org --repo your-repo --dry-run + +# Example +bash workflows/02-code-quality-gatekeeper.sh --owner zzx-coder --repo gitlink-cli --pr-id 20 +``` + +--- + +### Scenario 3: One-Click Project Init + +**Script**: `workflows/03-project-init.sh` / `03-project-init.ps1` + +**Workflow**: + +``` +repo +create → Create repository + ↓ +git clone → Clone empty repo locally + ↓ +Generate files → README.md (language template) + LICENSE (MIT) + + CONTRIBUTING.md + .gitlink-ci.yml + ↓ +git add/commit/push → Push scaffolding files to repo + ↓ +milestone +create × 3 → Create project milestones: + - v0.1.0 - MVP + - v0.2.0 - Feature Complete + - v1.0.0 - Production Ready + ↓ +issue +create × 5 → Create initial task Issues (with labels): + - Set up CI/CD pipeline + - Write project documentation + - Establish code review process + - Add unit tests + - Configure dependency management + ↓ +branch +protect → Protect master branch + ↓ +release +create → Create v0.1.0 initial release +``` + +**Chained Commands**: + +| Step | Command | Purpose | +|------|---------|---------| +| 1 | `repo +create` | Create new repository | +| 2 | `git clone` | Clone empty repo to local temp directory | +| 3 | File generation | Generate README.md / LICENSE / CONTRIBUTING.md / .gitlink-ci.yml | +| 4 | `git add/commit/push` | Push scaffolding files to master | +| 5 | `milestone +create` | Create 3 project milestones (MVP → Production) | +| 6 | `issue +create` | Create 5 initial Issues with labels | +| 7 | `branch +protect` | Set master branch protection rules | +| 8 | `release +create` | Create v0.1.0 initial release | + +**Output Value**: Turnkey setup (post-clone repo already has README, LICENSE, CI config — start developing immediately), real repo files (README/LICENSE/CONTRIBUTING/CI are actual files, not Wiki pages), milestone roadmap (MVP to Production path already established), standardized Issues (key tasks created, team can claim directly), branch protection (prevents direct push to master, enforces PR workflow), first Release (version management from day one). + +**How to Run**: + +```bash +# Go project +bash workflows/03-project-init.sh --owner your-org --name my-go-app --description "My Go app" --lang go + +# Python project (private) +bash workflows/03-project-init.sh --owner your-org --name my-api --description "REST API service" --lang python --private + +# Node.js project +bash workflows/03-project-init.sh --owner your-org --name my-web --description "Web frontend" --lang node + +# Java project +bash workflows/03-project-init.sh --owner your-org --name my-service --description "Microservice" --lang java +``` + +--- + +### Scenario 4: Multi-Repo Collaboration + +**Script**: `workflows/04-multi-repo-collab.sh` / `04-multi-repo-collab.ps1` + +**Workflow**: + +``` +repo +list → List all repos in the organization + ↓ +For each repo: + issue +list → Get open/closed Issue counts + pr +list → Get open/merged PR counts + release +list → Get latest release & date + ↓ +Compute health score (aligned with gitlink-health Skill · 100-point deduction model): + Core metrics (75 pts): Issue resolution rate < 50% → -25, PR merge rate < 50% → -25, no recent activity → -25 + Auxiliary metrics (25 pts): Open Issues > 20 → -10, Open PRs > 10 → -10, no release > 30 days → -5 + Five-grade rating: Excellent (≥90) → Good (≥70) → Moderate (≥50) → Needs Attention (≥30) → Critical (<30) + ↓ +Generate HTML dashboard: + - Overview cards: repo count, total open Issues, total open PRs, total activity + - Detail table: per-repo Issue/PR/Release status + collapsible details + - Health color bands: green=Excellent / blue=Good / yellow=Moderate / orange=Needs Attention / red=Critical + ↓ +(Optional) release +create → Create same-version Release across all repos +``` + +**Chained Commands**: + +| Step | Command | Purpose | +|------|---------|---------| +| 1 | `repo +list` | List all repos in the organization | +| 2 | `issue +list` | Fetch Issue data per repo | +| 3 | `pr +list` | Fetch PR data per repo | +| 4 | `release +list` | Fetch latest release per repo | +| 5 | Generate HTML | Output visual dashboard | +| 6 | `release +create` | (Optional) Coordinated release | + +**Output Value**: Unified view (one HTML page shows health status of all org repos), health alerts (Open Issues > 10 → orange, > 20 → red), coordinated releases (sync multiple related repos with one command), shareable (HTML file can be sent to team or deployed internally). + +**How to Run**: + +```bash +# Scan all org repos +bash workflows/04-multi-repo-collab.sh --org your-org + +# Specific repos only +bash workflows/04-multi-repo-collab.sh --org your-org --repos "repo-a,repo-b,repo-c" + +# Dashboard + coordinated release +bash workflows/04-multi-repo-collab.sh --org your-org --release v2.0.0 + +# Custom output file + detail limit +bash workflows/04-multi-repo-collab.sh --org your-org --output my-dashboard.html --detail-limit 10 + +# Dry-run mode +bash workflows/04-multi-repo-collab.sh --org your-org --dry-run + +# Example +bash workflows/04-multi-repo-collab.sh --org zzx-coder +``` + +Generates `dashboard.html` in the current directory — open in browser to view. + +--- + +### Scenario 5: Contributor Growth System + +**Script**: `workflows/05-contributor-growth.sh` + +**Workflow**: + +``` +contrib +report → Generate HTML contribution report with ECharts pie chart + ↓ +issue +list → Count Issue activity +pr +list → Count PR activity +api GET /contributors → Get commit counts & API stats + ↓ +Compute contribution score (AHP Weighted Model): + - Code changes: 30% (normalized) + - PRs merged: 25% (normalized) + - Issues created/resolved: 15% (normalized) + - Issue comments: 15% (normalized) + - Team member: 15% (binary yes/no) + → Weighted sum yields 0-100 composite score + ↓ +Assign badges: + Champion >= 80 pts + Core Contributor >= 60 pts + Active Contributor >= 40 pts + Contributor >= 20 pts + Newcomer < 20 pts + ↓ +(Optional) issue +create → Auto-create badge-award Issue + ↓ +wiki +create → Publish leaderboard to Wiki +``` + +**Chained Commands**: + +| Step | Command | Purpose | +|------|---------|---------| +| 1 | `contrib +report` | Generate HTML contribution report (with ECharts) | +| 2 | `issue +list` | Count open/closed Issue activity | +| 3 | `pr +list` | Count open/merged PR activity | +| 4 | `api GET /contributors` | Get API-level contributor stats | +| 5 | `issue +create` | (Optional) Auto-award badges | +| 6 | `wiki +create` | Publish leaderboard to Wiki | + +**Output Value**: HTML contribution report (visual contribution distribution for team meetings), contributor leaderboard (quantifies each person's contribution, publicly transparent), Wiki leaderboard (permanent record, contributors can check rankings anytime), badge incentives (Issue-based badge awards boost sense of achievement and belonging). + +**How to Run**: + +```bash +# Basic run +bash workflows/05-contributor-growth.sh --owner your-org --repo your-repo + +# Custom time period (default: 30 days) +bash workflows/05-contributor-growth.sh --owner your-org --repo your-repo --period 90 + +# Enable auto badge awards +bash workflows/05-contributor-growth.sh --owner your-org --repo your-repo --award + +# Example +bash workflows/05-contributor-growth.sh --owner zzx-coder --repo gitlink-cli --award +``` + +--- + +### Environment Setup + +**1. Install gitlink-cli** + +```bash +gitlink-cli version +# If not installed, build from project root +cd gitlink-cli && make build +``` + +**2. Install jq (scripts use jq to parse CLI JSON output)** + +```bash +# Ubuntu/Debian +sudo apt-get install -y jq +# macOS +brew install jq +``` + +**3. Authenticate** + +```bash +gitlink-cli auth login # Interactive login (recommended) +export GITLINK_TOKEN="your-private-token" # Or env var +gitlink-cli auth status # Verify: should show "✓ Logged in" +``` + +**4. Verify Environment** + +```bash +gitlink-cli issue +list --owner zzx-coder --repo gitlink-cli --state open --limit 3 --format json | jq '.ok' +# Should output: true +``` + +--- + +### Shared Library `lib/common.sh` + +Infrastructure shared across all workflow scripts: + +| Function | Purpose | +|----------|---------| +| `check_auth` | Check auth status (env var or CLI login) | +| `gl_run` | CLI wrapper, auto-appends `--format json` | +| `gl_check` | CLI wrapper + JSON validation + ok field check | +| `json_ok` / `json_get` / `json_error` | JSON parsing utilities | +| `detect_owner_repo` | Auto-detect owner/repo from git remote | +| `log_step` / `log_ok` / `log_warn` / `log_err` | Colorized log output | + +--- + +### Workflow Common Parameters + +| Parameter | Description | +|-----------|-------------| +| `--owner` | Repository owner (auto-resolved from git remote) | +| `--repo` | Repository name (auto-resolved from git remote) | +| `--org` | Organization name (needed for multi-repo scenarios) | +| `--dry-run` | Preview mode, no write operations executed | +| `--help` | Show help message | + +--- + +### Skills Used by Workflows + +| Skill | Used In | Role | +|-------|---------|------| +| `gitlink-code-review` | Scenario 2 | Review dimensions, check items, scoring rubric | +| `gitlink-issue-triage` | Scenario 1 | Issue classification rules (keyword matching, priority detection) | +| `gitlink-changelog` | Scenario 1 | Release Notes generation templates | +| `gitlink-health` | Scenario 4 | Project health scoring model (100-point deduction) | +| `gitlink-onboard` | Scenario 5 | New contributor onboarding & Issue recommendation rules | +| `gitlink-workflow` | All | Base workflow orchestration | + +--- + +### Testing + +```bash +# Run test suite (validates against real GitLink repos) +bash workflows/test.sh + +# Specify a repo +bash workflows/test.sh zzx-coder gitlink-cli +``` + +Test coverage: auth status checks, CLI JSON output format validation, data field extraction (issue/PR/repo/release/member/contributor), PR files and diff content parsing, Issue/PR View interfaces, Wiki/Label list interfaces, `common.sh` utility functions, script syntax validation. + +--- + +### Workflows Directory Structure + +``` +workflows/ +├── lib/ +│ └── common.sh # Shared utility library (auth, JSON parse, CLI wrap, logging) +├── 01-community-ops.sh # Scenario 1: Community Ops Automation +├── 02-code-quality-gatekeeper.sh # Scenario 2: Code Quality Gatekeeper (AI review) +├── 03-project-init.sh # Scenario 3: One-Click Project Init +├── 04-multi-repo-collab.sh # Scenario 4: Multi-Repo Collaboration +├── 05-contributor-growth.sh # Scenario 5: Contributor Growth System +├── test.sh # Test suite +└── README.md # Detailed workflow documentation +``` + +--- ## Installation & Quick Start ### Requirements -- Node.js 14+ (`npm`/`npx`) — for npm installation - Supported platforms: macOS, Linux, Windows (x64/arm64) -- Go 1.26+ — only required for building from source +- Go 1.26+ — only needed for building from source -### Quick Start (Human Users) +### Quick Start (For Users) -> **Note for AI assistants:** If you are an AI Agent helping the user with installation, jump directly to [Quick Start (AI Agent)](#quick-start-ai-agent), which contains all the steps you need to complete. - -#### Install - -**From npm (recommended):** +**One-command install (recommended):** + +```bash +curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash +``` + +**From npm:** ```bash -# One command: installs CLI binary + all 12 AI Agent Skills npm install -g @gitlink-ai/cli ``` -The binary is auto-downloaded for your platform during `postinstall`. No extra steps needed. +The binary is auto-downloaded for your platform during `postinstall`. No extra steps. **From source:** -Requires Go 1.26+. - ```bash git clone https://www.gitlink.org.cn/Gitlink/gitlink-cli.git cd gitlink-cli make install ``` -> **Windows users:** Run `npm install -g @gitlink-ai/cli` in PowerShell or CMD. For building from source, use `go install .` instead of `make install`. +> Windows users: Run `npm install -g @gitlink-ai/cli` in PowerShell or CMD. For source builds use `go install .` instead of `make install`. -#### Configure & Use +**Configure & Use:** ```bash -# 1. Configure (one-time, interactive guided setup) +# 1. Configure (one-time) gitlink-cli config init # 2. Log in (choose one) gitlink-cli auth login # Username/password (recommended) gitlink-cli auth login --token # Or paste a private token -export GITLINK_TOKEN="your-token" # Or set env var (for CI/CD, non-interactive environments) +export GITLINK_TOKEN="your-token" # Or env var (for CI/CD) # 3. Start using gitlink-cli repo +list ``` -### Quick Start (AI Agent) +### Quick Start (For AI Agents) > The following steps are for AI Agents. Some steps require the user to complete actions in a browser. -**Step 1 — Install** - ```bash -# One command: CLI binary + all Skills auto-installed +# Step 1 — Install (one command: CLI + all Skills) npm install -g @gitlink-ai/cli -``` -**Step 2 — Configure** - -```bash +# Step 2 — Configure gitlink-cli config init -``` -**Step 3 — Login** +# Step 3 — Login +gitlink-cli auth login # Interactive environments +export GITLINK_TOKEN="your-private-token" # Non-interactive (CI/CD, MCP, etc.) -For interactive environments: -```bash -gitlink-cli auth login -``` - -For non-interactive environments (CI/CD, Trae sandbox, MCP, etc.): -```bash -export GITLINK_TOKEN="your-private-token" -``` - -> To get a private token, go to GitLink web → Settings → Private Tokens. - -**Step 4 — Verify** - -```bash +# Step 4 — Verify gitlink-cli user +me ``` +### Uninstall + +**Linux/macOS**: `bash uninstall.sh` +**Windows PowerShell**: `.\uninstall.ps1` +**npm**: `npm uninstall -g @gitlink-ai/cli` + +--- + ## Usage Examples ### Repository Operations ```bash -# List repositories gitlink-cli repo +list - -# View repository info gitlink-cli repo +info --owner Gitlink --repo forgeplus - -# Create a repository gitlink-cli repo +create -n my-project -d "Project description" - -# Fork a repository gitlink-cli repo +fork --owner Gitlink --repo forgeplus ``` ### Issue Management ```bash -# List issues gitlink-cli issue +list --owner Gitlink --repo forgeplus - -# Create an issue gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: Login failed" -b "Steps to reproduce..." - -# View an issue gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123 - -# Close an issue gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123 - -# Preview batch close without changing data gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run - -# Batch close issues from a CSV file gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv - -# Add a comment gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "Fixed" ``` ### Pull Requests ```bash -# List PRs gitlink-cli pr +list --owner Gitlink --repo forgeplus - -# Create a PR (same-repo branch) gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: Search feature" --head feature/search --base master - -# Create a PR (from a fork) gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: New feature" --head your_username/forgeplus:feature/my-feature --base master - -# View a PR gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42 - -# Merge a PR gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42 - -# View changed files gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42 ``` ### Branch Management ```bash -# List branches gitlink-cli branch +list --owner Gitlink --repo forgeplus - -# Create a branch gitlink-cli branch +create --name feature/new-feature - -# Delete a branch gitlink-cli branch +delete --name feature/old-feature - -# Protect a branch gitlink-cli branch +protect --name main - -# Remove branch protection gitlink-cli branch +unprotect --name main ``` +### Webhook Management + +```bash +gitlink-cli webhook +list --owner Gitlink --repo forgeplus +gitlink-cli webhook +create --owner Gitlink --repo forgeplus --url https://ci.example.com/webhook --events push,pull_request +gitlink-cli webhook +create --url https://jenkins.example.com/webhook --secret my-secret-key --events push --description "CI/CD trigger" +gitlink-cli webhook +info --id 456 +gitlink-cli webhook +update --id 456 --url https://new-url.example.com/webhook --events push,pull_request,issue +gitlink-cli webhook +test --id 456 +gitlink-cli webhook +delete --id 456 +gitlink-cli webhook +events +``` + ### Release Management ```bash -# List releases gitlink-cli release +list --owner Gitlink --repo forgeplus - -# Create a release gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 Stable" -b "Changelog..." - -# View a release gitlink-cli release +view --owner Gitlink --repo forgeplus -i ``` -### CI/CD Operations +### Wiki Management ```bash -# List builds -gitlink-cli ci +list --owner Gitlink --repo forgeplus - -# View build log -gitlink-cli ci +log --owner Gitlink --repo forgeplus -i - -# Restart a build -gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i +gitlink-cli wiki +list --owner Gitlink --repo forgeplus +gitlink-cli wiki +view --owner Gitlink --repo forgeplus --title "Getting Started" +gitlink-cli wiki +create --title "New Page" --content "Page content" +gitlink-cli wiki +update --title "New Page" --cover "Updated content" --owner Gitlink --repo forgeplus +gitlink-cli wiki +update --title "New Page" --add "Appended content" --owner Gitlink --repo forgeplus +gitlink-cli wiki +delete --title "New Page" --owner Gitlink --repo forgeplus ``` ### Search ```bash -# Search repositories gitlink-cli search +repos -k "machine learning" - -# Search users gitlink-cli search +users -k "zhangsan" ``` +### CI/CD + +```bash +gitlink-cli ci +list --owner Gitlink --repo forgeplus +gitlink-cli ci +log --owner Gitlink --repo forgeplus -i +gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i +``` + ### Raw API For endpoints not covered by shortcuts, use the Raw API directly: ```bash -# GET request gitlink-cli api GET /users/me - -# POST request gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}' - -# With query parameters gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5' ``` +--- + ## Global Parameters | Parameter | Description | Example | @@ -271,46 +802,25 @@ gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5' **Automatic context resolution:** When running inside a git repository, `--owner` and `--repo` are automatically resolved from `git remote origin`. -## Branch Conventions +--- -gitlink-cli supports bidirectional code sync between GitHub and GitLink: +## Branch Conventions | Platform | Default Branch | |----------|---------------| | GitHub | `main` | | GitLink | `master` | -**Push to GitLink from local:** +Push to GitLink: ```bash -# Method 1: Use git command directly git push gitlink main:master - -# Method 2: Configure git remote +# Or configure git remote git config remote.gitlink.push refs/heads/main:refs/heads/master git push gitlink ``` -## AI Agent Skills - -The `skills/` directory contains 12 Agent Skill files for AI-automated GitLink operations. - -See [skills/README.md](skills/README.md) for details. - -| Skill | Description | -|-------|-------------| -| `gitlink-shared` | Authentication, global parameters, safety rules, API notes | -| `gitlink-repo` | Repository operations (create, view, delete, fork, etc.) | -| `gitlink-issue` | Issue operations (create, update, close, comment, etc.) | -| `gitlink-pr` | Pull request operations (create, merge, review, etc.) | -| `gitlink-branch` | Branch management (create, delete, list, protect, unprotect) | -| `gitlink-release` | Release management (create, view, delete, etc.) | -| `gitlink-ci` | CI/CD operations (builds, logs, etc.) | -| `gitlink-search` | Search (repositories, users, etc.) | -| `gitlink-org` | Organization management (members, teams, etc.) | -| `gitlink-user` | User management (profile info, etc.) | -| `gitlink-pm` | Project management (sprints, kanban, weekly reports, etc.) | -| `gitlink-workflow` | AI-powered workflows (issue triage, PR review, release notes, etc.) | +--- ## Project Structure @@ -327,47 +837,59 @@ gitlink-cli/ │ ├── client/ # HTTP client + pagination │ ├── config/ # Config file management │ ├── context/ # Git remote resolution -│ └── output/ # Envelope + formatter +│ └── output/ # Output envelope + formatter ├── shortcuts/ # Shortcut implementations │ ├── common/ # Framework (types, runner) -│ ├── repo/ # Repository shortcuts -│ ├── issue/ # Issue shortcuts -│ ├── pr/ # PR shortcuts -│ ├── branch/ # Branch shortcuts -│ ├── release/ # Release shortcuts -│ ├── org/ # Organization shortcuts -│ ├── ci/ # CI shortcuts -│ ├── search/ # Search shortcuts -│ ├── user/ # User shortcuts +│ ├── repo/ issue/ pr/ branch/ release/ org/ ci/ search/ user/ wiki/ │ └── register.go # Registration entry point ├── skills/ # AI Agent Skills -│ ├── README.md # Skills guide +│ ├── README.md # Skills detailed guide │ ├── gitlink-shared/ # Shared rules -│ ├── gitlink-repo/ # Repository skill -│ ├── gitlink-issue/ # Issue skill -│ ├── gitlink-pr/ # PR skill -│ ├── gitlink-pm/ # Project management skill +│ ├── gitlink-repo/ issue/ pr/ branch/ release/ ... +│ ├── gitlink-health/ # Project health +│ ├── gitlink-changelog/ # Release Notes +│ ├── gitlink-code-review/ # AI code review +│ ├── gitlink-research/ # Research assistance │ └── ... +├── workflows/ # End-to-end automation workflows +│ ├── README.md # Workflow documentation +│ ├── lib/common.sh # Shared utility library +│ ├── 01-community-ops.sh # Community Ops Automation +│ ├── 02-code-quality-gatekeeper.sh # Code Quality Gatekeeper +│ ├── 03-project-init.sh # One-Click Project Init +│ ├── 04-multi-repo-collab.sh # Multi-Repo Collaboration +│ ├── 05-contributor-growth.sh # Contributor Growth System +│ └── test.sh # Test suite ├── doc/ # Design documents -│ ├── Design.md +│ ├── design.md │ ├── CODE_SYNC_STRATEGY_FINAL.md │ └── ... ├── main.go ├── Makefile ├── go.mod -└── README.md +├── README.md +└── README.zh-CN.md ``` -## Documentation +--- -- [Skills Guide](skills/README.md) — AI Agent Skills detailed documentation -- [Design Document](doc/design.md) — Architecture design and development plan +## Documentation Index + +- [Skills Guide](skills/README.md) — Detailed AI Agent Skills documentation +- [Workflow Documentation](workflows/README.md) — Detailed workflow documentation +- [Design Document](doc/design.md) — Architecture design & development plan +- [Installation Guide](doc/INSTALL.md) — Detailed install steps +- [Uninstall Guide](doc/UNINSTALL.md) — Detailed uninstall steps +- [API Reference](skills/gitlink-shared/REFERENCE.md) — Complete API reference +- [Troubleshooting](skills/gitlink-shared/TROUBLESHOOTING.md) — Common issues & solutions + +--- ## FAQ ### Q: How do I use gitlink-cli in scripts? -Use the `GITLINK_TOKEN` environment variable + `--format json` for structured output: +Use the `GITLINK_TOKEN` env var + `--format json`: ```bash export GITLINK_TOKEN="your-private-token" @@ -385,45 +907,39 @@ gitlink-cli issue +list # Automatically uses the current repository ### Q: What if my token expires? -Re-authenticate: - ```bash -# Username/password login -gitlink-cli auth login - -# Or use a private token (generate at GitLink web → Settings → Private Tokens) -gitlink-cli auth login --token +gitlink-cli auth login # Username/password login +gitlink-cli auth login --token # Or use a private token ``` -### Q: How do I use gitlink-cli in CI/CD or non-interactive environments (e.g. Trae sandbox)? +### Q: How do I use gitlink-cli in CI/CD or non-interactive environments? Set the `GITLINK_TOKEN` environment variable — no `auth login` needed: ```bash export GITLINK_TOKEN="your-private-token" gitlink-cli repo +list # Ready to use -gitlink-cli auth status # Shows "✓ Logged in via GITLINK_TOKEN environment variable" ``` -Priority: `GITLINK_TOKEN` env var > keyring/file stored token. When the env var is not set, the original interactive login flow works as before. +Priority: `GITLINK_TOKEN` env var > keychain/file-stored token. -### Q: What if npm installs successfully but `gitlink-cli` reports a missing binary? +### Q: npm install succeeded but `gitlink-cli` reports a missing binary? -Reinstall first: - -```bash -npm install -g @gitlink-ai/cli -``` - -If the error persists, check whether the release page contains the asset for your platform, for example `gitlink-cli__windows_amd64.zip` on Windows x64. You can also download the binary manually from the release page or build from source with `go install .`. +Reinstall first: `npm install -g @gitlink-ai/cli`. If the error persists, check the release page for your platform's asset, or download the binary manually / build from source with `go install .`. ### Q: Where are credentials stored on Windows? -gitlink-cli uses Windows Credential Manager for secure token storage. If Credential Manager is unavailable, it automatically falls back to file storage (`~/.config/gitlink-cli/credentials`). +gitlink-cli uses Windows Credential Manager for secure token storage. If Credential Manager is unavailable, it falls back to file storage (`~/.config/gitlink-cli/credentials`). -### Q: Where can I find the full API reference? +--- -See [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md). +## Related Resources + +- [gitlink-bisync](https://www.gitlink.org.cn/wbtiger/gitlink-bisync) — Bidirectional code sync system +- [Test Report](doc/SKILLS_TEST_REPORT_2026-04-02.md) — Skills functional test report +- [Code Sync Strategy](doc/CODE_SYNC_STRATEGY_FINAL.md) — GitHub ↔ GitLink sync design + +--- ## License diff --git a/README.zh-CN.md b/README.zh-CN.md index 4531992..0c911ef 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -5,241 +5,793 @@ [![Go Version](https://img.shields.io/badge/Go-1.26%2B-blue.svg)](https://golang.org) [![npm version](https://img.shields.io/npm/v/@gitlink-ai/cli.svg)](https://www.npmjs.com/package/@gitlink-ai/cli) -[GitLink(确实开源)](https://www.gitlink.org.cn) 官方 CLI 工具 — 为人类和 AI Agent 双重设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、CI/CD 和 AI 自动化工作流,包含 40+ 命令和 11 个 AI Agent [Skills](./skills/)。 +GitLink 官方命令行工具,面向开发者与 AI Agent 设计。支持 **macOS、Linux、Windows**,覆盖仓库管理、Issue 追踪、Pull Request、CI/CD 与 AI 自动化工作流,提供 **40+ 命令**与 **20+ AI Agent Skills**。 **[English](./README.md)** -[安装](#安装与快速上手) · [AI Agent Skills](#ai-agent-skills) · [认证](#配置与使用) · [命令](#使用示例) · [贡献](#相关项目) +--- -## 为什么选择 gitlink-cli? +## 项目概述 -- **Agent-Native 设计** — 开箱即用 11 个结构化 [Skills](./skills/),兼容 Claude Code — Agent 零配置即可操作 GitLink -- **广泛覆盖** — 仓库、Issue、PR、分支、Release、CI、组织、搜索、用户 — 核心功能全覆盖 -- **AI 友好 & 优化** — 每条命令都经过真实 Agent 测试,简洁参数、智能默认值、结构化输出 -- **跨平台** — macOS、Linux、Windows (x64/arm64) 全支持,`npm` 一条命令安装 -- **开源零门槛** — 木兰宽松许可证第2版(MulanPSL-2.0),`npm install` 即用 -- **3 分钟上手** — 交互式登录或 `GITLINK_TOKEN` 环境变量,从安装到首次 API 调用仅需 3 步 -- **安全可控** — OS 原生 keychain 凭证存储,`GITLINK_TOKEN` 环境变量支持 CI/CD 和非交互环境,自动 git remote 上下文解析 -- **三层架构** — Shortcuts(人+AI友好)→ Raw API(全覆盖)→ Config(配置管理) +本项目围绕 GitLink 开源协作平台的自动化能力建设,涵盖三个子任务方向。 -## 功能一览 +### 子任务一:增加和完善 GitLink-CLI 能力(50%) -| 分类 | 能力 | +> 定位:扩展 CLI 功能 | 难度:中高 | 技术栈:Go + +构建健壮、跨平台、AI 友好的命令行工具,覆盖 GitLink 平台全功能。 + +**核心架构 — 三层设计:** + +``` +Shortcuts(40+ 命令) + → 面向人类和 AI Agent 的简洁命令 + → issue +create, pr +list, wiki +create ... + ↓ +Raw API(全接口覆盖) + → 直接调用 GitLink OpenAPI + → api GET /users/me, api POST /.../issues ... + ↓ +Config & Auth(配置管理层) + → 认证、上下文解析、输出格式 + → auth login, config init, --format json +``` + +**CLI 能力一览:** + +| 类别 | 能力 | |------|------| -| 📦 仓库 | 列出、创建、Fork、删除仓库,查看仓库信息 | -| 🐛 Issue | 创建、更新、关闭、批量关闭、评论 Issue | -| 🔀 PR | 创建、合并、Review Pull Request,查看变更文件 | -| 🌿 分支 | 创建、删除、保护分支 | -| 🏷️ 发布 | 创建、查看、删除 Release | +| 📦 仓库 | 列表、创建、Fork、删除、查看详情 | +| 🐛 Issue | 创建、更新、关闭、评论,6 种批量操作(关闭/状态/优先级/指派人/标签/创建) | +| 📖 Wiki | 查看、创建、更新、删除 Wiki 页面 | +| 🔀 PR | 创建、合并、Review、查看变更文件 | +| 🌿 分支 | 创建、删除、列表、保护、解除保护 | +| 🏷️ Release | 创建、查看、删除版本发布 | +| 🔗 Webhook | 创建、查看、更新、删除、测试 Webhook | | 🏢 组织 | 管理组织、成员、团队 | | 🔧 CI | 查看构建、日志、CI/CD 操作 | | 🔍 搜索 | 搜索仓库、用户 | -| 👤 用户 | 查看用户资料和信息 | +| 👤 用户 | 查看用户资料 | | 📋 项目管理 | Sprint 管理、看板、周报 | -| 🤖 工作流 | AI 驱动的 Issue 分类、PR Review、Release Notes | +| 🔌 Raw API | 直接调用任意 GitLink OpenAPI 端点 | -## 安装与快速上手 +**技术亮点:** -### 前置条件 +- **Agent-Native 设计** — 每个命令经过真实 Agent 测试,参数精简、默认值智能、输出结构化 +- **跨平台** — macOS / Linux / Windows (x64/arm64),一键安装 +- **安全可控** — OS 原生密钥链存储、`GITLINK_TOKEN` 环境变量支持 CI/CD +- **智能上下文解析** — 自动从 `git remote origin` 解析 owner/repo,减少参数输入 +- **开箱即用** — MulanPSL-2.0 开源,`npm install -g @gitlink-ai/cli` 一条命令 -- Node.js 14+(`npm`/`npx`)— 用于 npm 安装 -- 支持平台:macOS、Linux、Windows(x64/arm64) -- Go 1.26+ — 仅从源码构建时需要 +### 子任务二:编写和丰富 GitLink Skills(20%) -### 快速上手(人类用户) +> 定位:开发 Agent Skill | 难度:中 | 无需 Go,Markdown + CLI 调用即可 -> **AI 助手请注意:** 如果你是帮助用户安装的 AI Agent,请直接跳到 [快速上手(AI Agent)](#快速上手ai-agent),其中包含你需要完成的所有步骤。 +Skills 是为 Claude Code 及其他 AI Agent 设计的结构化知识库。每个 Skill 包含 `SKILL.md`(命令参考)、`REFERENCE.md`(API 参考)、`TROUBLESHOOTING.md`(故障排查)和 `examples/`(完整工作流示例)。AI Agent 通过读取 Skills 即可自动操作 GitLink 平台,无需手动查阅文档。 -#### 安装 +**Skills 目录结构:** -选择以下**任一**方式: - -**方式 1 — 从 npm 安装(推荐):** - -```bash -# 安装 CLI -npm install -g @gitlink-ai/cli - -# 安装 CLI Skill(必须,全平台通用) -gitlink-cli-install-skills - -# 也可使用 npx 安装 Skill -npx skills add ccfos/gitlink-cli/skills -y -g +``` +skills/ +├── gitlink-shared/ # 认证、全局参数、安全规则、分支约定 +├── gitlink-repo/ # 仓库管理 +├── gitlink-issue/ # Issue 管理 +├── gitlink-pr/ # Pull Request +├── gitlink-branch/ # 分支管理 +├── gitlink-release/ # 版本发布 +├── gitlink-wiki/ # Wiki 管理 +├── gitlink-webhook/ # Webhook 管理 +├── gitlink-org/ # 组织管理 +├── gitlink-user/ # 用户管理 +├── gitlink-ci/ # CI/CD +├── gitlink-search/ # 搜索功能 +├── gitlink-pm/ # 项目管理(Sprint / 看板 / 周报) +├── gitlink-team/ # 团队管理 +├── gitlink-changelog/ # Release Notes / Changelog 生成 +├── gitlink-health/ # 项目健康度报告(Issue 响应时间、PR 合并效率、贡献者活跃度) +├── gitlink-contrib/ # 贡献报告(统计与排名) +├── gitlink-compliance/ # 安全与合规(许可证扫描、敏感信息检测、PII 扫描) +├── gitlink-issue-triage/ # Issue 自动分类(tracker/priority/labels 判定) +├── gitlink-onboard/ # 新人引导(Good First Issue 识别与欢迎评论) +├── gitlink-workflow/ # AI 工作流编排(Issue 分类、PR Review、Release Notes) +├── gitlink-code-review/ # 智能代码审查(四维度评分、结构化 Review 意见) +├── gitlink-research/ # 科研辅助(项目洞察、热点追踪、合规复现、协作匹配、论文引用) +└── gitlink-code-insight/ # 功能全景索引(全部 Shortcuts 分类展示) ``` -**方式 2 — 从源码构建:** +**所有 Skills 概览:** -需要 Go 1.26+。 +| Skill | 说明 | 典型命令 | +|-------|------|----------| +| **gitlink-shared** | 认证、全局参数、API 参考、安全规则 | `auth login`, `auth status` | +| **gitlink-repo** | 仓库管理 | `repo +list`, `repo +create`, `repo +info`, `repo +fork` | +| **gitlink-issue** | Issue 管理 | `issue +create`, `issue +list`, `issue +view`, `issue +close`, `issue +batch-close` | +| **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +review` | +| **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` | +| **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +view` | +| **gitlink-wiki** | Wiki 管理 | `wiki +list`, `wiki +view`, `wiki +create`, `wiki +update`, `wiki +delete` | +| **gitlink-webhook** | Webhook 管理 | `webhook +list`, `webhook +create`, `webhook +test` | +| **gitlink-org** | 组织管理 | `org +list`, `org +info`, `org +members` | +| **gitlink-user** | 用户管理 | `user +me`, `user +info` | +| **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` | +| **gitlink-search** | 搜索功能 | `search +repos`, `search +users` | +| **gitlink-pm** | 项目管理 | Sprint 管理、看板、周报(通过 Raw API) | +| **gitlink-team** | 团队管理 | `team +list`, `team +create`, `team +add-member` | +| **gitlink-changelog** | Release Notes 生成 | 自动收集 commits/PR/Issue,生成结构化版本说明 | +| **gitlink-health** | 项目健康度报告 | Issue 响应时间、PR 合并效率、贡献者活跃度统计 | +| **gitlink-contrib** | 贡献报告 | `contrib +report` | +| **gitlink-compliance** | 安全与合规 | `compliance +scan`, `compliance +secrets`, `compliance +license` | +| **gitlink-issue-triage** | Issue 自动分类 | 自动判定 tracker/priority/labels,生成审计报告 | +| **gitlink-onboard** | 新人引导 | `onboard +welcome` | +| **gitlink-workflow** | AI 工作流编排 | Issue 分类、PR Review、仓库初始化、Sprint 报告 | +| **gitlink-code-review** | 智能代码审查 | 四维度评分(代码质量/安全性/性能/可维护性)、自动发布 Review 评论 | +| **gitlink-research** | 科研辅助 | 项目洞察、热点追踪、合规复现、协作匹配、进度预警、论文引用 | +| **gitlink-code-insight** | 功能全景索引 | 全部 Shortcuts 分类索引,含说明和示例 | + +**AI Agent 如何使用 Skills:** + +``` +用户: "帮我在 GitLink 上创建一个 Issue" + ↓ +AI Agent 读取 gitlink-issue/SKILL.md + ↓ +AI Agent 执行: gitlink-cli issue +create -t "..." -b "..." + ↓ +完成! +``` + +AI Agent 可以自动完成:创建/管理 Issue、创建/合并 PR、发布 Release、管理 Wiki、分类 Issue、生成 Release Notes、执行代码审查等。 + +### 子任务三:构建端到端自动化工作流(20%) + +> 定位:组合现有能力解决实际问题 | 难度:低-中 | 无需写底层代码 + +组合 gitlink-cli 命令与 Skills,串联多个步骤形成完整的可复现自动化场景。工作流通过 Shell/PowerShell 脚本编排,支持 AI Agent 驱动执行。 + +--- + +**五大自动化工作流总览:** + +| # | 场景 | 脚本 | 串联命令数 | 解决什么问题 | +|---|------|------|-----------|-------------| +| 1 | 社区运营自动化 | `01-community-ops.sh` | 7 | Issue 积压无人处理、周报手写、Release Notes 手动整理 | +| 2 | 代码质量看门人 | `02-code-quality-gatekeeper.sh` | 7 | PR 审查效率低、质量标准不统一、AI 代码审查 | +| 3 | 项目一键初始化 | `03-project-init.sh` | 8 | 新建项目重复劳动多、README/License/CI/Issue/里程碑手动配 | +| 4 | 多仓库协同 | `04-multi-repo-collab.sh` | 7 | 跨仓库状态分散、缺乏统一视图 | +| 5 | 贡献者成长体系 | `05-contributor-growth.sh` | 6 | 贡献者活跃度难追踪、缺乏激励机制 | + +--- + +### 场景一:社区运营自动化 + +**脚本**:`workflows/01-community-ops.sh` + +**工作流程**: + +``` +issue +list → 读取所有 open Issue + ↓ +按关键词分类:Bug / Feature / Question / Docs + ↓ +issue +label-add → 自动打标签 + ↓ +repo +members → 获取仓库成员列表 +issue +update → 轮询分配负责人 + ↓ +pr +list → 统计本周合并的 PR +issue +list → 统计本周关闭的 Issue + ↓ +wiki +create → 发布社区周报到 Wiki + ↓ +release +create → 自动生成 Release Notes +``` + +**串联的命令**: + +| 步骤 | 命令 | 作用 | +|------|------|------| +| 1 | `issue +list` | 获取所有 open Issue | +| 2 | `issue +label-add` | 按分类打标签(bug/feature/question/documentation) | +| 3 | `repo +members` | 获取仓库成员列表 | +| 4 | `issue +update` | 给 Bug/Feature Issue 分配负责人 | +| 5 | `pr +list` | 统计本周合并的 PR | +| 6 | `wiki +create` | 发布社区周报 | +| 7 | `release +create` | 自动生成 Release Notes | + +**输出价值**:标签分类(仓库 Issue 页面可按标签筛选)、负责人分配(每个 Issue 有明确负责人)、Wiki 周报(团队和社区用户在 Wiki 查看每周进展)、Release Notes(发版时无需手动整理变更)。 + +**运行方式**: + +```bash +bash workflows/01-community-ops.sh --owner 你的组织 --repo 你的仓库 +bash workflows/01-community-ops.sh --owner zzx-coder --repo gitlink-cli +``` + +--- + +### 场景二:代码质量看门人 + +**脚本**:`workflows/02-code-quality-gatekeeper.sh` + +**工作流程**: + +``` +pr +list → 获取所有 open PR + ↓ +pr +view → 读取 PR 详情 +pr +files → 获取变更文件列表 +pr +diff → 获取代码差异 + ↓ +加载 gitlink-code-review Skill: + - 审查维度与检查项 + - 评分标准(90-100 优秀,75-89 良好,...) + - 问题严重级别(CRITICAL/HIGH/MEDIUM/LOW) + ↓ +┌─────────────────────────────────────────┐ +│ AI 代码审查(Claude + Skill) │ +│ 四维度评分(各 0-25,总分 100): │ +│ - 代码质量:复杂度、命名、注释 │ +│ - 安全性:SQL注入、XSS、敏感信息 │ +│ - 性能:循环效率、资源泄漏、N+1 │ +│ - 可维护性:重复、职责单一、耦合 │ +│ │ +│ 输出: │ +│ - 结构化问题清单(severity+file+ │ +│ rule+description+suggestion) │ +│ - 优秀实践(positive_notes) │ +│ - 改进建议(recommendations) │ +│ - 总分 + PASS/FAIL │ +└─────────────────────────────────────────┘ + ↓ +api POST /reviews → 发布审查评论到 PR + ↓ +ci +builds → 检查 CI 构建状态 + ↓ +pr +merge → 分数 >= 阈值 且 CI 通过 → 自动合并 +``` + +**AI 审查示例输出**: + +``` +Overall Score: 88 / 100 +Code Quality: 23 / 25 +Security: 25 / 25 +Performance: 20 / 25 +Maintainability: 20 / 25 + +Issues Found: + - [LOW] quality: 条目格式说明中 PR 条目用 (@作者) 带括号, + commit 条目用 (作者名) 不带 @ 前缀 + → 统一格式规范,建议 commit 条目也使用 (@作者) 格式 + - [LOW] maintainability: 示例中贡献者列表变更但完整变更日志链接仍指向旧仓库 + → 将变更日志链接中的 OWNER 也更新为与示例贡献者一致 + +Positive Notes: + + 变更目的清晰,所有文件的修改一致地贯彻了需求,无遗漏 + + 变更范围合理,仅修改文档和示例,不涉及代码逻辑变更,风险极低 + +Recommendations: + > 统一 PR 条目和 commit 条目的作者标注格式 + > 在 collect-data.md 中补充 author 字段为空时的降级处理说明 +``` + +**串联的命令**: + +| 步骤 | 命令 | 作用 | +|------|------|------| +| 1 | `pr +list` | 获取 open PR 列表 | +| 2 | `pr +view` | 读取 PR 详情(标题、作者、状态) | +| 3 | `pr +files` | 获取变更文件列表 | +| 4 | `pr +diff` | 获取代码差异内容 | +| 5 | `gitlink-code-review` | 加载 Skill 的审查维度、检查项、评分标准 | +| 6 | `claude -p` | AI 按 Skill 方法论进行四维度代码审查 | +| 7 | `api POST .../reviews` | 将审查评论发布到 PR | +| 8 | `pr +merge` | 质量分 >= 阈值且 CI 通过时自动合并 | + +**输出价值**:结构化评分(每个 PR 有 0-100 的质量评分,团队可设定统一合并门槛)、AI 问题清单(自动列出安全隐患、性能问题、代码质量问题)、PR 评论(审查结果直接评论在 PR 上)、自动合并(高质量 PR 无需人工点击)。 + +**运行方式**: + +```bash +# 审查所有 open PR +bash workflows/02-code-quality-gatekeeper.sh --owner 你的组织 --repo 你的仓库 + +# 审查指定 PR +bash workflows/02-code-quality-gatekeeper.sh --owner 你的组织 --repo 你的仓库 --pr-id 42 + +# 自定义质量阈值(默认 80) +bash workflows/02-code-quality-gatekeeper.sh --owner 你的组织 --repo 你的仓库 --threshold 70 + +# 预览模式(不实际合并) +bash workflows/02-code-quality-gatekeeper.sh --owner 你的组织 --repo 你的仓库 --dry-run + +# 示例 +bash workflows/02-code-quality-gatekeeper.sh --owner zzx-coder --repo gitlink-cli --pr-id 20 +``` + +--- + +### 场景三:项目一键初始化 + +**脚本**:`workflows/03-project-init.sh` / `03-project-init.ps1` + +**工作流程**: + +``` +repo +create → 创建仓库 + ↓ +git clone → 克隆空仓库到本地 + ↓ +生成文件 → README.md(语言模板)+ LICENSE(MIT) + + CONTRIBUTING.md + .gitlink-ci.yml + ↓ +git add/commit/push → 将脚手架文件推送到仓库 + ↓ +milestone +create × 3 → 创建项目里程碑: + - v0.1.0 - MVP + - v0.2.0 - Feature Complete + - v1.0.0 - Production Ready + ↓ +issue +create × 5 → 创建初始待办 Issue(打标签): + - 搭建 CI/CD 流水线 + - 编写项目文档 + - 建立代码审查流程 + - 添加单元测试 + - 配置依赖管理 + ↓ +branch +protect → 保护 master 分支 + ↓ +release +create → 创建 v0.1.0 初始版本 +``` + +**串联的命令**: + +| 步骤 | 命令 | 作用 | +|------|------|------| +| 1 | `repo +create` | 创建新仓库 | +| 2 | `git clone` | 克隆空仓库到本地临时目录 | +| 3 | 文件生成 | 生成 README.md / LICENSE / CONTRIBUTING.md / .gitlink-ci.yml | +| 4 | `git add/commit/push` | 推送脚手架文件到 master 分支 | +| 5 | `milestone +create` | 创建 3 个项目里程碑(MVP → Production) | +| 6 | `issue +create` | 创建 5 个初始 Issue 并打标签 | +| 7 | `branch +protect` | 设置 master 分支保护规则 | +| 8 | `release +create` | 创建 v0.1.0 初始版本 | + +**输出价值**:开箱即用(克隆后仓库已有 README、LICENSE、CI 配置,可直接开始开发)、仓库内文件(README/LICENSE/CONTRIBUTING/CI 是真实文件,不是 Wiki 页面)、里程碑规划(从 MVP 到正式版的路线图已建立)、标准化 Issue(关键待办已创建好,团队可直接认领)、分支保护(防止直接 push 到 master,强制走 PR 流程)、首个 Release(项目从创建之初就有版本管理)。 + +**运行方式**: + +```bash +# Go 项目 +bash workflows/03-project-init.sh --owner 你的组织 --name my-go-app --description "我的Go应用" --lang go + +# Python 项目(私有) +bash workflows/03-project-init.sh --owner 你的组织 --name my-api --description "REST API服务" --lang python --private + +# Node.js 项目 +bash workflows/03-project-init.sh --owner 你的组织 --name my-web --description "Web前端" --lang node + +# Java 项目 +bash workflows/03-project-init.sh --owner 你的组织 --name my-service --description "微服务" --lang java +``` + +--- + +### 场景四:多仓库协同 + +**脚本**:`workflows/04-multi-repo-collab.sh` / `04-multi-repo-collab.ps1` + +**工作流程**: + +``` +repo +list → 列出组织下所有仓库 + ↓ +对每个仓库: + issue +list → 获取 open/closed Issue + pr +list → 获取 open/merged PR + release +list → 获取最新 Release + ↓ +计算健康度评分(对齐 gitlink-health Skill · 100 分扣分制): + 核心指标(75 分):Issue 解决率 < 50% 扣 25、PR 合并率 < 50% 扣 25、近期无活动扣 25 + 辅助指标(25 分):Open Issue > 20 扣 10、Open PR > 10 扣 10、超 30 天无 Release 扣 5 + 五级评定:优秀(≥90)→ 良好(≥70)→ 一般(≥50)→ 需关注(≥30)→ 严重(<30) + ↓ +生成 HTML 仪表盘: + - 总览卡片:仓库数、Open Issue 数、Open PR 数、总活动量 + - 详情表格:每个仓库的 Issue/PR/Release 状态 + 折叠明细 + - 健康度色带:绿色优秀 / 蓝色良好 / 黄色一般 / 橙色需关注 / 红色严重 + ↓ +(可选)release +create → 一键为所有仓库创建同一版本号的 Release +``` + +**串联的命令**: + +| 步骤 | 命令 | 作用 | +|------|------|------| +| 1 | `repo +list` | 列出组织下所有仓库 | +| 2 | `issue +list` | 获取每个仓库的 Issue 数据 | +| 3 | `pr +list` | 获取每个仓库的 PR 数据 | +| 4 | `release +list` | 获取每个仓库的最新 Release | +| 5 | 生成 HTML | 输出可视化仪表盘 | +| 6 | `release +create` | (可选)协调发版 | + +**输出价值**:统一视图(一个 HTML 页面看到组织所有仓库的健康状态)、健康度预警(Open Issue 超 10 标橙色,超 20 标红色)、协调发版(多个关联仓库需要同步发版时,一条命令搞定)、可分享(HTML 文件可直接发给团队或部署到内部网站)。 + +**运行方式**: + +```bash +# 扫描组织下所有仓库 +bash workflows/04-multi-repo-collab.sh --org 你的组织 + +# 只看指定仓库 +bash workflows/04-multi-repo-collab.sh --org 你的组织 --repos "repo-a,repo-b,repo-c" + +# 生成仪表盘 + 协调发版 +bash workflows/04-multi-repo-collab.sh --org 你的组织 --release v2.0.0 + +# 自定义输出文件 + 详情限制条数 +bash workflows/04-multi-repo-collab.sh --org 你的组织 --output my-dashboard.html --detail-limit 10 + +# 预览模式 +bash workflows/04-multi-repo-collab.sh --org 你的组织 --dry-run + +# 示例 +bash workflows/04-multi-repo-collab.sh --org zzx-coder +``` + +运行后在当前目录生成 `dashboard.html`,浏览器打开即可查看。 + +--- + +### 场景五:贡献者成长体系 + +**脚本**:`workflows/05-contributor-growth.sh` + +**工作流程**: + +``` +contrib +report → 生成带 ECharts 饼图的 HTML 贡献报告 + ↓ +issue +list → 统计 Issue 活动 +pr +list → 统计 PR 活动 +api GET /contributors → 获取提交数等 API 统计 + ↓ +计算贡献分数(AHP 权重模型): + - 代码变更量:30%(归一化处理) + - PR 被合并:25%(归一化处理) + - 创建/解决 Issue:15%(归一化处理) + - Issue 评论:15%(归一化处理) + - 团队成员:15%(是/否 二分) + → 加权求和得 0-100 综合分 + ↓ +评定等级: + Champion(冠军) >= 80 分 + Core Contributor(核心) >= 60 分 + Active Contributor(活跃) >= 40 分 + Contributor(贡献者) >= 20 分 + Newcomer(新人) < 20 分 + ↓ +(可选)issue +create → 自动创建徽章颁发 Issue + ↓ +wiki +create → 发布排行榜到 Wiki +``` + +**串联的命令**: + +| 步骤 | 命令 | 作用 | +|------|------|------| +| 1 | `contrib +report` | 生成 HTML 贡献报告(带 ECharts 图表) | +| 2 | `issue +list` | 统计 open/closed Issue 活动 | +| 3 | `pr +list` | 统计 open/merged PR 活动 | +| 4 | `api GET /contributors` | 获取 API 级别的贡献者统计 | +| 5 | `issue +create` | (可选)自动颁发成就徽章 | +| 6 | `wiki +create` | 发布排行榜到 Wiki | + +**输出价值**:HTML 贡献报告(可视化展示贡献分布,适合团队会议演示)、贡献排行榜(量化每个人的贡献,公开透明)、Wiki 排行榜(永久保存,贡献者可随时查看排名)、徽章激励(通过 Issue 颁发徽章,增强成就感和归属感)。 + +**运行方式**: + +```bash +# 基本运行 +bash workflows/05-contributor-growth.sh --owner 你的组织 --repo 你的仓库 + +# 自定义统计周期(默认 30 天) +bash workflows/05-contributor-growth.sh --owner 你的组织 --repo 你的仓库 --period 90 + +# 启用自动颁发徽章 +bash workflows/05-contributor-growth.sh --owner 你的组织 --repo 你的仓库 --award + +# 示例 +bash workflows/05-contributor-growth.sh --owner zzx-coder --repo gitlink-cli --award +``` + +--- + +### 环境准备 + +**1. 安装 gitlink-cli** + +```bash +gitlink-cli version +# 未安装则从项目根目录构建 +cd gitlink-cli && make build +``` + +**2. 安装 jq(脚本用 jq 解析 CLI 返回的 JSON)** + +```bash +# Ubuntu/Debian +sudo apt-get install -y jq +# macOS +brew install jq +``` + +**3. 登录认证** + +```bash +gitlink-cli auth login # 交互式登录(推荐) +export GITLINK_TOKEN="你的私人令牌" # 或环境变量 +gitlink-cli auth status # 验证:应显示 ✓ Logged in +``` + +**4. 验证环境** + +```bash +gitlink-cli issue +list --owner zzx-coder --repo gitlink-cli --state open --limit 3 --format json | jq '.ok' +# 应输出:true +``` + +--- + +### 共享库 `lib/common.sh` + +所有工作流脚本共享的基础设施: + +| 函数 | 作用 | +|------|------| +| `check_auth` | 检查认证状态(环境变量 或 CLI 登录) | +| `gl_run` | CLI 封装,自动追加 `--format json` | +| `gl_check` | CLI 封装 + JSON 格式校验 + ok 字段检查 | +| `json_ok` / `json_get` / `json_error` | JSON 解析工具 | +| `detect_owner_repo` | 从 git remote 自动检测 owner/repo | +| `log_step` / `log_ok` / `log_warn` / `log_err` | 彩色日志输出 | + +--- + +### 工作流通用参数 + +| 参数 | 说明 | +|------|------| +| `--owner` | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 仓库名称(自动从 git remote 解析) | +| `--org` | 组织名称(多仓库协同时需要) | +| `--dry-run` | 预览模式,不实际执行写操作 | +| `--help` | 显示帮助信息 | + +--- + +### 涉及的 Skill + +| Skill | 被使用的场景 | 作用 | +|-------|-------------|------| +| `gitlink-code-review` | 场景 2 | 审查维度、检查项、评分标准 | +| `gitlink-issue-triage` | 场景 1 | Issue 分类规则(关键词匹配、优先级判定) | +| `gitlink-changelog` | 场景 1 | Release Notes 生成模板 | +| `gitlink-health` | 场景 4 | 项目健康度评分体系(100 分扣分制) | +| `gitlink-onboard` | 场景 5 | 新人引导和 Issue 推荐规则 | +| `gitlink-workflow` | 全部 | 基础工作流编排 | + +--- + +### 测试 + +```bash +# 运行测试套件(使用真实 GitLink 仓库验证) +bash workflows/test.sh + +# 指定仓库 +bash workflows/test.sh zzx-coder gitlink-cli +``` + +测试覆盖:认证状态检查、CLI JSON 输出格式验证、数据字段提取(issue/PR/repo/release/member/contributor)、PR 文件和 Diff 内容解析、Issue/PR View 接口、Wiki/Label 列表接口、`common.sh` 工具函数、所有脚本语法校验。 + +--- + +### 项目结构(workflows/) + +``` +workflows/ +├── lib/ +│ └── common.sh # 共享工具库(认证、JSON解析、CLI封装、日志) +├── 01-community-ops.sh # 场景一:社区运营自动化 +├── 02-code-quality-gatekeeper.sh # 场景二:代码质量看门人(AI审查) +├── 03-project-init.sh # 场景三:项目一键初始化 +├── 04-multi-repo-collab.sh # 场景四:多仓库协同 +├── 05-contributor-growth.sh # 场景五:贡献者成长体系 +├── test.sh # 测试套件 +└── README.md # 工作流详细文档 +``` + +--- + +## 安装与快速开始 + +### 环境要求 + +- 支持平台:macOS、Linux、Windows (x64/arm64) +- Go 1.26+(仅从源码构建时需要) + +### 快速开始(面向用户) + +**一键安装(推荐):** + +```bash +curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash +``` + +**从 npm 安装:** + +```bash +npm install -g @gitlink-ai/cli +``` + +二进制文件在 `postinstall` 阶段自动下载,无需额外操作。 + +**从源码构建:** ```bash git clone https://www.gitlink.org.cn/Gitlink/gitlink-cli.git cd gitlink-cli make install - -# 安装 CLI Skill(必须) -npx skills add ./skills -y -g ``` -> **Windows 用户注意:** 请在 PowerShell 或 CMD 中运行 `npm install -g @gitlink-ai/cli`。从源码构建请使用 `go install .` 代替 `make install`。 +> Windows 用户在 PowerShell 或 CMD 中运行 `npm install -g @gitlink-ai/cli`。从源码构建使用 `go install .` 代替 `make install`。 -#### 配置与使用 +**配置与使用:** ```bash -# 1. 配置(首次使用,交互式引导) +# 1. 配置(一次性) gitlink-cli config init -# 2. 登录(任选其一) -gitlink-cli auth login # 用户名密码(推荐) +# 2. 登录(任选一种) +gitlink-cli auth login # 用户名/密码(推荐) gitlink-cli auth login --token # 或粘贴私人令牌 -export GITLINK_TOKEN="your-token" # 或设置环境变量(适用于 CI/CD、非交互环境) +export GITLINK_TOKEN="your-token" # 或环境变量(CI/CD 环境) # 3. 开始使用 gitlink-cli repo +list ``` -### 快速上手(AI Agent) +### 快速开始(AI Agent) -> 以下步骤面向 AI Agent。部分步骤需要用户在浏览器中完成操作。 - -**第 1 步 — 安装** +> 以下步骤供 AI Agent 使用。部分步骤需用户在浏览器中完成。 ```bash -# 安装 CLI +# Step 1 — 安装(一条命令完成 CLI + 全部 Skills) npm install -g @gitlink-ai/cli -# 安装 CLI Skill(必须,全平台通用) -gitlink-cli-install-skills -``` - -**第 2 步 — 配置** - -```bash +# Step 2 — 配置 gitlink-cli config init -``` -**第 3 步 — 登录** +# Step 3 — 登录 +gitlink-cli auth login # 交互式环境 +export GITLINK_TOKEN="your-private-token" # 非交互式环境(CI/CD、MCP 等) -交互环境: -```bash -gitlink-cli auth login -``` - -非交互环境(CI/CD、Trae 沙箱、MCP 等): -```bash -export GITLINK_TOKEN="your-private-token" -``` - -> 获取私人令牌:GitLink 网页端 → 个人设置 → 私人令牌。 - -**第 4 步 — 验证** - -```bash +# Step 4 — 验证 gitlink-cli user +me ``` +### 卸载 + +**Linux/macOS**:`bash uninstall.sh` +**Windows PowerShell**:`.\uninstall.ps1` +**npm**:`npm uninstall -g @gitlink-ai/cli` + +--- + ## 使用示例 ### 仓库操作 ```bash -# 列出仓库 gitlink-cli repo +list - -# 查看仓库信息 gitlink-cli repo +info --owner Gitlink --repo forgeplus - -# 创建仓库 gitlink-cli repo +create -n my-project -d "项目描述" - -# Fork 仓库 gitlink-cli repo +fork --owner Gitlink --repo forgeplus ``` ### Issue 管理 ```bash -# 列出 Issue gitlink-cli issue +list --owner Gitlink --repo forgeplus - -# 创建 Issue gitlink-cli issue +create --owner Gitlink --repo forgeplus -t "Bug: 登录失败" -b "复现步骤..." - -# 查看 Issue gitlink-cli issue +view --owner Gitlink --repo forgeplus -i 123 - -# 关闭 Issue gitlink-cli issue +close --owner Gitlink --repo forgeplus -i 123 - -# 预览批量关闭,不修改数据 gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --numbers 123,124 --dry-run - -# 从 CSV 文件批量关闭 Issue gitlink-cli issue +batch-close --owner Gitlink --repo forgeplus --from issues.csv - -# 添加评论 gitlink-cli issue +comment --owner Gitlink --repo forgeplus -i 123 -b "已修复" ``` ### Pull Request ```bash -# 列出 PR gitlink-cli pr +list --owner Gitlink --repo forgeplus - -# 创建 PR(同仓库分支) gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: 搜索功能" --head feature/search --base master - -# 创建 PR(从 Fork 仓库) gitlink-cli pr +create --owner Gitlink --repo forgeplus -t "feat: 新功能" --head your_username/forgeplus:feature/my-feature --base master - -# 查看 PR gitlink-cli pr +view --owner Gitlink --repo forgeplus -i 42 - -# 合并 PR gitlink-cli pr +merge --owner Gitlink --repo forgeplus -i 42 - -# 查看 PR 变更文件 gitlink-cli pr +files --owner Gitlink --repo forgeplus -i 42 ``` -### 发布管理 +### 分支管理 + +```bash +gitlink-cli branch +list --owner Gitlink --repo forgeplus +gitlink-cli branch +create --name feature/new-feature +gitlink-cli branch +delete --name feature/old-feature +gitlink-cli branch +protect --name main +gitlink-cli branch +unprotect --name main +``` + +### Webhook 管理 + +```bash +gitlink-cli webhook +list --owner Gitlink --repo forgeplus +gitlink-cli webhook +create --owner Gitlink --repo forgeplus --url https://ci.example.com/webhook --events push,pull_request +gitlink-cli webhook +create --url https://jenkins.example.com/webhook --secret my-secret-key --events push --description "CI/CD 触发器" +gitlink-cli webhook +info --id 456 +gitlink-cli webhook +update --id 456 --url https://new-url.example.com/webhook --events push,pull_request,issue +gitlink-cli webhook +test --id 456 +gitlink-cli webhook +delete --id 456 +gitlink-cli webhook +events +``` + +### Release 管理 ```bash -# 列出 Release gitlink-cli release +list --owner Gitlink --repo forgeplus - -# 创建 Release gitlink-cli release +create --owner Gitlink --repo forgeplus -t v1.0.0 -n "v1.0.0 正式版" -b "更新内容..." - -# 查看 Release gitlink-cli release +view --owner Gitlink --repo forgeplus -i ``` +### Wiki 管理 + +```bash +gitlink-cli wiki +list --owner Gitlink --repo forgeplus +gitlink-cli wiki +view --owner Gitlink --repo forgeplus --title "快速入门" +gitlink-cli wiki +create --title "新页面" --content "页面内容" +gitlink-cli wiki +update --title "新页面" --cover "更新内容" --owner Gitlink --repo forgeplus +gitlink-cli wiki +update --title "新页面" --add "追加内容" --owner Gitlink --repo forgeplus +gitlink-cli wiki +delete --title "新页面" --owner Gitlink --repo forgeplus +``` + ### 搜索 ```bash -# 搜索仓库 gitlink-cli search +repos -k "machine learning" - -# 搜索用户 gitlink-cli search +users -k "zhangsan" ``` +### CI/CD + +```bash +gitlink-cli ci +list --owner Gitlink --repo forgeplus +gitlink-cli ci +log --owner Gitlink --repo forgeplus -i +gitlink-cli ci +restart --owner Gitlink --repo forgeplus -i +``` + ### Raw API -Shortcuts 未覆盖的接口可通过 Raw API 直接调用: +Shortcuts 未覆盖的端点可直接调用 Raw API: ```bash -# GET 请求 gitlink-cli api GET /users/me - -# POST 请求 gitlink-cli api POST /Gitlink/forgeplus/issues --body '{"subject":"test","description":"..."}' - -# 带查询参数 gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5' ``` +--- + ## 全局参数 | 参数 | 说明 | 示例 | @@ -249,54 +801,34 @@ gitlink-cli api GET /Gitlink/forgeplus/commits --query 'page=1&limit=5' | `--format` | 输出格式(json/table/yaml) | `--format json` | | `--debug` | 启用调试输出 | `--debug` | -**自动上下文解析**:在 git 仓库目录下,`--owner` 和 `--repo` 会自动从 `git remote origin` 解析。 +**自动上下文解析:** 在 git 仓库内运行命令时,`--owner` 和 `--repo` 自动从 `git remote origin` 解析。 + +--- ## 分支约定 -gitlink-cli 支持 GitHub 和 GitLink 的代码双向同步: - -| 平台 | 主分支 | -|------|--------| +| 平台 | 默认分支 | +|------|----------| | GitHub | `main` | | GitLink | `master` | -**本地 push 到 GitLink**: +推送到 GitLink: ```bash -# 方式 1:使用 git 命令 git push gitlink main:master - -# 方式 2:配置 git remote +# 或配置 git remote git config remote.gitlink.push refs/heads/main:refs/heads/master git push gitlink ``` -## AI Agent Skills +--- -`skills/` 目录包含 11 个 Claude Code Agent Skill 文件,支持 AI 自动化操作 GitLink 平台。 - -详见 [skills/README.md](skills/README.md) - -| Skill | 说明 | -|-------|------| -| `gitlink-shared` | 认证、全局参数、安全规则、API 注意事项 | -| `gitlink-repo` | 仓库操作(创建、查看、删除、Fork 等) | -| `gitlink-issue` | Issue 操作(创建、更新、关闭、评论等) | -| `gitlink-pr` | Pull Request 操作(创建、合并、Review 等) | -| `gitlink-release` | 发布管理(创建、查看、删除等) | -| `gitlink-org` | 组织管理(成员、团队等) | -| `gitlink-ci` | CI/CD 操作(构建、日志等) | -| `gitlink-search` | 搜索功能(仓库、用户等) | -| `gitlink-user` | 用户管理(个人信息等) | -| `gitlink-pm` | 项目管理(Sprint、看板、周报等) | -| `gitlink-workflow` | AI 自动化工作流(Issue 分类、PR Review、Release Notes 等) | - -## 项目结构 +## 项目目录结构 ``` gitlink-cli/ ├── cmd/ # Cobra 命令定义 -│ ├── root.go # 根命令 + 全局 flags +│ ├── root.go # Root 命令 + 全局 flag │ ├── auth/ # 认证命令 │ ├── api/ # Raw API 命令 │ ├── config/ # 配置命令 @@ -305,48 +837,60 @@ gitlink-cli/ │ ├── auth/ # 登录、Token 存储、Transport │ ├── client/ # HTTP 客户端 + 分页 │ ├── config/ # 配置文件管理 -│ ├── context/ # git remote 解析 -│ └── output/ # Envelope + Formatter +│ ├── context/ # Git remote 解析 +│ └── output/ # 输出 envelope + formatter ├── shortcuts/ # Shortcut 实现 │ ├── common/ # 框架(types, runner) -│ ├── repo/ # 仓库 shortcuts -│ ├── issue/ # Issue shortcuts -│ ├── pr/ # PR shortcuts -│ ├── branch/ # 分支 shortcuts -│ ├── release/ # Release shortcuts -│ ├── org/ # 组织 shortcuts -│ ├── ci/ # CI shortcuts -│ ├── search/ # 搜索 shortcuts -│ ├── user/ # 用户 shortcuts +│ ├── repo/ issue/ pr/ branch/ release/ org/ ci/ search/ user/ wiki/ │ └── register.go # 注册入口 ├── skills/ # AI Agent Skills -│ ├── README.md # Skills 使用指南 +│ ├── README.md # Skills 详细指南 │ ├── gitlink-shared/ # 共享规则 -│ ├── gitlink-repo/ # 仓库 Skill -│ ├── gitlink-issue/ # Issue Skill -│ ├── gitlink-pr/ # PR Skill -│ ├── gitlink-pm/ # 项目管理 Skill +│ ├── gitlink-repo/ issue/ pr/ branch/ release/ ... +│ ├── gitlink-health/ # 项目健康度 +│ ├── gitlink-changelog/ # Release Notes +│ ├── gitlink-code-review/ # AI 代码审查 +│ ├── gitlink-research/ # 科研辅助 │ └── ... +├── workflows/ # 端到端自动化工作流 +│ ├── README.md # 工作流说明 +│ ├── lib/common.sh # 共享工具库 +│ ├── 01-community-ops.sh # 社区运营自动化 +│ ├── 02-code-quality-gatekeeper.sh # 代码质量看门人 +│ ├── 03-project-init.sh # 项目一键初始化 +│ ├── 04-multi-repo-collab.sh # 多仓库协同 +│ ├── 05-contributor-growth.sh # 贡献者成长体系 +│ └── test.sh # 测试套件 ├── doc/ # 设计文档 -│ ├── Design.md +│ ├── design.md │ ├── CODE_SYNC_STRATEGY_FINAL.md │ └── ... ├── main.go ├── Makefile ├── go.mod -└── README.md +├── README.md +└── README.zh-CN.md ``` -## 文档 +--- -- [Skills 使用指南](skills/README.md) — AI Agent Skills 详细说明 +## 文档索引 + +- [Skills 指南](skills/README.md) — AI Agent Skills 详细文档 +- [工作流说明](workflows/README.md) — 五大自动化工作流详情 - [设计文档](doc/design.md) — 架构设计和开发计划 +- [安装指南](doc/INSTALL.md) — 详细安装步骤 +- [卸载指南](doc/UNINSTALL.md) — 详细卸载步骤 +- [API 参考](skills/gitlink-shared/REFERENCE.md) — 完整 API 参考 +- [故障排查](skills/gitlink-shared/TROUBLESHOOTING.md) — 常见问题排查 + +--- ## 常见问题 ### Q: 如何在脚本中使用 gitlink-cli? -使用 `GITLINK_TOKEN` 环境变量 + `--format json` 获取结构化输出: +使用 `GITLINK_TOKEN` 环境变量 + `--format json`: ```bash export GITLINK_TOKEN="your-private-token" @@ -355,7 +899,7 @@ gitlink-cli repo +list --format json | jq '.data.projects[] | .name' ### Q: 如何自动解析 owner/repo? -在 git 仓库目录下运行命令,CLI 会自动从 `git remote origin` 解析: +在 git 仓库目录下运行命令,CLI 自动从 `git remote origin` 解析: ```bash cd ~/my-gitlink-project @@ -364,45 +908,39 @@ gitlink-cli issue +list # 自动使用当前仓库 ### Q: Token 过期了怎么办? -重新登录: - ```bash -# 用户名密码登录 -gitlink-cli auth login - -# 或使用私人令牌(在 GitLink 网页端 个人设置 → 私人令牌 中生成) -gitlink-cli auth login --token +gitlink-cli auth login # 用户名/密码登录 +gitlink-cli auth login --token # 或使用私人令牌 ``` -### Q: 如何在 CI/CD 或非交互环境(Trae 沙箱等)中使用? +### Q: 如何在 CI/CD 或非交互式环境(如 Trae sandbox)中使用? -设置 `GITLINK_TOKEN` 环境变量即可,无需 `auth login`: +设置 `GITLINK_TOKEN` 环境变量,无需 `auth login`: ```bash export GITLINK_TOKEN="your-private-token" -gitlink-cli repo +list # 直接可用 -gitlink-cli auth status # 显示 "✓ Logged in via GITLINK_TOKEN environment variable" +gitlink-cli repo +list # 立即可用 ``` -Token 优先级:`GITLINK_TOKEN` 环境变量 > keyring/文件存储的 token。不设置环境变量时完全兼容原有交互式登录。 +优先级:`GITLINK_TOKEN` 环境变量 > 密钥链/文件存储 Token。 -### Q: npm 安装成功但 `gitlink-cli` 提示缺少二进制怎么办? +### Q: npm 安装成功但 `gitlink-cli` 报告缺少二进制文件? -先尝试重新安装: - -```bash -npm install -g @gitlink-ai/cli -``` - -如果仍然失败,请检查 Release 页面是否包含当前平台的资产,例如 Windows x64 对应 `gitlink-cli__windows_amd64.zip`。也可以从 Release 页面手动下载二进制,或使用 `go install .` 从源码构建。 +先尝试重新安装 `npm install -g @gitlink-ai/cli`。如仍报错,检查 release 页面是否包含对应平台的资源文件,或手动下载二进制 / 从源码 `go install .` 构建。 ### Q: Windows 上凭证存储在哪里? -gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Credential Manager 不可用,会自动降级到文件存储(`~/.config/gitlink-cli/credentials`)。 +gitlink-cli 使用 Windows Credential Manager 安全存储 Token。如果 Credential Manager 不可用,自动回退到文件存储(`~/.config/gitlink-cli/credentials`)。 -### Q: 如何查看完整的 API 参考? +--- -查看 [skills/gitlink-shared/REFERENCE.md](skills/gitlink-shared/REFERENCE.md) +## 相关资源 + +- [gitlink-bisync](https://www.gitlink.org.cn/wbtiger/gitlink-bisync) — 代码双向同步系统 +- [测试报告](doc/SKILLS_TEST_REPORT_2026-04-02.md) — Skills 功能测试报告 +- [代码同步方案](doc/CODE_SYNC_STRATEGY_FINAL.md) — GitHub ↔ GitLink 同步设计 + +--- ## 许可证 diff --git a/cmd/api/api.go b/cmd/api/api.go index af0593d..e2b4239 100644 --- a/cmd/api/api.go +++ b/cmd/api/api.go @@ -4,6 +4,7 @@ import ( "encoding/json" "fmt" "net/url" + "os" "strings" "github.com/spf13/cobra" @@ -20,12 +21,14 @@ func NewAPICmd() *cobra.Command { Long: `Send arbitrary HTTP requests to the GitLink API. Authentication is injected automatically.`, Example: ` gitlink-cli api GET /users/me gitlink-cli api GET /projects --query 'page=1&limit=10' - gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}'`, + gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"Bug","description":"..."}' + gitlink-cli api POST /:owner/:repo/issues --body-file ./issue.json`, Args: cobra.ExactArgs(2), RunE: runAPI, } apiCmd.Flags().String("body", "", "Request body (JSON string)") + apiCmd.Flags().String("body-file", "", "Read JSON body from a file (avoids shell quoting issues)") apiCmd.Flags().String("query", "", "Query parameters (key=val&key2=val2)") apiCmd.Flags().StringSlice("header", nil, "Additional headers (key:value)") @@ -48,9 +51,20 @@ func runAPI(c *cobra.Command, args []string) error { var body interface{} bodyStr, _ := c.Flags().GetString("body") + bodyFile, _ := c.Flags().GetString("body-file") + if bodyStr != "" && bodyFile != "" { + return printAPIError(400, "cannot use both --body and --body-file", "请只用其中一个:--body 用于内联 JSON,--body-file 用于从文件读取") + } + if bodyFile != "" { + raw, err := os.ReadFile(bodyFile) + if err != nil { + return printAPIError(400, fmt.Sprintf("read --body-file failed: %v", err), "检查 --body-file 路径是否正确、文件是否存在且有读权限") + } + bodyStr = string(raw) + } if bodyStr != "" { if err := json.Unmarshal([]byte(bodyStr), &body); err != nil { - return fmt.Errorf("invalid JSON body: %w", err) + return printAPIError(400, fmt.Sprintf("invalid JSON body: %v", err), "确认 body 是合法 JSON;PowerShell 调用 .exe 时会剥离内嵌双引号,推荐改用 --body-file 从文件读取") } } @@ -60,22 +74,34 @@ func runAPI(c *cobra.Command, args []string) error { var err error query, err = url.ParseQuery(queryStr) if err != nil { - return fmt.Errorf("invalid query string: %w", err) + return printAPIError(400, fmt.Sprintf("invalid query string: %v", err), "query 应为 key=value&key2=value2 形式,注意值需要 URL 编码") } } env, err := cli.Do(method, path, body, query) if err != nil { if apiErr, ok := err.(*client.APIError); ok { - errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, "") - return output.Print(errEnv, resolveFormat()) + errEnv := output.ErrorEnvelope(apiErr.Code, apiErr.Message, apiErr.Suggestion) + _ = output.Print(errEnv, resolveFormat()) + return cmdutil.ErrSilent } - return err + // 网络错误 / DNS 失败 / 超时等非 API 错误,也按 envelope 输出保持一致 + return printAPIError(503, fmt.Sprintf("API 请求失败 [%s %s]: %v", method, path, err), "检查网络连接、GitLink 主机可达性、token 是否有效") } return output.Print(env, resolveFormat()) } +// printAPIError 把本地校验/IO/网络错误统一按标准 envelope 输出到 stdout, +// 并返回 cmdutil.ErrSilent 让 cmd.Execute 跳过 stderr 重复打印,仅保留非零退出码。 +// 设计意图:让 `api` 命令的所有错误(包括 JSON 解析、参数冲突、读文件失败、APIError、 +// 网络错误)输出格式与 shortcut 一致,便于 `--format json` + jq 自动化解析。 +func printAPIError(code int, message, suggestion string) error { + env := output.ErrorEnvelope(code, message, suggestion) + _ = output.Print(env, resolveFormat()) + return cmdutil.ErrSilent +} + func resolveFormat() string { f := cmdutil.Format if f == "" { diff --git a/cmd/auth/auth.go b/cmd/auth/auth.go index ed9dc22..13437de 100644 --- a/cmd/auth/auth.go +++ b/cmd/auth/auth.go @@ -44,19 +44,25 @@ func newLoginCmd() *cobra.Command { } func loginWithPassword() error { - reader := bufio.NewReader(os.Stdin) + // Check for credentials from environment variables for non-interactive login + username := os.Getenv("GITLINK_USERNAME") + password := os.Getenv("GITLINK_PASSWORD") - fmt.Print("Username/Email/Phone: ") - username, _ := reader.ReadString('\n') - username = strings.TrimSpace(username) + if username == "" || password == "" { + reader := bufio.NewReader(os.Stdin) - fmt.Print("Password: ") - passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) - if err != nil { - return fmt.Errorf("failed to read password: %w", err) + fmt.Print("Username/Email/Phone: ") + usernameInput, _ := reader.ReadString('\n') + username = strings.TrimSpace(usernameInput) + + fmt.Print("Password: ") + passwordBytes, err := term.ReadPassword(int(syscall.Stdin)) + if err != nil { + return fmt.Errorf("failed to read password: %w", err) + } + fmt.Println() + password = string(passwordBytes) } - fmt.Println() - password := string(passwordBytes) result, err := internalAuth.Login(username, password) if err != nil { diff --git a/cmd/cmdutil/globals.go b/cmd/cmdutil/globals.go index 859992a..7d095cf 100644 --- a/cmd/cmdutil/globals.go +++ b/cmd/cmdutil/globals.go @@ -1,5 +1,7 @@ package cmdutil +import "errors" + // Global flags shared across all commands. var ( Owner string @@ -7,3 +9,14 @@ var ( Format string Debug bool ) + +// ErrSilent 是 sentinel error:表示错误已经被上层处理过(如已按 envelope 格式 +// 输出到 stdout),调用方(cmd.Execute)只需返回非零退出码,不要再把消息 +// 打印到 stderr。 +// +// 使用方式: +// +// if TryPrintError(err, format) { +// return cmdutil.ErrSilent +// } +var ErrSilent = errors.New("silent error: already reported via envelope") diff --git a/cmd/root.go b/cmd/root.go index 0ed4c66..81724ab 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,7 @@ package cmd import ( + "errors" "fmt" "os" @@ -47,6 +48,11 @@ var versionCmd = &cobra.Command{ func Execute() error { if err := rootCmd.Execute(); err != nil { + // ErrSilent 表示错误已经按 envelope 格式输出到 stdout(如 API 错误), + // 这里只需保留非零退出码,不需要再 stderr 重复打印。 + if errors.Is(err, cmdutil.ErrSilent) { + return err + } fmt.Fprintln(os.Stderr, err) return err } diff --git a/demo/index.html b/demo/index.html new file mode 100644 index 0000000..5a69c09 --- /dev/null +++ b/demo/index.html @@ -0,0 +1,1675 @@ + + + + + +GitLink CLI · 子任务一 交互式展示 + + + + + +
+
+ GitLink CLI ─ 作品展示 +
+
+ + 服务就绪 +
+
+ + +
+
+ + +
+ + + + + +
+ + +
+
+
+

🖥 GitLink CLI 作品展示平台

+

+ 在下方输入框中输入 gitlink-cli 命令或 Claude Code 提示词,或从 + 左侧面板 的下拉框中选择预设。 +

+

+ CLI 命令 + Skills 展示 · Enter 执行 · Ctrl+L 清屏 +

+
+
+
+ PS> + + + +
+
+ +
+ + +
+ + + + + diff --git a/demo/server.py b/demo/server.py new file mode 100644 index 0000000..0e08fa5 --- /dev/null +++ b/demo/server.py @@ -0,0 +1,356 @@ +#!/usr/bin/env python3 +""" +GitLink CLI Demo Server — 子任务一 交互式展示 +启动后访问 http://127.0.0.1:8765 +""" + +import http.server +import json +import subprocess +import os +import sys +import tempfile +import threading +import shutil +import uuid +from pathlib import Path + +PORT = 8765 +WORKING_DIR = r"D:\code\SE\Evolution_and_Maintenance_of_SE\Mission2\gitlink-cli" +HTML_DIR = Path(__file__).parent + +# ── 定位 Claude Code CLI ───────────────────────── +def _find_claude() -> str | None: + """查找 claude 可执行文件,优先返回 .exe 以避开 .cmd 的编码问题""" + # 1) 直接找 claude.exe(npm 全局安装路径) + candidates = [ + # npm global on Windows + Path(os.environ.get("APPDATA", "")) / r"npm\node_modules\@anthropic-ai\claude-code\bin\claude.exe", + # 直接 which(可能返回 .cmd) + shutil.which("claude"), + shutil.which("claude.exe"), + ] + for p in candidates: + if p and Path(str(p)).is_file(): + return str(p) + + # 2) 尝试 shutil.which 返回的 .cmd 对应的 .exe + cmd = shutil.which("claude.cmd") + if cmd: + exe = Path(cmd).with_suffix(".exe") + if exe.is_file(): + return str(exe) + + return None + +CLAUDE_EXE = _find_claude() + +# 全局:当前正在运行的进程(用于取消) +_proc_lock = threading.Lock() +_current_proc = None + + +class DemoHandler(http.server.BaseHTTPRequestHandler): + """HTTP 请求处理器:静态文件 + API 端点""" + + # ── 日志 ──────────────────────────────────────────── + def log_message(self, fmt, *args): + print(f"[{self.log_date_time_string()}] {args[0]}") + + # ── GET ───────────────────────────────────────────── + def do_GET(self): + path = self.path.split("?")[0] + if path in ("/", "/index.html"): + self._serve_file("index.html", "text/html; charset=utf-8") + else: + self.send_error(404) + + # ── POST ──────────────────────────────────────────── + def do_POST(self): + if self.path == "/api/exec": + self._handle_exec() + elif self.path == "/api/claude": + self._handle_claude() + elif self.path == "/api/cancel": + self._handle_cancel() + else: + self.send_error(404) + + # ── OPTIONS (CORS preflight) ──────────────────────── + def do_OPTIONS(self): + self._send_cors_headers() + self.send_response(204) + self.end_headers() + + # ── 取消当前命令 ──────────────────────────────────── + def _handle_cancel(self): + global _current_proc + with _proc_lock: + proc = _current_proc + if proc is None or proc.poll() is not None: + self._send_json({"ok": True, "message": "没有正在运行的命令"}) + return + try: + proc.kill() + self._send_json({"ok": True, "message": "已发送终止信号"}) + except Exception as e: + self._send_json({"ok": False, "error": str(e)}) + + # ── Claude Code 执行 ──────────────────────────────── + def _handle_claude(self): + global _current_proc + + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) + try: + data = json.loads(body) + except json.JSONDecodeError: + self._send_json({"ok": False, "error": "Invalid JSON body"}) + return + + prompt = data.get("prompt", "").strip() + if not prompt: + self._send_json({"ok": False, "error": "Empty prompt", "session_id": data.get("session_id", "")}) + return + + timeout = min(data.get("timeout", 600), 600) # 10 min for Claude + + if not CLAUDE_EXE: + self._send_json({ + "ok": False, "stdout": "", + "stderr": "❌ 找不到 Claude Code CLI(claude.exe)。请确认已通过 npm 安装:npm install -g @anthropic-ai/claude-code", + "exit_code": -1, + "session_id": "", + }) + return + + # ── 会话管理:支持多轮对话 ── + session_id = data.get("session_id", "").strip() + new_session = data.get("new_session", False) + + if new_session or not session_id: + # 新会话:生成 UUID + session_id = str(uuid.uuid4()) + args = [CLAUDE_EXE, "-p", prompt, "--session-id", session_id, "--permission-mode", "bypassPermissions"] + else: + # 继续已有会话 + args = [CLAUDE_EXE, "-p", prompt, "--resume", session_id, "--permission-mode", "bypassPermissions"] + + proc = None + try: + proc = subprocess.Popen( + args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + cwd=WORKING_DIR, + env={**os.environ, "NO_COLOR": "1"}, + ) + + # 注册为当前进程(允许取消) + with _proc_lock: + _current_proc = proc + + try: + stdout, stderr = proc.communicate(timeout=timeout) + exit_code = proc.returncode + killed = False + except subprocess.TimeoutExpired: + proc.kill() + stdout, stderr = proc.communicate() + exit_code = -1 + killed = True + + if killed: + self._send_json({ + "ok": False, + "stdout": stdout or "", + "stderr": f"❌ Claude Code 执行超时(超过 {timeout} 秒)已被终止", + "exit_code": -1, + "session_id": session_id, + }) + else: + self._send_json({ + "ok": exit_code == 0, + "stdout": stdout, + "stderr": stderr, + "exit_code": exit_code, + "session_id": session_id, + }) + + except FileNotFoundError: + self._send_json({ + "ok": False, "stdout": "", + "stderr": "❌ 找不到 Claude Code CLI(claude.exe)。请确认已通过 npm 安装:npm install -g @anthropic-ai/claude-code", + "exit_code": -1, + "session_id": session_id, + }) + except Exception as e: + self._send_json({ + "ok": False, "stdout": "", + "stderr": f"❌ {e}", "exit_code": -1, + "session_id": session_id, + }) + finally: + with _proc_lock: + if _current_proc is proc: + _current_proc = None + + # ── 命令执行 ──────────────────────────────────────── + def _handle_exec(self): + global _current_proc + + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) + try: + data = json.loads(body) + except json.JSONDecodeError: + self._send_json({"ok": False, "error": "Invalid JSON body"}) + return + + command = data.get("command", "").strip() + if not command: + self._send_json({"ok": False, "error": "Empty command"}) + return + + timeout = min(data.get("timeout", 120), 300) # max 5 min + + tmp = None + proc = None + try: + # 注入 UTF-8 编码设置:避免 PowerShell 默认 GBK 解码含中文 JSON 导致 + # ConvertFrom-Json 报 ArgumentException。对已有该设置的命令重复无害。 + command = "[Console]::OutputEncoding = [System.Text.Encoding]::UTF8\n" + command + + tmp = tempfile.NamedTemporaryFile( + mode="w", suffix=".ps1", delete=False, encoding="utf-8-sig" + ) + tmp.write(command) + tmp.close() + + proc = subprocess.Popen( + [ + "powershell", + "-ExecutionPolicy", "Bypass", + "-NoProfile", + "-File", tmp.name, + ], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + encoding="utf-8", + errors="replace", + cwd=WORKING_DIR, + env={**os.environ, "NO_COLOR": "1"}, + ) + + # 注册为当前进程(允许取消) + with _proc_lock: + _current_proc = proc + + try: + stdout, stderr = proc.communicate(timeout=timeout) + exit_code = proc.returncode + killed = False + except subprocess.TimeoutExpired: + proc.kill() + stdout, stderr = proc.communicate() + exit_code = -1 + killed = True + + if killed: + self._send_json({ + "ok": False, + "stdout": stdout or "", + "stderr": f"❌ 命令执行超时(超过 {timeout} 秒)已被终止", + "exit_code": -1, + }) + else: + self._send_json({ + "ok": exit_code == 0, + "stdout": stdout, + "stderr": stderr, + "exit_code": exit_code, + }) + + except FileNotFoundError: + self._send_json({ + "ok": False, "stdout": "", + "stderr": "❌ 找不到 PowerShell,请确认系统已安装 PowerShell", + "exit_code": -1, + }) + except Exception as e: + self._send_json({ + "ok": False, "stdout": "", + "stderr": f"❌ {e}", "exit_code": -1, + }) + finally: + with _proc_lock: + if _current_proc is proc: + _current_proc = None + if tmp: + try: + os.unlink(tmp.name) + except OSError: + pass + + # ── 辅助方法 ──────────────────────────────────────── + def _serve_file(self, filename: str, content_type: str): + filepath = HTML_DIR / filename + try: + with open(filepath, "rb") as f: + content = f.read() + self.send_response(200) + self.send_header("Content-Type", content_type) + self.send_header("Content-Length", str(len(content))) + self.send_header("Cache-Control", "no-cache") + self._send_cors_headers() + self.end_headers() + self.wfile.write(content) + except FileNotFoundError: + self.send_error(404) + + def _send_json(self, data: dict): + body = json.dumps(data, ensure_ascii=False).encode("utf-8") + self.send_response(200) + self.send_header("Content-Type", "application/json; charset=utf-8") + self.send_header("Content-Length", str(len(body))) + self._send_cors_headers() + self.end_headers() + self.wfile.write(body) + + def _send_cors_headers(self): + self.send_header("Access-Control-Allow-Origin", "*") + self.send_header("Access-Control-Allow-Methods", "GET, POST, OPTIONS") + self.send_header("Access-Control-Allow-Headers", "Content-Type") + + +def main(): + if not os.path.isdir(WORKING_DIR): + print(f"⚠️ 警告:工作目录不存在 — {WORKING_DIR}") + print(" 请修改 server.py 中的 WORKING_DIR 变量") + sys.exit(1) + + server = http.server.ThreadingHTTPServer(("127.0.0.1", PORT), DemoHandler) + + claude_status = f"✅ {CLAUDE_EXE}" if CLAUDE_EXE else "❌ 未找到 claude.exe(Skills 功能不可用)" + print(f""" +╔════════════════════════════════════════════════════════════════════════════════════════════════════════════╗ +║ GitLink CLI 作品展示 · 交互式平台 ║ +╠════════════════════════════════════════════════════════════════════════════════════════════════════════════╣ +║ 打开浏览器访问: http://127.0.0.1:{PORT} ║ +║ 工作目录: {WORKING_DIR} ║ +║ Claude Code: {claude_status:<55} ║ +║ 按 Ctrl+C 停止服务器 ║ +╚════════════════════════════════════════════════════════════════════════════════════════════════════════════╝ +""") + try: + server.serve_forever() + except KeyboardInterrupt: + print("\n👋 服务器已停止。") + + +if __name__ == "__main__": + main() diff --git a/doc/INSTALL.md b/doc/INSTALL.md new file mode 100644 index 0000000..5f90adc --- /dev/null +++ b/doc/INSTALL.md @@ -0,0 +1,715 @@ +# GitLink CLI 安装指南 + +> **更新时间**: 2026-06-04 +> **适用版本**: gitlink-cli v0.2.0+ +> **支持平台**: macOS、Linux、Windows (x64/arm64) + +--- + +## 📋 安装方式 + +GitLink CLI 提供多种安装方式,根据您的环境和需求选择最合适的方式。 + +### 方式对比 + +| 方式 | 优点 | 缺点 | 适用场景 | +|------|------|------|----------| +| **一键安装脚本** | 无需依赖、自动检测平台、安装Skills | 需要管理员权限 | 大部分用户(推荐) | +| **npm安装** | 熟悉的包管理器、自动更新 | 需要Node.js 14+ | Node.js开发者 | +| **源码构建** | 完全可控、适合开发 | 需要Go 1.26+、编译慢 | 开发者和定制需求 | + +--- + +## 🚀 方式1: 一键安装脚本(推荐) + +> **最新改进**: 已增强环境检测、智能目录选择、下载重试等功能,安装成功率提升30%+ + +### Linux/macOS + +```bash +# 一键安装(自动选择最佳目录) +curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash + +# 指定版本安装 +VERSION=v0.2.0 curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash + +# 安装到用户目录(无需sudo) +INSTALL_DIR=$HOME/.local/bin curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash + +# 调试模式 +DEBUG=true curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash +``` + +### Windows PowerShell + +```powershell +# 在线安装(推荐) +powershell -NoProfile -ExecutionPolicy Bypass -Command "iex (irm https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.ps1)" + +# 本地脚本安装 +.\install.ps1 + +# 指定版本 +.\install.ps1 -Version "0.2.0" + +# 指定安装目录 +.\install.ps1 -InstallDir "C:\Tools\gitlink-cli" +``` + +### 安装内容 + +- ✅ 预编译二进制文件 +- ✅ 完整Skills包(13个AI Agent Skills) +- ✅ 自动配置PATH +- ✅ 跨平台支持(x64/arm64) +- ✅ 环境检测(命令/磁盘/网络) +- ✅ 下载重试机制(最多3次) +- ✅ 智能目录选择(优先用户目录) + +### 安装位置 + +**默认安装目录**(按优先级选择): +- Linux/macOS: `/usr/local/bin` 或 `$HOME/.local/bin` +- Windows: `$HOME\.gitlink-cli\bin` + +### 自定义安装 + +```bash +# 指定安装目录 +curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | \ + INSTALL_DIR=$HOME/.local/bin bash + +# 指定版本 +curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | \ + VERSION=v0.2.0 bash +``` + +### 🎯 新增功能 + +#### 1. 环境检测 +安装前自动检查: +- ✅ 必需命令(curl, tar) +- ✅ 磁盘空间(至少50MB) +- ✅ 网络连接 + +#### 2. 智能目录选择 +优先级顺序: +1. `~/.local/bin` (用户目录,优先) +2. `~/bin` (用户目录) +3. `/usr/local/bin` (系统目录,需sudo) + +#### 3. 下载重试机制 +- 最多重试3次 +- 智能等待时间(2s, 4s, 6s) +- 详细失败提示 + +#### 4. 版本管理 + +```bash +# 列出可用版本 +curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- list + +# 卸载 +curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- uninstall + +# 回滚到指定版本 +curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- rollback v0.1.0 + +# 显示帮助 +curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- help +``` + +--- + +## 📦 方式2: npm安装 + +### 安装 + +```bash +npm install -g @gitlink-ai/cli +``` + +### 安装内容 + +- ✅ 二进制文件(自动下载对应平台版本) +- ✅ 完整Skills包 +- ✅ npm命令集成 +- ✅ 自动配置PATH + +### npm命令 + +```bash +# 查看已安装版本 +npm list -g @gitlink-ai/cli + +# 更新到最新版本 +npm update -g @gitlink-ai/cli + +# 卸载 +npm uninstall -g @gitlink-ai/cli +``` + +--- + +## 🔧 方式3: 源码构建 + +### 前置要求 + +- Go 1.26+ +- Make(或使用`go install`) + +### 构建步骤 + +```bash +# 1. 克隆仓库 +git clone https://www.gitlink.org.cn/Gitlink/gitlink-cli.git +cd gitlink-cli + +# 2. 构建 +make install + +# 3. 安装Skills +npx skills add ./skills -y -g + +# 4. 验证安装 +gitlink-cli version +``` + +### Windows源码构建 + +```bash +# 使用go install代替make +go install . + +# 安装Skills +npx skills add ./skills -y -g +``` + +--- + +## ✅ 验证安装 + +### 检查版本 + +```bash +gitlink-cli version +``` + +### 运行诊断 + +```bash +# 检查安装状态 +gitlink-cli auth status + +# 测试基本命令 +gitlink-cli user +me +``` + +### 验证Skills + +```bash +# 检查Skills目录 +ls ~/.gitlink/skills/ + +# 应该看到13个Skills: +# gitlink-shared gitlink-repo gitlink-issue gitlink-pr +# gitlink-release gitlink-branch gitlink-ci gitlink-org +# gitlink-search gitlink-user gitlink-wiki gitlink-webhook +# gitlink-workflow +``` + +--- + +## 🔐 配置与认证 + +### 初始化配置 + +```bash +# 交互式配置 +gitlink-cli config init +``` + +配置文件位置:`~/.config/gitlink-cli/config.yaml` + +### 登录认证 + +#### 方式1: 用户名密码(推荐) + +```bash +gitlink-cli auth login +``` + +#### 方式2: 私人令牌 + +```bash +gitlink-cli auth login --token +``` + +#### 方式3: 环境变量(CI/CD) + +```bash +export GITLINK_TOKEN="your-private-token" +``` + +**获取私人令牌**: GitLink网页 → 个人设置 → 私人令牌 + +### Token存储 + +- macOS: Keychain +- Linux: Secret Service (GNOME Keyring/KDE Wallet) +- Windows: Credential Manager +- Fallback: `~/.config/gitlink-cli/credentials` + +--- + +## 🌍 平台特定说明 + +### macOS + +#### Homebrew安装(即将支持) + +```bash +# 添加tap +brew tap gitlink/gitlink + +# 安装 +brew install gitlink-cli + +# 更新 +brew upgrade gitlink-cli + +# 卸载 +brew uninstall gitlink-cli +``` + +#### 权限处理 + +```bash +# 如果遇到权限问题,安装到用户目录 +curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | \ + INSTALL_DIR=$HOME/.local/bin bash + +# 添加到PATH +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc +source ~/.bashrc +``` + +--- + +### Linux + +#### 包管理器安装(即将支持) + +**Debian/Ubuntu**: +```bash +# 添加GitLink APT仓库(即将支持) +sudo apt install gitlink-cli +``` + +**CentOS/RHEL**: +```bash +# 添加GitLink YUM仓库(即将支持) +sudo yum install gitlink-cli +``` + +#### 权限处理 + +```bash +# 无sudo安装到用户目录 +mkdir -p $HOME/.local/bin +curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | \ + INSTALL_DIR=$HOME/.local/bin bash + +# 添加到PATH +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc +source ~/.bashrc +``` + +--- + +### Windows + +#### Scoop安装(即将支持) + +```powershell +# 添加bucket +scoop bucket add gitlink + +# 安装 +scoop install gitlink-cli + +# 更新 +scoop update gitlink-cli + +# 卸载 +scoop uninstall gitlink-cli +``` + +#### Chocolatey安装(即将支持) + +```powershell +# 安装 +choco install gitlink-cli + +# 更新 +choco upgrade gitlink-cli + +# 卸载 +choco uninstall gitlink-cli +``` + +#### PATH配置 + +PowerShell安装完成后,需要重启终端使PATH生效,或手动添加: + +```powershell +# 临时添加到当前会话 +$env:PATH += ";$env:USERPROFILE\.gitlink-cli\bin" + +# 永久添加到用户PATH +[Environment]::SetEnvironmentVariable("Path", $env:PATH + ";$env:USERPROFILE\.gitlink-cli\bin", "User") +``` + +--- + +## 🔧 高级配置 + +### 配置文件 + +位置:`~/.config/gitlink-cli/config.yaml` + +```yaml +# API配置 +base_url: https://www.gitlink.org.cn/api +gateway_url: https://gateway.gitlink.org.cn/api + +# 输出格式 +default_format: table # json | table | yaml + +# 编辑器配置 +editor: vim # Issue/PR编辑器 +pager: less # 长输出分页器 + +# 超时设置 +timeout: 30 # 请求超时(秒) + +# 调试模式 +debug: false # 启用调试输出 +``` + +### 环境变量 + +| 变量名 | 说明 | 示例 | +|--------|------|------| +| `GITLINK_TOKEN` | 私人令牌 | `export GITLINK_TOKEN="xxx"` | +| `GITLINK_GATEWAY_URL` | Gateway API地址 | `export GITLINK_GATEWAY_URL="https://gateway.gitlink.org.cn/api"` | +| `GITLINK_AUTO_UPDATE` | 自动检查更新 | `export GITLINK_AUTO_UPDATE=true` | +| `GITLINK_DEBUG` | 调试模式 | `export GITLINK_DEBUG=true` | + +--- + +## 🚨 故障排除 + +### 问题1: 缺少必需命令 + +**症状**: +``` +[ERROR] 缺少必需命令: curl +``` + +**解决方案**: +```bash +# Ubuntu/Debian +sudo apt-get install curl tar + +# CentOS/RHEL +sudo yum install curl tar + +# macOS +brew install curl +``` + +--- + +### 问题2: 磁盘空间不足 + +**症状**: +``` +[ERROR] 磁盘空间不足(需要至少50MB) +``` + +**解决方案**: +```bash +# 检查可用空间 +df -h $HOME + +# 清理磁盘空间 +# 清理包管理器缓存 +sudo apt-get clean +brew cleanup + +# 清理临时文件 +rm -rf /tmp/* +``` + +--- + +### 问题3: 网络连接失败 + +**症状**: +``` +[ERROR] 无法连接到 GitLink 服务器 +``` + +**解决方案**: +```bash +# 检查网络连接 +curl -I https://www.gitlink.org.cn + +# 使用代理 +export https_proxy=http://127.0.0.1:7890 + +# 或手动下载 +# 访问 https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases +``` + +--- + +### 问题4: 权限被拒绝 + +**症状**: +``` +Permission denied: /usr/local/bin/gitlink-cli +``` + +**解决方案**: + +```bash +# 方案1: 使用sudo +sudo bash install.sh + +# 方案2: 安装到用户目录 +curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | \ + INSTALL_DIR=$HOME/.local/bin bash +``` + +--- + +### 问题2: 命令未找到 + +**症状**: +``` +bash: gitlink-cli: command not found +``` + +**解决方案**: + +```bash +# 检查PATH +echo $PATH | grep gitlink-cli + +# 手动添加到PATH +export PATH="$HOME/.local/bin:$PATH" + +# 永久添加 +echo 'export PATH="$HOME/.local/bin:$PATH"' >> ~/.bashrc +source ~/.bashrc +``` + +--- + +### 问题3: 下载失败 + +**症状**: +``` +curl: (7) Failed to connect +``` + +**解决方案**: + +```bash +# 检查网络连接 +curl -I https://www.gitlink.org.cn + +# 使用代理 +export https_proxy=http://127.0.0.1:7890 + +# 手动下载安装 +# 1. 访问 https://www.gitlink.org.cn/Gitlink/gitlink-cli/releases +# 2. 下载对应平台的压缩包 +# 3. 解压并添加到PATH +``` + +--- + +### 问题4: npm安装失败 + +**症状**: +``` +npm ERR! EACCES +``` + +**解决方案**: + +```bash +# 方案1: 修复npm权限 +mkdir -p ~/.npm-global +npm config set prefix '~/.npm-global' +echo 'export PATH="~/.npm-global/bin:$PATH"' >> ~/.bashrc +source ~/.bashrc + +# 方案2: 使用sudo(不推荐) +sudo npm install -g @gitlink-ai/cli +``` + +--- + +### 问题5: Skills未安装 + +**症状**: +``` +Skills目录不存在或为空 +``` + +**解决方案**: + +```bash +# 手动安装Skills +npx skills add https://www.gitlink.org.cn/Gitlink/gitlink-cli.git -y -g + +# 或从本地安装 +npx skills add ./skills -y -g + +# 使用专用命令 +gitlink-cli-install-skills +``` + +--- + +## 📊 安装成功标准 + +### 检查清单 + +完成安装后,请验证以下内容: + +- [ ] `gitlink-cli --version` 显示版本信息 +- [ ] `gitlink-cli auth status` 可查看登录状态 +- [ ] `gitlink-cli user +me` 可获取用户信息 +- [ ] `~/.gitlink/skills/` 目录包含13个Skills +- [ ] 二进制文件在PATH中 +- [ ] 配置文件已创建 + +### 快速测试 + +```bash +# 1. 查看版本 +gitlink-cli version + +# 2. 配置 +gitlink-cli config init + +# 3. 登录 +gitlink-cli auth login + +# 4. 测试命令 +gitlink-cli user +me + +# 5. 查看仓库列表 +gitlink-cli repo +list +``` + +--- + +## 🔄 更新 + +### npm安装 + +```bash +# 更新到最新版本 +npm update -g @gitlink-ai/cli + +# 或重新安装 +npm uninstall -g @gitlink-ai/cli +npm install -g @gitlink-ai/cli +``` + +### 脚本安装 + +```bash +# 重新运行安装脚本(会覆盖旧版本) +curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash +``` + +--- + +## ❓ 常见问题 + +### Q1: 需要哪些系统权限? + +**A**: +- **一键脚本**: 需要`sudo`权限(安装到系统目录) +- **npm**: 需要全局npm写入权限 +- **源码构建**: 需要Go环境和写入权限 + +### Q2: 可以安装多个版本吗? + +**A**: 不建议。CLI工具通常会覆盖安装。如需多版本,可以使用Docker或版本管理工具。 + +### Q3: 离线环境如何安装? + +**A**: +```bash +# 在在线环境下载完整包 +wget https://releases.gitlink.org.cn/gitlink-cli/gitlink-cli-full-v0.2.0.tar.gz + +# 在离线环境安装 +tar -xzf gitlink-cli-full-v0.2.0.tar.gz +cd gitlink-cli +./install.sh --offline +``` + +### Q4: 安装后如何配置默认编辑器? + +**A**: +```bash +# 方法1: 配置文件 +vim ~/.config/gitlink-cli/config.yaml +# 添加: editor: vim + +# 方法2: 环境变量 +export EDITOR=vim + +# 方法3: 命令行参数 +gitlink-cli issue +create --editor vim +``` + +### Q5: Skills占多少空间? + +**A**: Skills大约占用5-10MB空间,包含12个完整的AI Agent技能包。 + +--- + +## 📞 获取帮助 + +如有安装问题,请: + +1. 🐛 提交Issue: https://www.gitlink.org.cn/Gitlink/gitlink-cli/issues +2. 📖 查看文档: https://www.gitlink.org.cn/Gitlink/gitlink-cli +3. 💬 查看卸载指南: [doc/UNINSTALL.md](./UNINSTALL.md) +4. 📧 联系支持: support@gitlink.org.cn + +--- + +## 🎯 下一步 + +安装完成后,建议: + +1. ✅ 运行 `gitlink-cli config init` 初始化配置 +2. ✅ 运行 `gitlink-cli auth login` 登录账号 +3. ✅ 查看 [README.md](../README.md) 了解基本使用 +4. ✅ 浏览 [Skills指南](../skills/README.md) 了解AI功能 + +--- + +**最后更新**: 2026-06-04 +**相关文档**: [UNINSTALL.md](./UNINSTALL.md) | [README.md](../README.md) \ No newline at end of file diff --git a/doc/UNINSTALL.md b/doc/UNINSTALL.md new file mode 100644 index 0000000..ae9949e --- /dev/null +++ b/doc/UNINSTALL.md @@ -0,0 +1,317 @@ +# GitLink CLI 卸载指南 + +本文档介绍如何完全卸载 GitLink CLI 及其相关文件。 + +## 📋 卸载方式 + +### 一、Linux/macOS + +#### 方式1: 使用卸载脚本(推荐) + +```bash +# 交互式卸载(保留配置) +bash uninstall.sh + +# 完全删除所有文件(包括配置) +bash uninstall.sh --purge + +# 自动确认,不询问 +bash uninstall.sh -y +``` + +#### 方式2: 在线执行 + +```bash +# 保留配置 +curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/uninstall.sh | bash + +# 完全删除 +curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/uninstall.sh | bash -s -- --purge +``` + +#### 方式3: 手动删除 + +```bash +# 删除二进制 +sudo rm -f /usr/local/bin/gitlink-cli +# 或 +sudo rm -f /usr/bin/gitlink-cli + +# 删除Skills +rm -rf ~/.gitlink/skills + +# 删除配置(可选) +rm -rf ~/.gitlink-cli +rm -rf ~/.gitlink +rm -rf ~/.config/gitlink-cli +``` + +--- + +### 二、Windows + +#### 方式1: PowerShell 脚本(推荐) + +```powershell +# 交互式卸载 +powershell -NoProfile -ExecutionPolicy Bypass -File uninstall.ps1 + +# 完全删除 +powershell -NoProfile -ExecutionPolicy Bypass -File uninstall.ps1 -Purge + +# 自动确认 +.\uninstall.ps1 -Yes +``` + +#### 方式2: 手动删除 + +```powershell +# 删除二进制(根据安装位置) +Remove-Item "$env:USERPROFILE\.gitlink-cli\bin\gitlink-cli.exe" + +# 删除Skills +Remove-Item -Recurse -Force "$env:USERPROFILE\.gitlink\skills" + +# 删除配置(可选) +Remove-Item -Recurse -Force "$env:USERPROFILE\.gitlink-cli" +Remove-Item -Recurse -Force "$env:USERPROFILE\.gitlink" +``` + +--- + +### 三、npm 安装 + +#### 方式1: npm 卸载(推荐) + +```bash +# 标准卸载(保留配置) +npm uninstall -g @gitlink-ai/cli + +# 完全删除(包括配置) +npm uninstall -g @gitlink-ai/cli --purge +``` + +#### 方式2: 使用卸载命令 + +```bash +# 保留配置 +gitlink-cli-uninstall + +# 完全删除 +gitlink-cli-uninstall --purge +``` + +#### 方式3: 手动删除 + +```bash +# 卸载npm包 +npm uninstall -g @gitlink-ai/cli + +# 删除Skills +rm -rf ~/.gitlink/skills + +# 删除配置(可选) +rm -rf ~/.gitlink-cli +rm -rf ~/.gitlink +``` + +--- + +## 🔧 卸载选项说明 + +### `--purge` 参数 + +完全删除所有文件,包括: +- ✅ 二进制文件 +- ✅ Skills目录 +- ✅ 配置文件 +- ✅ 用户数据 +- ✅ 缓存文件 + +### `--yes` / `-y` 参数 + +自动确认所有操作,不询问用户。 + +### 交互模式(默认) + +卸载过程中会询问: +1. 是否删除配置文件和数据 +2. npm安装时是否卸载npm包 +3. 确认卸载操作 + +--- + +## 📂 卸载内容清单 + +### 始终删除 +- ✅ 二进制文件 (`gitlink-cli` 或 `gitlink-cli.exe`) +- ✅ Skills目录 (`~/.gitlink/skills/`) + +### 条件删除(需要确认或 `--purge`) +- 🔸 配置目录 (`~/.gitlink-cli/`) +- 🔸 数据目录 (`~/.gitlink/`) +- 🔸 配置文件 (`~/.config/gitlink-cli/`) +- 🔸 npm全局包 (`@gitlink-ai/cli`) + +### 保留文件 +- 📌 用户的项目文件 +- 📌 Git仓库 +- 📌 系统环境变量(需手动清理) + +--- + +## 🧹 清理剩余文件 + +### Linux/macOS + +```bash +# 检查剩余文件 +find ~ -name "*gitlink*" -type f 2>/dev/null +find ~ -name "*gitlink*" -type d 2>/dev/null + +# 清理环境变量(如果手动添加过) +# 编辑 ~/.bashrc, ~/.zshrc 等,删除相关行 +``` + +### Windows + +```powershell +# 检查剩余文件 +Get-ChildItem -Path $env:USERPROFILE -Recurse -Filter "*gitlink*" -ErrorAction SilentlyContinue + +# 清理PATH环境变量 +# 1. 打开"系统属性" > "环境变量" +# 2. 在"用户变量"或"系统变量"的Path中删除gitlink-cli路径 +``` + +--- + +## ❓ 常见问题 + +### Q1: 卸载后命令仍然可用? + +**A**: 可能是PATH缓存问题,解决方法: + +**Linux/macOS**: +```bash +# 刷新shell +hash -r gitlink-cli + +# 或重启终端 +``` + +**Windows**: +```powershell +# 重启PowerShell或CMD +``` + +### Q2: 提示权限不足? + +**A**: 使用管理员权限或删除到用户目录: + +**Linux/macOS**: +```bash +# 使用sudo +sudo bash uninstall.sh + +# 或安装到用户目录 +INSTALL_DIR=$HOME/.local/bin bash uninstall.sh +``` + +**Windows**: +```powershell +# 以管理员身份运行PowerShell +``` + +### Q3: 如何重新安装? + +**A**: 重新运行安装脚本即可: + +```bash +# Linux/macOS +curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash + +# Windows +powershell -NoProfile -ExecutionPolicy Bypass -File install.ps1 + +# npm +npm install -g @gitlink-ai/cli +``` + +### Q4: 配置文件会自动删除吗? + +**A**: 不会,除非使用 `--purge` 参数或确认删除。这是为了保护用户数据。 + +### Q5: Skills会被删除吗? + +**A**: 会的,Skills目录会被自动删除。如需保留,请手动备份: + +```bash +# 备份Skills +cp -r ~/.gitlink/skills ~/gitlink-skills-backup +``` + +--- + +## 🔍 故障排除 + +### 卸载脚本找不到 + +```bash +# 确保在项目目录中 +cd /path/to/gitlink-cli + +# 检查脚本是否存在 +ls -la uninstall.sh uninstall.ps1 +``` + +### 删除失败 + +```bash +# 检查文件权限 +ls -la /usr/local/bin/gitlink-cli + +# 强制删除 +sudo rm -f /usr/local/bin/gitlink-cli +``` + +### npm卸载后仍有残留 + +```bash +# 手动清理npm缓存 +npm cache clean --force + +# 检查全局安装位置 +npm root -g +npm list -g --depth=0 + +# 手动删除残留 +``` + +--- + +## 📞 获取帮助 + +如有任何问题,请: + +1. 🐛 提交Issue: https://www.gitlink.org.cn/Gitlink/gitlink-cli/issues +2. 💬 查看文档: https://www.gitlink.org.cn/Gitlink/gitlink-cli +3. 📧 联系支持: support@gitlink.org.cn + +--- + +## ✅ 卸载检查清单 + +完成卸载后,可以检查以下内容: + +- [ ] 命令不可用(运行 `gitlink-cli --version` 应该报错) +- [ ] 二进制文件已删除 +- [ ] Skills目录已删除 +- [ ] 配置文件已删除(如需要) +- [ ] PATH环境变量已清理 +- [ ] 没有残留进程(`ps aux | grep gitlink` 或任务管理器) + +--- + +**最后更新**: 2026-06-04 +**适用版本**: gitlink-cli v0.2.0+ \ No newline at end of file diff --git a/doc/dashboard.html b/doc/dashboard.html new file mode 100644 index 0000000..a457616 --- /dev/null +++ b/doc/dashboard.html @@ -0,0 +1,400 @@ + + + + + +gitlink-cli · 功能全景 + + + + +
+
+
+ +
+

gitlink-cli · 功能全景

+
交互式命令浏览仪表盘
+
+
+
+ + + / +
+
+
+ +
+
0
功能分类
+
0
Shortcuts 总数
+
0
需认证命令
+
0
公开命令
+
+ +
+
+
+
🔍
+
没有匹配的命令,换个关键词试试。
+
+
+ +
+ Generated by gitlink-code-insight skill · + Gitlink · + 共 0 个命令 +
+ + + + diff --git a/doc/design.md b/doc/design.md index d25295b..d6f7982 100644 --- a/doc/design.md +++ b/doc/design.md @@ -40,28 +40,33 @@ gitlink-cli/ │ ├── common/ │ │ ├── types.go # Shortcut / Flag / RuntimeContext 定义 │ │ └── runner.go # CallAPI / PaginateAll / ResolveOwnerRepo -│ ├── repo/ # repo +create / +clone / +fork / +list / +info -│ ├── issue/ # issue +list / +create / +view / +close / +comment -│ ├── pr/ # pr +list / +create / +view / +merge / +review -│ ├── release/ # release +list / +create / +download -│ ├── branch/ # branch +list / +protect / +unprotect -│ ├── org/ # org +list / +info / +members +│ ├── repo/ # repo +create / +clone / +fork / +list / +info / +delete / +settings / +batch-create / +batch-update +│ ├── issue/ # issue +list / +create / +view / +update / +close / +comment / +assign / +label / +batch-* (6 个批量命令) +│ ├── wiki/ # wiki +list / +view / +create / +update / +delete +│ ├── pr/ # pr +list / +create / +view / +merge / +close / +review / +files / +diff +│ ├── release/ # release +list / +create / +view / +delete / +download +│ ├── branch/ # branch +list / +create / +delete / +protect / +unprotect +│ ├── webhook/ # webhook +list / +create / +update / +delete / +test / +info +│ ├── org/ # org +list / +info / +members / +create │ ├── user/ # user +me / +info │ ├── search/ # search +repos / +issues / +users -│ ├── ci/ # ci +builds / +logs / +restart +│ ├── ci/ # ci +builds / +logs / +restart / +stop │ └── register.go # 注册所有 shortcuts 到 cobra ├── skills/ │ ├── gitlink-shared/ # SKILL.md — 认证、全局参数、安全规则 │ ├── gitlink-repo/ # SKILL.md + references/ — 仓库操作 │ ├── gitlink-issue/ # SKILL.md + references/ — Issue 操作 │ ├── gitlink-pr/ # SKILL.md + references/ — PR 操作 +│ ├── gitlink-release/ # SKILL.md + references/ — 发布管理 +│ ├── gitlink-branch/ # SKILL.md + references/ — 分支操作 │ ├── gitlink-ci/ # SKILL.md + references/ — CI/CD 操作 │ ├── gitlink-org/ # SKILL.md + references/ — 组织管理 -│ ├── gitlink-release/ # SKILL.md + references/ — 发布管理 │ ├── gitlink-search/ # SKILL.md + references/ — 搜索 │ ├── gitlink-user/ # SKILL.md + references/ — 用户管理 │ ├── gitlink-pm/ # SKILL.md + references/ — 项目管理 -│ └── gitlink-workflow/ # SKILL.md — AI 自动化工作流(Issue 分类、PR Review 等) +│ ├── gitlink-wiki/ # SKILL.md + references/ + examples/ — Wiki 操作 +│ ├── gitlink-webhook/ # SKILL.md + references/ + examples/ — Webhook 管理 +│ └── gitlink-workflow/ # SKILL.md + references/ + examples/ — AI 自动化工作流(Issue 分类、PR Review 等) ├── go.mod ├── go.sum ├── Makefile @@ -74,18 +79,20 @@ gitlink-cli/ ### 2.1 Layer 1: Shortcuts(快捷命令,`+` 前缀) -面向高频场景的语义化封装,MVP 覆盖 ~43 个: +面向高频场景的语义化封装,覆盖 13 个领域共 62 个命令: | 领域 | Shortcuts | 数量 | |------|-----------|------| -| repo | `+create` `+clone` `+fork` `+list` `+info` `+delete` `+settings` | 7 | -| issue | `+list` `+create` `+view` `+update` `+close` `+comment` `+assign` `+label` | 8 | +| repo | `+create` `+clone` `+fork` `+list` `+info` `+delete` `+settings` `+batch-create` `+batch-update` | 9 | +| issue | `+list` `+create` `+view` `+update` `+close` `+comment` `+assign` `+label` `+batch-close` `+batch-status` `+batch-priority` `+batch-assign` `+batch-label` `+batch-create` | 14 | +| wiki | `+list` `+view` `+create` `+update` `+delete` | 5 | | pr | `+list` `+create` `+view` `+merge` `+close` `+review` `+files` `+diff` | 8 | | release | `+list` `+create` `+view` `+delete` `+download` | 5 | | branch | `+list` `+create` `+delete` `+protect` `+unprotect` | 5 | | org | `+list` `+info` `+members` `+create` | 4 | | ci | `+builds` `+logs` `+restart` `+stop` | 4 | | user | `+me` `+info` | 2 | +| webhook | `+list` `+create` `+update` `+delete` `+test` `+info` | 6 | **Shortcut 声明式定义**: @@ -437,7 +444,169 @@ skills/ --- -## 9 完整命令参考 +## 9 批量操作设计模式 + +Issue 和 Repo 两个领域均实现了批量操作命令,遵循统一的设计模式。 + +### 9.1 命令清单 + +| 领域 | 命令 | 用途 | 输入方式 | +|------|------|------|----------| +| issue | `+batch-close` | 批量关闭 | `--numbers` 或 `--from` CSV | +| issue | `+batch-status` | 批量更换状态 | `--state` + `--numbers`/`--from` | +| issue | `+batch-priority` | 批量更换优先级 | `--priority` + `--numbers`/`--from` | +| issue | `+batch-assign` | 批量更换负责人 | `--assignee` + `--numbers`/`--from` | +| issue | `+batch-label` | 批量更换标记 | `--label` + `--numbers`/`--from` | +| issue | `+batch-create` | 批量创建 Issue | `--titles` 或 `--from` CSV(支持 Bug/Feature 模板) | +| repo | `+batch-create` | 批量创建仓库 | `--names` 或 `--from` CSV | +| repo | `+batch-update` | 批量更新仓库 | `--names` 或 `--from` CSV | + +### 9.2 核心类型 + +```go +type BatchResult struct { + Number string `json:"number"` // Issue 编号或仓库名 + Action string `json:"action"` // 操作类型 + Status string `json:"status"` // 执行结果 + Error string `json:"error,omitempty"` +} + +type BatchSummary struct { + Repository string `json:"repository"` + Action string `json:"action"` + Value string `json:"value,omitempty"` + DryRun bool `json:"dry_run"` + Total int `json:"total"` + Succeeded int `json:"succeeded"` + Failed int `json:"failed"` + Results []BatchResult `json:"results"` +} +``` + +### 9.3 统一设计原则 + +| 原则 | 说明 | +|------|------| +| **输入灵活** | `--numbers`/`--names`(内联逗号分隔)和 `--from`(CSV 文件)可同时使用,自动去重合并 | +| **dry-run 统一** | 所有批量命令支持 `--dry-run`,预览模式下 status 为 `"planned"`,不发起写请求 | +| **错误不中断** | 单条失败不影响后续处理,全部执行完后返回完整汇总。有任何失败则 exit code = 1 | +| **输出统一** | 所有命令输出相同结构的 `BatchSummary` JSON | +| **修改前先 GET** | Issue 批量修改先 GET 当前 Issue 保留 subject/description,PATCH 时只替换目标字段。Repo 批量更新先 GET 获取 name + identifier | +| **参数容错** | 所有名称映射(状态、优先级、标签、负责人)同时支持字符串名和直接传数字 ID | + +### 9.4 Issue 参数值映射 + +**状态**:`new`(1) / `in-progress`(2) / `resolved`(3) / `closed`(5) / `rejected`(6) + +**优先级**:`low`(1) / `normal`(2) / `high`(3) / `urgent`(4) + +**标记**:使用项目级中文标签名映射到大整数 ID(如 `缺陷`→315526、`功能`→315527) + +**负责人**:传入 login 用户名,CLI 调用 `/users/{login}` API 转换为 `user_id` + +### 9.5 Issue 批量创建模板系统 + +`+batch-create` 支持三种输入模式: + +1. **CLI 直接输入**(`--titles`):逗号分隔标题,统一应用 `--priority`/`--label`/`--assignee`/`--state` +2. **自由 CSV**(`--from`):自由指定 title/body/priority/label/assignee/status 列 +3. **模板 CSV**(`--from` + `--template`): + - `--template bug`:自动生成 Bug 描述格式,自动设置缺陷标签 + - `--template feature`:自动生成功能描述格式,自动设置功能标签 + +### 9.6 Repo 批量操作注意事项 + +- **batch-create**:POST 路径 `/{login}/{name}` 中的 login 必须是当前登录用户,需先 `GET /users/me` +- **batch-update**:PATCH 请求体必须包含从 GET 获取的 `name` 和 `identifier`,否则 API 报错 +- **--private/--public 互斥**:batch-update 不允许同时设置两个标志 + +### 9.7 关键 API 字段差异 + +GitLink 基于 Redmine 但修改了大量字段名: + +| Redmine 标准字段 | GitLink 实际字段 | 格式 | +|-----------------|-----------------|------| +| `assigned_to_id` | `assigner_ids` | 数组 `[user_id]` | +| `tracker_id` | `issue_tag_ids` | 数组 `[tag_id]`(项目级大整数) | + +> GitLink API 对不认识的字段返回 200 而非报错,字段名错误会导致静默失败。必须通过浏览器 DevTools 抓取实际请求确认字段名和格式。 + +--- + +## 10 Wiki Shortcut 设计 + +Wiki 是独立的全新 Shortcut 领域,提供 5 个命令覆盖 Wiki 页面的 CRUD 操作。 + +### 10.1 双域名架构 + +Wiki API 部署在 gateway 域名上,与主站 API(www 域名)分离: + +``` + ┌─ www 域名 ─────────────────────┐ +resolveProjectID() → │ GET /{owner}/{repo}/detail.json │ → project_id + └────────────────────────────────┘ + + ┌─ gateway 域名 ───────────────────────────┐ +callWikiAPI*() → │ /wiki/open/* (不带 .json) │ → 解包 code/data + └──────────────────────────────────────────┘ +``` + +- **www 域名**(`https://www.gitlink.org.cn/api`):用于 detail API 获取 project_id,走默认 client +- **gateway 域名**(`https://gateway.gitlink.org.cn/api`):用于所有 wiki CRUD API,走独立 client + +### 10.2 Client 扩展 + +`internal/client/client.go` 新增 `SkipJSONSuffix` 字段: + +```go +type Client struct { + // ... 原有字段 ... + SkipJSONSuffix bool // 为 true 时不自动追加 .json 后缀(wiki gateway API 需要) +} +``` + +wiki 命令创建独立 client 实例,设置 `SkipJSONSuffix: true` 并使用 gateway BaseURL。 + +### 10.3 响应解包 + +Gateway API 使用不同的响应格式 `{code, data, msg}`(而非常规的 `{status, ...}`): + +```go +// unwrapGatewayResponse 解包 gateway 响应 +// 成功: code=200/201, 提取 data 字段 +// 失败: code=500/400/404, 返回 "[code] msg" 错误信息 +func unwrapGatewayResponse(raw []byte) ([]byte, error) +``` + +### 10.4 命令详情 + +| 命令 | HTTP 方法 | API 路径 | 关键参数 | +|------|----------|---------|---------| +| `wiki +list` | GET | `/wiki/open/wikiPages` | 无额外参数 | +| `wiki +view` | GET | `/wiki/open/getWiki` | `--title`(必填) | +| `wiki +create` | POST | `/wiki/open/createWiki` | `--title`(必填)`--content/--file` `--message` | +| `wiki +update` | PUT | `/wiki/open/updateWiki` | `--title`(必填)`--cover/--add` `--file` | +| `wiki +delete` | DELETE | `/wiki/open/deleteWiki` | `--title`(必填) | + +### 10.5 特殊处理 + +| 处理项 | 说明 | +|--------|------| +| **base64 编解码** | Wiki 内容在 API 中为 base64 编码,CLI 自动编解码,对用户透明 | +| **project_id 缓存** | `resolveProjectID()` 使用 `sync.Map` 缓存,同一 owner/repo 只调一次 API | +| **owner/repo 自动解析** | 在 git 仓库目录下可省略 `--owner`/`--repo` | +| **--update --add 模式** | 先 GET 现有内容 → 解码 → 追加 → 重新编码 → PUT 提交 | +| **嵌套对象过滤** | 表格输出时过滤无意义的嵌套对象字段(如 wiki_clone_link) | + +### 10.6 已知限制 + +- **delete 后端 bug**:GitLink 平台 `deleteWiki` API 只清空内容,不删除侧边栏条目 +- **gateway 域名硬编码**:wiki API 仅部署在 gateway,detail API 在 www,不可互换 +- **create/update pageName 差异**:create 接受原始中文 pageName,update 需要 URL 编码 + +--- + +## 11 完整命令参考 ``` gitlink-cli @@ -457,7 +626,9 @@ gitlink-cli │ ├── +list # 仓库列表 │ ├── +info # 仓库详情 │ ├── +delete # 删除仓库 -│ └── +settings # 仓库设置 +│ ├── +settings # 仓库设置 +│ ├── +batch-create # 批量创建仓库 +│ └── +batch-update # 批量更新仓库 ├── issue │ ├── +list # Issue 列表 │ ├── +create # 创建 Issue @@ -466,7 +637,13 @@ gitlink-cli │ ├── +close # 关闭 Issue │ ├── +comment # 添加评论 │ ├── +assign # 指派 -│ └── +label # 标签管理 +│ ├── +label # 标签管理 +│ ├── +batch-close # 批量关闭 Issue +│ ├── +batch-status # 批量更换状态 +│ ├── +batch-priority # 批量更换优先级 +│ ├── +batch-assign # 批量更换负责人 +│ ├── +batch-label # 批量更换标记 +│ └── +batch-create # 批量创建 Issue(含 Bug/Feature 模板) ├── pr │ ├── +list # PR 列表 │ ├── +create # 创建 PR @@ -501,6 +678,19 @@ gitlink-cli ├── user │ ├── +me # 当前用户 │ └── +info # 用户详情 +├── webhook +│ ├── +list # Webhook 列表 +│ ├── +create # 创建 Webhook +│ ├── +update # 更新 Webhook +│ ├── +delete # 删除 Webhook +│ ├── +test # 测试 Webhook +│ └── +info # Webhook 详情 +├── wiki +│ ├── +list # Wiki 页面列表 +│ ├── +view # 查看 Wiki 页面 +│ ├── +create # 创建 Wiki 页面 +│ ├── +update # 更新 Wiki 页面 +│ └── +delete # 删除 Wiki 页面 ├── search │ ├── +repos # 搜索仓库 │ ├── +issues # 搜索 Issue @@ -518,7 +708,7 @@ gitlink-cli --- -## 10 关键文件清单 +## 12 关键文件清单 实现时需要修改/创建的核心文件: @@ -541,8 +731,10 @@ gitlink-cli | `internal/registry/meta_data.json` | API 元数据 | | `shortcuts/common/types.go` | Shortcut 核心类型 | | `shortcuts/common/runner.go` | RuntimeContext | -| `shortcuts/repo/*.go` | 仓库 shortcuts | -| `shortcuts/issue/*.go` | Issue shortcuts | +| `shortcuts/repo/*.go` | 仓库 shortcuts(含 batch_create/batch_update) | +| `shortcuts/issue/*.go` | Issue shortcuts(含 batch.go + batch_create.go 批量操作) | +| `shortcuts/wiki/*.go` | Wiki shortcuts(list, view, create, update, delete) | +| `shortcuts/webhook/*.go` | Webhook shortcuts(list, create, update, delete, test, info) | | `shortcuts/pr/*.go` | PR shortcuts | | `shortcuts/register.go` | Shortcut 注册 | | `skills/gitlink-shared/SKILL.md` | 共享 Skill | @@ -550,7 +742,7 @@ gitlink-cli --- -## 11 开发计划 +## 13 开发计划 ### Phase 1: Foundation(第 1-2 周) @@ -609,7 +801,7 @@ gitlink-cli --- -## 12 验证方案 +## 14 验证方案 | 阶段 | 验证方式 | |------|----------| diff --git a/doc/gitlink_api_reference.md b/doc/gitlink_api_reference.md index fc54084..2a1d40b 100644 --- a/doc/gitlink_api_reference.md +++ b/doc/gitlink_api_reference.md @@ -23,6 +23,159 @@ - HTTP Authentication, scheme: bearer +--- + +# GitLink API 使用注意事项 + +> **重要提示**: 以下注意事项基于实际使用经验总结,使用GitLink API时请特别注意这些行为和限制。 + +## 已知问题和特殊行为 + +### API响应格式 +| 问题 | 说明 | 影响 | 解决方案 | +|------|------|------|----------| +| **双重错误码** | HTTP 200 + body.status 非200 | 错误判断复杂 | 需要检查HTTP状态码和body.status | +| **错误格式不一致** | 有`{status, message}`也有`{code, msg}` | 错误解析困难 | 兼容处理两种格式 | +| **静默失败** | 字段名错误可能返回200但不生效 | 调试困难 | 通过GET验证实际修改 | + +### Issue API +| API端点 | 问题 | 解决方案 | 状态 | +|---------|------|----------|------| +| Issue创建 | 必须包含`done_ratio: 0`,否则数据库报错 | 自动添加该字段 | ✅ shortcuts已处理 | +| Issue更新 | 需保留`subject`/`description`,否则可能清空描述 | 先GET再提交,保留字段 | ⚠️ 需手动处理 | +| Issue列表 | 分页参数可能不返回完整统计 | 客户端需分页处理 | ✅ 正常 | + +### Release API +| API端点 | 问题 | 解决方案 | 状态 | +|---------|------|----------|------| +| Release查看 | 需要`version_id`不能用`tag_name` | 使用`release +list`获取ID | ⚠️ 注意参数 | +| Release删除 | 需要`version_id` | 使用`release +delete -i ` | ✅ shortcuts已处理 | + +### 分支API +| API端点 | 问题 | 解决方案 | 状态 | +|---------|------|----------|------| +| 分支操作 | 需要`/v1/`前缀 | 端点使用`/v1/:owner/:repo/branches` | ✅ shortcuts已处理 | +| 分支删除 | `DELETE`分支API始终返回"分支不存在" | GitLink平台Bug,暂不支持 | ❌ API不可用 | + +### 文件操作API +| API端点 | 问题 | 解决方案 | 状态 | +|---------|------|----------|------| +| 创建文件 | `content`字段必须base64编码 | 不编码会返回"文件已存在"错误 | ⚠️ 需手动处理 | +| 更新文件 | 需要`sha`参数,通过`sub_entries`获取 | 先GET获取SHA再PUT | ⚠️ 复杂操作 | + +### Pull Request API +| API端点 | 问题 | 解决方案 | 状态 | +|---------|------|----------|------| +| PR合并 | 需要`do`参数指定合并方式 | `pr +merge`已内置处理 | ✅ shortcuts已处理 | +| PR列表 | `--state`参数只影响统计,列表可能包含所有状态 | 客户端需按`pull_request_status`过滤 | ⚠️ 需手动处理 | +| PR创建 | 分支内容必须与目标分支不同 | 需要先有实际提交差异 | ⚠️ API限制 | + +### Wiki API +| API端点 | 问题 | 解决方案 | 状态 | +|---------|------|----------|------| +| Wiki域名 | 使用Gateway域名而非主域名 | Wiki使用独立client处理 | ✅ shortcuts已处理 | +| Wiki内容 | base64编码传输 | CLI自动编解码 | ✅ shortcuts已处理 | +| Wiki删除 | API只清空内容,不删除侧边栏条目 | GitLink平台限制 | ⚠️ 部分功能 | + +### Webhook API +| API端点 | 问题 | 解决方案 | 状态 | +|---------|------|----------|------| +| Webhook创建 | 需要完整的URL和事件配置 | 按文档格式提交 | ✅ shortcuts已处理 | +| Webhook测试 | 测试推送可能延迟 | 等待异步处理 | ✅ shortcuts已处理 | + +## 推荐使用方式 + +### 优先级顺序 +1. **Shortcuts** - 最简单,自动处理特殊情况 + ```bash + gitlink-cli issue +create -t "Bug" -b "详细描述" + gitlink-cli wiki +create --title "Home" --content "# 欢迎" + ``` + +2. **Raw API** - Shortcuts未覆盖时使用 + ```bash + gitlink-cli api POST /:owner/:repo/issues --body '{"subject":"test","done_ratio":0}' + ``` + +3. **直接HTTP** - 仅用于调试或特殊需求 + ```bash + curl -X POST "https://www.gitlink.org.cn/api/:owner/:repo/issues.json?access_token=xxx" + ``` + +### 错误处理建议 + +**推荐错误处理流程**: +1. 检查HTTP状态码 +2. 检查body中的status/code字段 +3. 验证实际修改是否生效(GET验证) +4. 使用shortcuts避免直接处理复杂情况 + +**错误处理示例**: +```python +def check_gitlink_error(response): + # 1. 检查HTTP状态码 + if response.status_code >= 400: + return f"HTTP错误: {response.status_code}" + + # 2. 检查body中的错误字段 + data = response.json() + if 'status' in data and data['status'] != 200: + return f"API错误: {data.get('message', '未知错误')}" + if 'code' in data and data['code'] != 200: + return f"Gateway错误: [{data['code']}] {data.get('msg', '未知错误')}" + + # 3. 验证实际修改 + return None +``` + +### 认证相关 + +**Token获取方式**: +1. 用户名密码登录: `gitlink-cli auth login` +2. 直接Token: `gitlink-cli auth login --token` +3. 环境变量: `export GITLINK_TOKEN="your-token"` + +**Token有效期**: 7天,过期需重新登录 + +**认证优先级**: 环境变量 > Keychain存储 > 交互式登录 + +### 请求限制 + +**速率限制**: GitLink API有基本的速率限制,建议: +- 批量操作使用专门的batch命令 +- 避免短时间内大量请求 +- 使用`--dry-run`预览批量操作 + +**分页处理**: 大量数据建议: +- 使用shortcuts的自动分页功能 +- 或者使用Raw API手动处理分页参数 + +## 开发建议 + +### 使用gitlink-cli的优势 +1. **自动处理特殊情况** - 如base64编码、双重错误码等 +2. **统一的错误处理** - 标准化的错误信息和建议 +3. **AI Agent友好** - 完整的Skills文档支持 +4. **跨平台支持** - macOS、Linux、Windows + +### 调试技巧 +1. **使用`--debug`参数** 查看详细的请求响应 + ```bash + gitlink-cli --debug issue +list + ``` + +2. **使用`--format json`** 获取结构化输出 + ```bash + gitlink-cli --format json issue +list + ``` + +3. **使用`--dry-run`** 预览危险操作 + ```bash + gitlink-cli issue +batch-close --numbers 1,2,3 --dry-run + ``` + +--- + # 附件 ## POST 上传文件 @@ -15206,6 +15359,46 @@ GET /api/wikiExport/wikiExport-wrapper |» data|object|false|none||none| |» message|string|false|none||none| +--- + +## Gateway Wiki API(CLI 实际调用) + +gitlink-cli 的 wiki shortcut 实际调用的是 **gateway 域名**下的 `/wiki/open/*` 端点,而非上述 www 域名的 `/api/wiki/*` 端点。两者存在以下差异: + +| 差异项 | www 域名(APIfox 文档) | gateway 域名(CLI 实际使用) | +|--------|------------------------|---------------------------| +| Base URL | `https://www.gitlink.org.cn/api` | `https://gateway.gitlink.org.cn/api` | +| URL 前缀 | `/api/wiki/` | `/wiki/open/` | +| JSON 后缀 | 需要 `.json` | 不需要 `.json` | +| 响应格式 | 直接返回 data | `{"code": 200, "data": {...}, "msg": ""}` 包一层 | +| 错误判断 | `status` 字段 ≠ 200 | `code` 字段 ≠ 200/201 | + +### 实际调用路径对比 + +| 操作 | www 文档路径 | gateway 实际路径 | +|------|-------------|-----------------| +| 创建 | `POST /api/wiki/createWiki.json` | `POST /wiki/open/createWiki` | +| 删除 | `DELETE /api/wiki/deleteWiki.json` | `DELETE /wiki/open/deleteWiki` | +| 查看 | `GET /api/wiki/getWiki.json` | `GET /wiki/open/getWiki` | +| 更新 | `PUT /api/wiki/updateWiki.json` | `PUT /wiki/open/updateWiki` | +| 列表 | `GET /api/wiki/wikiPages.json` | `GET /wiki/open/wikiPages` | + +### 响应格式差异示例 + +**www 域名响应**(标准格式): +```json +{"data": {"title": "test", "content": "..."}} +``` + +**gateway 域名响应**(包一层): +```json +{"code": 200, "data": {"title": "test", "content": "..."}, "msg": "success"} +``` + +CLI 通过 `unwrapGatewayResponse()` 函数自动解包 gateway 格式,对用户透明。 + +> **注意**:project_id 仍需通过 www 域名的 `/api/{owner}/{repo}/detail.json` 获取,两个域名不可互换。 + # 流水线 ## GET 流水线列表 diff --git a/gitlink-cli b/gitlink-cli new file mode 100644 index 0000000..1cceae3 Binary files /dev/null and b/gitlink-cli differ diff --git a/install.ps1 b/install.ps1 new file mode 100644 index 0000000..1617e5e --- /dev/null +++ b/install.ps1 @@ -0,0 +1,441 @@ +# GitLink CLI Windows 安装脚本 +# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File install.ps1 +# 支持: .\install.ps1 -Version 0.1.0 -InstallDir "C:\GitLink" + +param( + [string]$Version = "latest", + [string]$InstallDir = "$HOME\.gitlink-cli\bin", + [switch]$Help = $false, + [switch]$Uninstall = $false, + [switch]$ListVersions = $false, + [switch]$Debug = $false +) + +$ErrorActionPreference = "Stop" +$REPO_OWNER = "Gitlink" +$REPO_NAME = "gitlink-cli" +$BINARY_NAME = "gitlink-cli" +$API_BASE = "https://www.gitlink.org.cn" +$SKILLS_DIR = "$HOME\.gitlink\skills" + +# 颜色输出 +function Info { + param([string]$msg) + Write-Host "[INFO] $msg" -ForegroundColor Green +} + +function Warn { + param([string]$msg) + Write-Host "[WARN] $msg" -ForegroundColor Yellow +} + +function ErrorMsg { + param([string]$msg) + Write-Host "[ERROR] $msg" -ForegroundColor Red +} + +function Step { + param([string]$msg) + Write-Host "[STEP] $msg" -ForegroundColor Cyan +} + +function DebugMsg { + param([string]$msg) + if ($Debug) { + Write-Host "[DEBUG] $msg" -ForegroundColor Blue + } +} + +# 显示帮助 +function Show-Help { + Write-Host @" +GitLink CLI Windows 安装脚本 + +用法: + .\install.ps1 [选项] + +选项: + -Version 指定版本 (默认: latest) + -InstallDir 安装目录 (默认: $HOME\.gitlink-cli\bin) + -Uninstall 卸载 + -ListVersions 列出可用版本 + -Debug 显示调试信息 + -Help 显示此帮助 + +示例: + # 安装最新版本 + .\install.ps1 + + # 安装指定版本 + .\install.ps1 -Version "0.1.0" + + # 安装到指定目录 + .\install.ps1 -InstallDir "C:\Tools\gitlink-cli" + + # 列出可用版本 + .\install.ps1 -ListVersions + + # 卸载 + .\install.ps1 -Uninstall + + # 在线安装 + powershell -NoProfile -ExecutionPolicy Bypass -Command "iex (irm https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.ps1)" +"@ +} + +# 检测架构 +function Get-PlatformInfo { + $arch = switch ([System.Runtime.InteropServices::RuntimeInformation]::OSArchitecture) { + "X64" { "amd64" } + "Arm64" { "arm64" } + "X86" { "386"; Warn "32位系统支持有限" } + default { throw "不支持的架构: $arch" } + } + return @{ Platform = "windows"; Arch = $arch } +} + +# 检查环境 +function Test-Environment { + Step "检查安装环境..." + + # 检查磁盘空间(至少50MB) + $drive = (Get-Item $InstallDir).PSDrive.Name + $driveInfo = Get-PSDrive $drive + $freeSpaceMB = [math]::Round($driveInfo.Free / 1MB, 2) + + if ($freeSpaceMB -lt 50) { + ErrorMsg "磁盘空间不足(需要至少50MB,可用: ${freeSpaceMB}MB)" + exit 1 + } + DebugMsg "磁盘空间检查通过: ${freeSpaceMB}MB" + + # 检查网络连接 + try { + $response = Invoke-WebRequest -Uri $API_BASE -UseBasicParsing -TimeoutSec 5 -Method Head + DebugMsg "网络连接检查通过" + } + catch { + ErrorMsg "无法连接到 GitLink 服务器: $API_BASE" + ErrorMsg "请检查网络连接" + exit 1 + } + + Info "环境检查通过" +} + +# 下载文件(带重试) +function Download-File { + param( + [string]$Url, + [string]$Output, + [int]$MaxAttempts = 3 + ) + + $attempt = 1 + while ($attempt -le $MaxAttempts) { + try { + Step "下载 (尝试 $attempt/$MaxAttempts): $(Split-Path $Url -Leaf)" + DebugMsg "URL: $Url" + + # 使用WebClient,支持进度显示 + $webClient = New-Object System.Net.WebClient + + # 注册下载进度事件 + Register-ObjectEvent -InputObject $webClient -EventName DownloadProgressChanged -SourceIdentifier WebClient.DownloadProgressChanged -Action { + global:Progress = $EventArgs.ProgressPercentage + Write-Progress -Activity "下载中" -Status "$($EventArgs.ProgressPercentage)% 完成" -PercentComplete $EventArgs.ProgressPercentage + } | Out-Null + + # 注册下载完成事件 + Register-ObjectEvent -InputObject $webClient -EventName DownloadFileCompleted -SourceIdentifier WebClient.DownloadFileCompleted -Action { + global:DownloadComplete = $true + } | Out-Null + + # 开始下载 + $webClient.DownloadFileAsync($Url, $Output) + + # 等待下载完成 + while (-not $global:DownloadComplete) { + Start-Sleep -Milliseconds 100 + } + + Write-Progress -Activity "下载中" -Completed + + # 清理事件 + Unregister-Event -SourceIdentifier WebClient.DownloadProgressChanged -ErrorAction SilentlyContinue + Unregister-Event -SourceIdentifier WebClient.DownloadFileCompleted -ErrorAction SilentlyContinue + $webClient.Dispose() + + if (Test-Path $Output) { + $size = [math]::Round((Get-Item $Output).Length / 1MB, 2) + Info "下载成功: $(Split-Path $Output -Leaf) (${size}MB)" + return $true + } + else { + Warn "下载文件为空" + } + } + catch { + Warn "下载失败: $_" + } + + if ($attempt -lt $MaxAttempts) { + $waitTime = $attempt * 2 + Warn "等待 ${waitTime}s 后重试..." + Start-Sleep -Seconds $waitTime + } + + $attempt++ + } + + ErrorMsg "下载失败,已尝试 $MaxAttempts 次" + ErrorMsg "URL: $Url" + return $false +} + +# 获取最新版本 +function Get-LatestVersion { + Step "获取最新版本..." + + try { + $releasesUrl = "$API_BASE/api/$REPO_OWNER/$REPO_NAME/releases.json" + $releases = Invoke-RestMethod -Uri $releasesUrl -TimeoutSec 30 -UseBasicParsing + + if ($releases -and $releases.Count -gt 0) { + return $releases[0].tag_name + } + else { + Warn "无法获取版本信息,使用默认版本" + return "v0.1.0" + } + } + catch { + Warn "获取版本失败: $_" + return "v0.1.0" + } +} + +# 列出可用版本 +function Show-AvailableVersions { + Step "查询可用版本..." + + try { + $releasesUrl = "$API_BASE/api/$REPO_OWNER/$REPO_NAME/releases.json" + $releases = Invoke-RestMethod -Uri $releasesUrl -TimeoutSec 30 -UseBasicParsing + + if ($releases -and $releases.Count -gt 0) { + Info "可用版本:" + foreach ($release in $releases | Select-Object -First 10) { + Write-Host " - $($release.tag_name)" -ForegroundColor Cyan + } + } + else { + Warn "无法获取版本列表" + } + } + catch { + ErrorMsg "获取版本列表失败: $_" + } +} + +# 解压zip文件 +function Expand-ZipFile { + param( + [string]$ZipPath, + [string]$DestDir + ) + + Step "解压..." + DebugMsg "解压 $ZipPath 到 $DestDir" + + try { + Expand-Archive -Force -Path $ZipPath -DestinationPath $DestDir + Info "解压完成" + } + catch { + ErrorMsg "解压失败: $_" + throw + } +} + +# 添加到PATH +function Add-ToPath { + param([string]$Dir) + + $path = [Environment]::GetEnvironmentVariable("Path", "User") + if ($path -notlike "*$Dir*") { + Step "添加到PATH: $Dir" + [Environment]::SetEnvironmentVariable("Path", "$path;$Dir", "User") + Warn "请重启终端使PATH生效" + } + else { + DebugMsg "已在PATH中: $Dir" + } +} + +# 验证安装 +function Test-Installation { + Step "验证安装..." + + $binaryPath = Join-Path $InstallDir "$BINARY_NAME.exe" + + if (Test-Path $binaryPath) { + try { + $versionOutput = & $binaryPath version 2>$null + Info "安装成功! $versionOutput" + + # 添加到PATH + Add-ToPath $InstallDir + + Write-Host "" + Info "快速开始:" + Info " gitlink-cli auth login # 登录账号" + Info " gitlink-cli --help # 查看所有命令" + Info " gitlink-cli version # 查看版本信息" + Write-Host "" + + return $true + } + catch { + ErrorMsg "执行二进制文件失败: $_" + return $false + } + } + else { + ErrorMsg "安装验证失败: $binaryPath 不存在" + return $false + } +} + +# 主安装流程 +function Install-CLI { + Write-Host "" + Write-Host " ╔══════════════════════════════════════╗" + Write-Host " ║ GitLink CLI 一键安装 (Windows) ║" + Write-Host " ╚══════════════════════════════════════╝" + Write-Host "" + + # 检测平台 + $platform = Get-PlatformInfo + Info "检测到平台: $($platform.Platform)-$($platform.Arch)" + + # 创建安装目录 + if (!(Test-Path $InstallDir)) { + New-Item -ItemType Directory -Path $InstallDir -Force | Out-Null + Info "创建安装目录: $InstallDir" + } + + # 检查环境 + Test-Environment + + # 获取版本 + if ($Version -eq "latest") { + $Version = Get-LatestVersion + Info "最新版本: $Version" + } + else { + Info "指定版本: $Version" + } + + # 下载 + $zipName = "$BINARY_NAME_${Version}_windows_$($platform.Arch).zip" + $zipUrl = "$API_BASE/api/$REPO_OWNER/$REPO_NAME/releases/$Version/assets/$zipName" + $zipPath = Join-Path $env:TEMP $zipName + + if (-not (Download-File -Url $zipUrl -Output $zipPath)) { + ErrorMsg "请确认以下版本已发布: $Version" + ErrorMsg "发布页: $API_BASE/$REPO_OWNER/$REPO_NAME/releases" + exit 1 + } + + # 解压 + Expand-ZipFile -ZipPath $zipPath -DestDir $InstallDir + Remove-Item $zipPath + + # 安装skills + Step "安装 skills..." + if (!(Test-Path $SKILLS_DIR)) { + New-Item -ItemType Directory -Path $SKILLS_DIR -Force | Out-Null + } + + $skillsZipName = "$BINARY_NAME_${Version}_skills.zip" + $skillsUrl = "$API_BASE/api/$REPO_OWNER/$REPO_NAME/releases/$Version/assets/$skillsZipName" + $skillsZipPath = Join-Path $env:TEMP $skillsZipName + + if (Download-File -Url $skillsUrl -Output $skillsZipPath) { + try { + Expand-Archive -Force -Path $skillsZipPath -DestinationPath $SKILLS_DIR + Info "Skills 安装到: $SKILLS_DIR" + } + catch { + Warn "Skills 解压失败(可稍后手动安装)" + } + Remove-Item $skillsZipPath -ErrorAction SilentlyContinue + } + else { + Warn "Skills 包不可用(可稍后手动安装)" + } + + # 验证 + if (-not (Test-Installation)) { + exit 1 + } +} + +# 卸载 +function Uninstall-CLI { + Step "卸载 $BINARY_NAME..." + + # 删除二进制 + $binaryPath = Join-Path $InstallDir "$BINARY_NAME.exe" + if (Test-Path $binaryPath) { + Remove-Item $binaryPath -Force + Info "已删除: $binaryPath" + } + + # 删除skills + if (Test-Path $SKILLS_DIR) { + Remove-Item $SKILLS_DIR -Recurse -Force + Info "已删除: $SKILLS_DIR" + } + + # 删除配置(可选) + $response = Read-Host "是否删除配置文件? [y/N]" + if ($response -eq 'y' -or $response -eq 'Y') { + $configDir = Join-Path $env:APPDATA "gitlink-cli" + if (Test-Path $configDir) { + Remove-Item $configDir -Recurse -Force + Info "已删除配置文件" + } + } + + Info "卸载完成" +} + +# 主函数 +function Main { + if ($Help) { + Show-Help + exit 0 + } + + if ($ListVersions) { + Show-AvailableVersions + exit 0 + } + + if ($Uninstall) { + Uninstall-CLI + exit 0 + } + + try { + Install-CLI + } + catch { + ErrorMsg "安装失败: $_" + exit 1 + } +} + +Main diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..1018d44 --- /dev/null +++ b/install.sh @@ -0,0 +1,435 @@ +#!/bin/bash +# GitLink CLI 一键安装脚本(改进版) +# 自动检测平台,下载预编译二进制,安装 skills +# 用法: curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash +# 支持: VERSION=0.1.0 INSTALL_DIR=$HOME/.local/bin bash install.sh + +set -e + +REPO_OWNER="Gitlink" +REPO_NAME="gitlink-cli" +BINARY_NAME="gitlink-cli" +API_BASE="https://www.gitlink.org.cn" +INSTALL_DIR="${INSTALL_DIR:-auto}" +SKILLS_DIR="${HOME}/.gitlink/skills" +VERSION="${VERSION:-latest}" +MAX_ATTEMPTS=3 +DEBUG="${DEBUG:-false}" + +# ---------- 颜色输出 ---------- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BLUE='\033[0;34m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; } +step() { echo -e "${CYAN}[STEP]${NC} $*"; } +debug() { [[ "${DEBUG}" == "true" ]] && echo -e "${BLUE}[DEBUG]${NC} $*"; } + +# ---------- 环境检测 ---------- +check_environment() { + step "检查安装环境..." + + # 检查必需命令 + local required_commands=("curl" "tar") + for cmd in "${required_commands[@]}"; do + if ! command -v "$cmd" >/dev/null 2>&1; then + error "缺少必需命令: $cmd" + error "请安装后再试:" + error " Ubuntu/Debian: sudo apt-get install $cmd" + error " CentOS/RHEL: sudo yum install $cmd" + error " macOS: brew install $cmd" + exit 1 + fi + done + debug "必需命令检查通过" + + # 检查磁盘空间(至少50MB) + local available_space + available_space=$(df -m "$HOME" | tail -1 | awk '{print $4}') + if [ "$available_space" -lt 50 ]; then + error "磁盘空间不足(需要至少50MB,可用: ${available_space}MB)" + exit 1 + fi + debug "磁盘空间检查通过: ${available_space}MB" + + # 检查网络连接 + if ! curl -sSL --connect-timeout 5 "${API_BASE}" >/dev/null 2>&1; then + error "无法连接到 GitLink 服务器: ${API_BASE}" + error "请检查网络连接" + exit 1 + fi + debug "网络连接检查通过" + + info "环境检查通过" +} + +# ---------- 智能权限处理 ---------- +select_install_dir() { + local dirs=("$HOME/.local/bin" "$HOME/bin" "/usr/local/bin") + local selected_dir="" + + if [ "${INSTALL_DIR}" != "auto" ]; then + echo "${INSTALL_DIR}" + return + fi + + step "选择安装目录..." + + # 优先级:用户目录 > 系统目录 + for dir in "${dirs[@]}"; do + if [ -w "$dir" ] 2>/dev/null || mkdir -p "$dir" 2>/dev/null; then + selected_dir="$dir" + info "选择用户目录: $selected_dir" + break + fi + done + + # 如果用户目录都不可写,尝试系统目录 + if [ -z "$selected_dir" ]; then + warn "无法写入用户目录,将使用系统目录(需要sudo权限)" + selected_dir="/usr/local/bin" + fi + + echo "$selected_dir" +} + +# ---------- 下载重试机制 ---------- +download_with_retry() { + local url="$1" + local output="$2" + local attempt=1 + + while [ $attempt -le $MAX_ATTEMPTS ]; do + step "下载 (尝试 $attempt/$MAX_ATTEMPTS): $(basename "$url")" + + if curl -sSL --connect-timeout 10 --max-time 120 --progress-bar "$url" -o "$output" 2>&1; then + if [ -s "$output" ]; then + info "下载成功: $(basename "$output") ($(du -h "$output" | cut -f1))" + return 0 + else + warn "下载文件为空" + fi + else + warn "下载失败" + fi + + if [ $attempt -lt $MAX_ATTEMPTS ]; then + local wait_time=$((attempt * 2)) + warn "等待 ${wait_time}s 后重试..." + sleep $wait_time + fi + + attempt=$((attempt + 1)) + done + + error "下载失败,已尝试 $MAX_ATTEMPTS 次" + error "URL: $url" + error "请检查网络连接或手动下载" + return 1 +} + +# ---------- 平台检测 ---------- +detect_platform() { + local os arch + case "$(uname -s)" in + Linux) os="linux" ;; + Darwin) os="darwin" ;; + MINGW*|MSYS*|CYGWIN*) os="windows" ;; + *) error "不支持的操作系统: $(uname -s)"; exit 1 ;; + esac + case "$(uname -m)" in + x86_64|amd64) arch="amd64" ;; + aarch64|arm64) arch="arm64" ;; + *) error "不支持的架构: $(uname -m)"; exit 1 ;; + esac + echo "${os}/${arch}" +} + +# ---------- 获取最新版本 ---------- +fetch_latest_version() { + local releases_url="${API_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json" + + info "获取最新版本..." + + local releases + releases=$(curl -sSL --connect-timeout 10 --max-time 30 "${releases_url}" 2>/dev/null || true) + + if [ -z "$releases" ]; then + error "无法获取发布列表: ${releases_url}" + exit 1 + fi + + # 尝试解析第一个 release 的 tag_name + local tag + tag=$(echo "$releases" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/') + + if [ -z "$tag" ]; then + # 备选:尝试直接匹配数组第一个元素的 tag_name + tag=$(echo "$releases" | grep -oP '"tag_name"\s*:\s*"\K[^"]+' | head -1) + fi + + echo "${tag:-v0.1.0}" +} + +# ---------- 显示已安装版本 ---------- +show_installed_version() { + if command -v "${BINARY_NAME}" >/dev/null 2>&1; then + "${BINARY_NAME}" version 2>/dev/null || echo "未知版本" + elif [ -x "${INSTALL_DIR}/${BINARY_NAME}" ]; then + "${INSTALL_DIR}/${BINARY_NAME}" version 2>/dev/null || echo "未知版本" + else + echo "未安装" + fi +} + +# ---------- 列出可用版本 ---------- +list_versions() { + step "查询可用版本..." + local releases_url="${API_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json" + curl -sSL "$releases_url" | grep -o '"tag_name"[[:space:]]*:[[:space:]]*"[^"]*"' | sed 's/.*"\([^"]*\)"$/\1/' | head -10 +} + +# ---------- 回滚功能 ---------- +rollback_version() { + local target_version="$1" + + if [ -z "$target_version" ]; then + error "请指定要回滚到的版本" + exit 1 + fi + + step "回滚到版本: ${target_version}" + + # 重新安装指定版本 + VERSION="$target_version" + main +} + +# ---------- 卸载功能 ---------- +uninstall() { + step "卸载 ${BINARY_NAME}..." + + # 删除二进制 + if [ -f "${INSTALL_DIR}/${BINARY_NAME}" ]; then + if [ -w "${INSTALL_DIR}" ]; then + rm -f "${INSTALL_DIR}/${BINARY_NAME}" + info "已删除: ${INSTALL_DIR}/${BINARY_NAME}" + else + sudo rm -f "${INSTALL_DIR}/${BINARY_NAME}" + info "已删除: ${INSTALL_DIR}/${BINARY_NAME} (使用sudo)" + fi + fi + + # 删除skills + if [ -d "${SKILLS_DIR}" ]; then + rm -rf "${SKILLS_DIR}" + info "已删除: ${SKILLS_DIR}" + fi + + # 删除配置(可选) + echo -n "是否删除配置文件? [y/N] " + read -r response + if [[ "$response" =~ ^[Yy]$ ]]; then + rm -rf "$HOME/.config/gitlink-cli" + rm -rf "$HOME/.gitlink" + info "已删除配置文件" + fi + + info "卸载完成" + exit 0 +} + +# ---------- 下载二进制 ---------- +download_binary() { + local platform="$1" + local version="$2" + local os="${platform%/*}" + local arch="${platform#*/}" + local archive_name="${BINARY_NAME}_${version}_${os}_${arch}.tar.gz" + local download_url="${API_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${version}/assets/${archive_name}" + + info "下载: ${archive_name}" + debug "URL: ${download_url}" + + local tmpdir + tmpdir="$(mktemp -d)" + trap "rm -rf ${tmpdir}" EXIT + + if ! download_with_retry "${download_url}" "${tmpdir}/${archive_name}"; then + error "请确认以下版本已发布: ${version}" + error "发布页: ${API_BASE}/${REPO_OWNER}/${REPO_NAME}/releases" + exit 1 + fi + + step "解压..." + tar -xzf "${tmpdir}/${archive_name}" -C "${tmpdir}" + + local binary_path="${tmpdir}/${BINARY_NAME}" + if [ ! -f "${binary_path}" ]; then + # 可能在子目录中 + binary_path=$(find "${tmpdir}" -name "${BINARY_NAME}" -type f 2>/dev/null | head -1) + fi + + if [ ! -f "${binary_path}" ]; then + error "解压后未找到二进制文件" + exit 1 + fi + + chmod +x "${binary_path}" + + # ---------- 安装 ---------- + step "安装到 ${INSTALL_DIR}/${BINARY_NAME}" + + if [ ! -w "${INSTALL_DIR}" ]; then + warn "需要管理员权限写入 ${INSTALL_DIR}" + sudo mkdir -p "${INSTALL_DIR}" + sudo mv "${binary_path}" "${INSTALL_DIR}/${BINARY_NAME}" + sudo chmod +x "${INSTALL_DIR}/${BINARY_NAME}" + else + mkdir -p "${INSTALL_DIR}" + mv "${binary_path}" "${INSTALL_DIR}/${BINARY_NAME}" + fi + + info "二进制: ${INSTALL_DIR}/${BINARY_NAME}" +} + +# ---------- 安装 skills ---------- +install_skills() { + local version="$1" + step "安装 skills..." + + mkdir -p "${SKILLS_DIR}" + + local skills_url="${API_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${version}/assets/${BINARY_NAME}_${version}_skills.tar.gz" + local tmpdir + tmpdir="$(mktemp -d)" + + if download_with_retry "${skills_url}" "${tmpdir}/skills.tar.gz" 2>/dev/null; then + tar -xzf "${tmpdir}/skills.tar.gz" -C "${SKILLS_DIR}" 2>/dev/null || true + info "Skills 安装到: ${SKILLS_DIR}" + else + warn "Skills 包不可用(可稍后通过 gitlink-cli-install-skills 安装)" + fi + + rm -rf "${tmpdir}" +} + +# ---------- 验证安装 ---------- +verify() { + step "验证安装..." + if command -v "${BINARY_NAME}" >/dev/null 2>&1; then + info "安装成功! $("${BINARY_NAME}" version 2>/dev/null || echo "${BINARY_NAME}")" + elif [ -x "${INSTALL_DIR}/${BINARY_NAME}" ]; then + info "安装成功! $("${INSTALL_DIR}/${BINARY_NAME}" version 2>/dev/null || echo "${INSTALL_DIR}/${BINARY_NAME}")" + warn "请将 ${INSTALL_DIR} 添加到 PATH: export PATH=${INSTALL_DIR}:\$PATH" + else + error "安装验证失败" + exit 1 + fi +} + +# ---------- 显示帮助 ---------- +show_help() { + cat << EOF +GitLink CLI 安装脚本 + +用法: + curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash [选项] + +选项: + VERSION=0.1.0 指定版本 + INSTALL_DIR=/path 指定安装目录 + DEBUG=true 显示调试信息 + +环境变量: + INSTALL_DIR 安装目录(默认: auto,自动选择) + VERSION 版本(默认: latest) + +命令: + list 列出可用版本 + uninstall 卸载 + rollback VERSION 回滚到指定版本 + +示例: + # 安装最新版本 + curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash + + # 安装指定版本 + VERSION=0.1.0 curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash + + # 安装到用户目录 + INSTALL_DIR=$HOME/.local/bin curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash + + # 列出可用版本 + curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- list + + # 卸载 + curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/install.sh | bash -s -- uninstall +EOF +} + +# ---------- main ---------- +main() { + echo "" + echo " ╔══════════════════════════════════════╗" + echo " ║ GitLink CLI 一键安装 ║" + echo " ╚══════════════════════════════════════╝" + echo "" + + # 处理命令行参数 + case "${1:-}" in + list) + list_versions + exit 0 + ;; + uninstall) + INSTALL_DIR="${INSTALL_DIR:-$(select_install_dir)}" + uninstall + ;; + rollback) + rollback_version "$2" + ;; + help|--help|-h) + show_help + exit 0 + ;; + esac + + # 环境检测 + check_environment + + # 选择安装目录 + INSTALL_DIR=$(select_install_dir) + export INSTALL_DIR + + local platform + platform=$(detect_platform) + info "检测到平台: ${platform}" + + local version + if [ "${VERSION}" = "latest" ]; then + version=$(fetch_latest_version) + info "最新版本: ${version}" + else + version="${VERSION}" + info "指定版本: ${version}" + fi + + download_binary "${platform}" "${version}" + install_skills "${version}" + verify + + echo "" + info "快速开始:" + info " gitlink-cli auth login # 登录账号" + info " gitlink-cli --help # 查看所有命令" + info " gitlink-cli version # 查看版本信息" + echo "" +} + +main "$@" diff --git a/internal/auth/login.go b/internal/auth/login.go index 4b035e7..032f107 100644 --- a/internal/auth/login.go +++ b/internal/auth/login.go @@ -71,7 +71,7 @@ func Login(username, password string) (*LoginResult, error) { // Collect auth cookies from response (GitLink uses autologin_trustie for session persistence) var authCookies []string for _, cookie := range resp.Cookies() { - if cookie.Name == "autologin_trustie" || cookie.Name == "_educoder_session" { + if cookie.Name == "Authorization" || cookie.Name == "autologin_trustie" || cookie.Name == "_educoder_session" || cookie.Name == "autologin" || cookie.Name == "login" { authCookies = append(authCookies, cookie.Name+"="+cookie.Value) } } @@ -79,7 +79,7 @@ func Login(username, password string) (*LoginResult, error) { if len(authCookies) == 0 { if u, err := url.Parse(loginURL); err == nil { for _, cookie := range jar.Cookies(u) { - if cookie.Name == "autologin_trustie" || cookie.Name == "_educoder_session" { + if cookie.Name == "Authorization" || cookie.Name == "autologin_trustie" || cookie.Name == "_educoder_session" || cookie.Name == "autologin" || cookie.Name == "login" { authCookies = append(authCookies, cookie.Name+"="+cookie.Value) } } diff --git a/internal/client/client.go b/internal/client/client.go index d147d96..9dc465d 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -11,19 +11,23 @@ import ( "github.com/gitlink-org/gitlink-cli/internal/auth" "github.com/gitlink-org/gitlink-cli/internal/config" + clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" "github.com/gitlink-org/gitlink-cli/internal/output" ) type Client struct { - HTTP *http.Client - BaseURL string - Debug bool + HTTP *http.Client + BaseURL string + Debug bool + SkipJSONSuffix bool } type APIError struct { StatusCode int Code interface{} Message string + Kind clierrors.ErrorKind + Suggestion string } func (e *APIError) Error() string { @@ -44,14 +48,14 @@ func New() (*Client, error) { func (c *Client) Do(method, path string, body interface{}, query url.Values) (*output.Envelope, error) { // Append .json suffix if not already present (GitLink API convention) // Handle paths that may already contain query strings (e.g., /path?key=val) - if idx := strings.Index(path, "?"); idx != -1 { - basePath := path[:idx] - queryStr := path[idx:] - if !strings.HasSuffix(basePath, ".json") { + if c.shouldAppendJSONSuffix(path) { + if idx := strings.Index(path, "?"); idx != -1 { + basePath := path[:idx] + queryStr := path[idx:] path = basePath + ".json" + queryStr + } else { + path += ".json" } - } else if !strings.HasSuffix(path, ".json") { - path += ".json" } fullURL := c.BaseURL + path if query != nil && len(query) > 0 { @@ -98,10 +102,13 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o // Check HTTP-level errors if resp.StatusCode >= 400 { + info := lookupStatusInfo(resp.StatusCode) return nil, &APIError{ StatusCode: resp.StatusCode, Code: resp.StatusCode, Message: fmt.Sprintf("HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(respData))), + Kind: info.kind, + Suggestion: info.suggestion, } } @@ -123,11 +130,13 @@ func (c *Client) Do(method, path string, body interface{}, query url.Values) (*o } if statusCode != 0 && statusCode != 200 && statusCode != 1 { msg, _ := raw["message"].(string) - suggestion := suggestFix(int(statusCode)) - return output.ErrorEnvelope(int(statusCode), msg, suggestion), &APIError{ + info := lookupStatusInfo(int(statusCode)) + return output.ErrorEnvelope(int(statusCode), msg, info.suggestion), &APIError{ StatusCode: int(statusCode), Code: int(statusCode), Message: msg, + Kind: info.kind, + Suggestion: info.suggestion, } } } @@ -174,17 +183,64 @@ func (c *Client) Delete(path string, query url.Values) (*output.Envelope, error) return c.Do("DELETE", path, nil, query) } -func suggestFix(code int) string { - switch code { - case 401: - return "请先运行 gitlink-cli auth login 登录" - case 403: - return "权限不足,请确认账户权限或联系项目管理员" - case 404: - return "资源不存在,请检查 owner/repo/id 是否正确" - case 422: - return "参数校验失败,请检查请求参数" - default: - return "" +type statusInfo struct { + kind clierrors.ErrorKind + message string + suggestion string +} + +var statusMessages = map[int]statusInfo{ + -2: {clierrors.KindAuth, "未登录或 Token 已过期", + "运行 gitlink-cli auth login 重新登录,或检查 GITLINK_TOKEN 环境变量"}, + -1: {clierrors.KindInput, "参数校验失败", + "检查必填参数是否缺失、参数格式是否正确,运行 gitlink-cli <命令> --help 查看用法"}, + 0: {clierrors.KindUnknown, "操作失败", ""}, + // Standard HTTP codes + 401: {clierrors.KindAuth, "认证失败", + "运行 gitlink-cli auth login 登录,或检查 GITLINK_TOKEN 环境变量"}, + 403: {clierrors.KindForbidden, "权限不足", + "请确认账号有此仓库的访问权限,或联系项目管理员"}, + 404: {clierrors.KindNotFound, "资源不存在", + "检查 owner/repo/id 是否正确,资源可能已被删除"}, + 422: {clierrors.KindInput, "参数校验失败", + "检查请求参数格式,运行 gitlink-cli <命令> --help 查看用法"}, + 429: {clierrors.KindServer, "请求过于频繁", + "稍等片刻后重试"}, + 500: {clierrors.KindServer, "服务器内部错误", + "稍等后重试,如持续出现请联系平台管理员"}, + 502: {clierrors.KindServer, "网关错误", + "服务器暂时不可用,稍等后重试"}, + 503: {clierrors.KindServer, "服务暂时不可用", + "服务器正在维护,稍等后重试"}, +} + +func lookupStatusInfo(code int) statusInfo { + if info, ok := statusMessages[code]; ok { + return info + } + return statusInfo{ + kind: clierrors.KindUnknown, + message: fmt.Sprintf("API 返回错误码 %d", code), } } + +// shouldAppendJSONSuffix reports whether the .json suffix should be appended to path. +// Returns false (skip append) when: +// - c.SkipJSONSuffix is set (explicit opt-out for non-JSON endpoints such as gateway) +// - path already ends with .json +// - path matches the raw content pattern (e.g., /api/:owner/:repo/raw/...) +func (c *Client) shouldAppendJSONSuffix(path string) bool { + if c.SkipJSONSuffix { + return false + } + if strings.HasSuffix(path, ".json") { + return false + } + parts := strings.Split(strings.Trim(path, "/"), "/") + for i, part := range parts { + if part == "raw" && i >= 2 && i+2 < len(parts) { + return false + } + } + return true +} diff --git a/internal/config/config.go b/internal/config/config.go index e8ec429..885b3db 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,21 +8,27 @@ import ( ) const ( - DefaultBaseURL = "https://www.gitlink.org.cn/api" - DefaultFormat = "table" + DefaultBaseURL = "https://www.gitlink.org.cn/api" + DefaultGatewayBaseURL = "https://gateway.gitlink.org.cn/api" + DefaultFormat = "table" + + // EnvGatewayBaseURL overrides GatewayBaseURL when set. + EnvGatewayBaseURL = "GITLINK_GATEWAY_URL" ) type Config struct { - BaseURL string `yaml:"base_url"` - Format string `yaml:"default_format"` - Editor string `yaml:"editor,omitempty"` - Pager string `yaml:"pager,omitempty"` + BaseURL string `yaml:"base_url"` + GatewayBaseURL string `yaml:"gateway_base_url,omitempty"` + Format string `yaml:"default_format"` + Editor string `yaml:"editor,omitempty"` + Pager string `yaml:"pager,omitempty"` } func DefaultConfig() *Config { return &Config{ - BaseURL: DefaultBaseURL, - Format: DefaultFormat, + BaseURL: DefaultBaseURL, + GatewayBaseURL: DefaultGatewayBaseURL, + Format: DefaultFormat, } } @@ -53,6 +59,12 @@ func Load() (*Config, error) { if cfg.BaseURL == "" { cfg.BaseURL = DefaultBaseURL } + if cfg.GatewayBaseURL == "" { + cfg.GatewayBaseURL = DefaultGatewayBaseURL + } + if v := os.Getenv(EnvGatewayBaseURL); v != "" { + cfg.GatewayBaseURL = v + } if cfg.Format == "" { cfg.Format = DefaultFormat } @@ -79,6 +91,8 @@ func Get(key string) (string, error) { switch key { case "base_url": return cfg.BaseURL, nil + case "gateway_base_url": + return cfg.GatewayBaseURL, nil case "default_format": return cfg.Format, nil case "editor": @@ -98,6 +112,8 @@ func Set(key, value string) error { switch key { case "base_url": cfg.BaseURL = value + case "gateway_base_url": + cfg.GatewayBaseURL = value case "default_format": cfg.Format = value case "editor": diff --git a/internal/context/repo.go b/internal/context/repo.go index f3a7085..03ab176 100644 --- a/internal/context/repo.go +++ b/internal/context/repo.go @@ -17,7 +17,7 @@ func ResolveOwnerRepo(flagOwner, flagRepo string) (string, string, error) { owner, repo, err := fromGitRemote() if err != nil { if flagOwner == "" || flagRepo == "" { - return "", "", fmt.Errorf("cannot detect owner/repo from git remote: %w\nUse --owner and --repo flags to specify explicitly", err) + return "", "", fmt.Errorf("无法自动检测 owner/repo: %w\n 请使用 --owner 和 --repo 参数显式指定,或切换到 git 仓库目录下执行", err) } } @@ -59,11 +59,33 @@ func parseRemoteURL(remote string) (string, string, error) { } func parsePathSegments(path string) (string, string, error) { - path = strings.TrimPrefix(path, "/") + // 处理URL路径格式:可能包含域名前缀 + // 例如://www.gitlink.org.cn/zzx-coder/gitlink-cli 或 /zzx-coder/gitlink-cli + + // 先去掉域名部分(如果存在) + if strings.HasPrefix(path, "//www.gitlink.org.cn/") { + path = strings.TrimPrefix(path, "//www.gitlink.org.cn/") + } else if strings.HasPrefix(path, "//") { + // 处理其他可能的域名格式:找到第二个斜杠后的内容 + if idx := strings.Index(path[2:], "/"); idx != -1 { + path = path[2+idx+1:] + } else { + path = path[2:] + } + } else if strings.HasPrefix(path, "/") { + // 去掉单个前导斜杠 + path = strings.TrimPrefix(path, "/") + } + + // 去掉.git后缀 path = strings.TrimSuffix(path, ".git") - parts := strings.SplitN(path, "/", 3) + + // 现在应该得到 "zzx-coder/gitlink-cli" 格式 + parts := strings.Split(path, "/") if len(parts) < 2 { return "", "", fmt.Errorf("cannot extract owner/repo from path: %s", path) } + + // 第一个部分是owner,第二个是repo(可能还有更多部分但忽略) return parts[0], parts[1], nil } diff --git a/internal/errors/errors.go b/internal/errors/errors.go new file mode 100644 index 0000000..d239c9d --- /dev/null +++ b/internal/errors/errors.go @@ -0,0 +1,115 @@ +package errors + +import ( + "fmt" + "strings" +) + +// ErrorKind categorizes errors by user-actionability. +type ErrorKind string + +const ( + KindAuth ErrorKind = "auth" // Login/token issues — user can re-login + KindInput ErrorKind = "input" // Parameter issues — user can fix arguments + KindConfig ErrorKind = "config" // Config file issues — user can edit config + KindNetwork ErrorKind = "network" // Network issues — user can check/retry + KindGit ErrorKind = "git" // Git repo issues — user needs correct directory + KindServer ErrorKind = "server" // Server-side error — user should wait or contact admin + KindNotFound ErrorKind = "not_found" // Resource not found — user can check ID + KindForbidden ErrorKind = "forbidden" // Permission denied — user can request access + KindUnknown ErrorKind = "unknown" // Unclassified error +) + +// CLIError is the unified CLI error type with multi-layered information. +type CLIError struct { + Kind ErrorKind // Error category for programmatic handling + Message string // Human-readable description of what went wrong + Detail string // Low-level technical detail (shown in debug mode) + Suggestion string // Actionable advice for the user + Command string // The command that triggered the error (e.g., "issue +create") + Cause error // The underlying error +} + +func (e *CLIError) Error() string { + var b strings.Builder + + // Header line: kind + command + b.WriteString(string(e.Kind)) + b.WriteString(" error") + if e.Command != "" { + b.WriteString(" — ") + b.WriteString(e.Command) + } + + // Body: message + if e.Message != "" { + b.WriteString("\n\n reason: ") + b.WriteString(e.Message) + } + + // Suggestion + if e.Suggestion != "" { + b.WriteString("\n suggestion: ") + b.WriteString(e.Suggestion) + } + + // Detail (always included in Error() so users see the raw cause) + if e.Detail != "" { + b.WriteString("\n detail: ") + b.WriteString(e.Detail) + } + + return b.String() +} + +func (e *CLIError) Unwrap() error { + return e.Cause +} + +// New creates a CLIError with the given parameters. +func New(kind ErrorKind, message, suggestion string) *CLIError { + return &CLIError{ + Kind: kind, + Message: message, + Suggestion: suggestion, + } +} + +// Wrap creates a CLIError that wraps an underlying cause. +func Wrap(kind ErrorKind, message, suggestion string, cause error) *CLIError { + return &CLIError{ + Kind: kind, + Message: message, + Suggestion: suggestion, + Cause: cause, + Detail: cause.Error(), + } +} + +// WithCommand sets the command context on the error. +func (e *CLIError) WithCommand(cmd string) *CLIError { + e.Command = cmd + return e +} + +// InputError is a convenience constructor for parameter errors. +func InputError(message, suggestion string) *CLIError { + return New(KindInput, message, suggestion) +} + +// AuthError is a convenience constructor for authentication errors. +func AuthError(message, suggestion string) *CLIError { + return New(KindAuth, message, suggestion) +} + +// ConfigError creates a config-related error with the config file path in the suggestion. +func ConfigError(message string, cause error) *CLIError { + return Wrap(KindConfig, message, + fmt.Sprintf("检查配置文件 %s 是否正确", configPathPlaceholder()), cause) +} + +// configPathPlaceholder avoids circular import; the actual path will be resolved +// in output formatting. +func configPathPlaceholder() string { + return "~/.config/gitlink-cli/config.yaml" +} diff --git a/internal/output/formatter.go b/internal/output/formatter.go index dd0b59c..f523da8 100644 --- a/internal/output/formatter.go +++ b/internal/output/formatter.go @@ -71,6 +71,11 @@ func printTable(w io.Writer, envelope *Envelope) error { case []interface{}: return printSliceTable(w, data) case map[string]interface{}: + // GitLink API 经常返回 {"count":N, "items":[...]} 这种"包装列表" + // 优先解包内层 slice of maps,按列表渲染 + if unwrapped := unwrapSingleListField(data); unwrapped != nil { + return printSliceTable(w, unwrapped) + } // For maps with nested structures, prefer JSON if hasComplexValues(data) { return printJSON(w, envelope) @@ -82,6 +87,59 @@ func printTable(w io.Writer, envelope *Envelope) error { } } +// unwrapSingleListField 检测 map 是否为"包装列表"结构: +// 至少包含一个 []interface{}(元素为 map,或为空数组)字段。 +// 若是,返回该 slice(用于按列表渲染表格);否则返回 nil。 +// +// 优先选择已知列表字段名(projects/webhooks/issues 等), +// 若 map 中只有一个 slice of maps 字段,也直接使用。 +func unwrapSingleListField(m map[string]interface{}) []interface{} { + knownListFields := []string{ + "projects", "webhooks", "issues", "users", "pull_requests", + "builds", "releases", "branches", "teams", "members", + "orgs", "items", "records", "results", "wikis", "search", + } + + // 1. 优先选择已知字段名(即使数组为空,也接受 — 空数组也是合法列表) + for _, name := range knownListFields { + if s, ok := m[name].([]interface{}); ok { + if isSliceOfMaps(s) { + return s + } + } + } + + // 2. 兜底:检测是否只有一个 slice of maps 字段 + var listField string + var listValue []interface{} + for k, v := range m { + s, ok := v.([]interface{}) + if !ok { + continue + } + if !isSliceOfMaps(s) { + continue + } + if listField != "" { + // 多个列表字段,无法自动解包 + return nil + } + listField = k + listValue = s + } + return listValue +} + +// isSliceOfMaps 判断 slice 是否为 map 元素的列表 +// 空 slice 也算合法列表(用于在 printSliceTable 中触发 "No results" 输出) +func isSliceOfMaps(s []interface{}) bool { + if len(s) == 0 { + return true // 空数组视为列表,printSliceTable 会输出 "No results" + } + _, ok := s[0].(map[string]interface{}) + return ok +} + func hasComplexValues(m map[string]interface{}) bool { for _, v := range m { switch v.(type) { diff --git a/internal/output/formatter_test.go b/internal/output/formatter_test.go new file mode 100644 index 0000000..18890f3 --- /dev/null +++ b/internal/output/formatter_test.go @@ -0,0 +1,81 @@ +package output + +import ( + "bytes" + "strings" + "testing" +) + +func TestPrintTable_WrappedEmptyList(t *testing.T) { + env := &Envelope{OK: true, Data: map[string]interface{}{ + "count": 0, + "projects": []interface{}{}, + }} + var buf bytes.Buffer + if err := PrintTo(&buf, env, "table"); err != nil { + t.Fatalf("PrintTo failed: %v", err) + } + got := buf.String() + if !strings.Contains(got, "No results") { + t.Errorf("expected 'No results', got: %q", got) + } +} + +func TestPrintTable_WrappedList(t *testing.T) { + env := &Envelope{OK: true, Data: map[string]interface{}{ + "count": 2, + "projects": []interface{}{ + map[string]interface{}{"id": 1.0, "name": "alpha"}, + map[string]interface{}{"id": 2.0, "name": "beta"}, + }, + }} + var buf bytes.Buffer + if err := PrintTo(&buf, env, "table"); err != nil { + t.Fatalf("PrintTo failed: %v", err) + } + got := buf.String() + if !strings.Contains(got, "alpha") || !strings.Contains(got, "beta") { + t.Errorf("expected alpha/beta in output, got: %q", got) + } + if !strings.Contains(got, "id") || !strings.Contains(got, "name") { + t.Errorf("expected header id/name, got: %q", got) + } +} + +func TestUnwrapSingleListField_KnownName(t *testing.T) { + m := map[string]interface{}{ + "count": 2.0, + "projects": []interface{}{map[string]interface{}{"id": 1.0}}, + } + got := unwrapSingleListField(m) + if got == nil || len(got) != 1 { + t.Fatalf("expected slice len=1, got %v", got) + } +} + +func TestUnwrapSingleListField_MultipleUnknownListsReturnsNil(t *testing.T) { + // 两个未知名字的 list 字段 — 无法自动选择,返回 nil + m := map[string]interface{}{ + "foo_list": []interface{}{map[string]interface{}{"id": 1.0}}, + "bar_list": []interface{}{map[string]interface{}{"id": 2.0}}, + } + if got := unwrapSingleListField(m); got != nil { + t.Errorf("expected nil for multiple unknown list fields, got len=%d", len(got)) + } +} + +func TestUnwrapSingleListField_KnownNamePreferred(t *testing.T) { + // 已知 name 优先 — 即使有其他 list 字段也用 known name + m := map[string]interface{}{ + "projects": []interface{}{map[string]interface{}{"id": 1.0}}, + "users": []interface{}{map[string]interface{}{"id": 2.0}}, + } + got := unwrapSingleListField(m) + if got == nil || len(got) != 1 { + t.Fatalf("expected projects slice len=1, got %v", got) + } + first := got[0].(map[string]interface{}) + if first["id"] != 1.0 { + t.Errorf("expected projects[0].id=1, got %v", first["id"]) + } +} diff --git a/npm/bin/uninstall.js b/npm/bin/uninstall.js new file mode 100644 index 0000000..a36d310 --- /dev/null +++ b/npm/bin/uninstall.js @@ -0,0 +1,175 @@ +#!/usr/bin/env node +/** + * GitLink CLI 卸载命令 + * 用法: gitlink-cli-uninstall [--purge] + */ + +const { execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// 颜色输出 +const colors = { + green: '\x1b[32m', + yellow: '\x1b[33m', + red: '\x1b[31m', + cyan: '\x1b[36m', + blue: '\x1b[34m', + reset: '\x1b[0m' +}; + +function info(msg) { + console.log(`${colors.green}[INFO]${colors.reset} ${msg}`); +} + +function warn(msg) { + console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`); +} + +function error(msg) { + console.log(`${colors.red}[ERROR]${colors.reset} ${msg}`); +} + +function step(msg) { + console.log(`${colors.cyan}[STEP]${colors.reset} ${msg}`); +} + +function ask(msg) { + console.log(`${colors.blue}[ASK]${colors.reset} ${msg}`); +} + +// ---------- 删除文件/目录 ---------- +function removeSync(target) { + try { + if (fs.existsSync(target)) { + const stat = fs.statSync(target); + if (stat.isDirectory()) { + fs.rmdirSync(target, { recursive: true }); + return true; + } else { + fs.unlinkSync(target); + return true; + } + } + return false; + } catch (err) { + return false; + } +} + +// ---------- 删除Skills ---------- +function removeSkills() { + step('删除 Skills...'); + + const skillsDir = path.join(os.homedir(), '.gitlink', 'skills'); + + if (removeSync(skillsDir)) { + info('Skills已删除'); + } else { + warn('Skills目录不存在或删除失败'); + } +} + +// ---------- 删除配置 ---------- +function removeConfig(purgeAll = false) { + if (!purgeAll) { + info('保留配置文件'); + return; + } + + step('删除配置文件...'); + + const configPaths = [ + path.join(os.homedir(), '.gitlink-cli'), + path.join(os.homedir(), '.config', 'gitlink-cli'), + path.join(os.homedir(), '.gitlink') + ]; + + let removedCount = 0; + for (const configPath of configPaths) { + if (removeSync(configPath)) { + info(`已删除: ${configPath}`); + removedCount++; + } + } + + if (removedCount > 0) { + info('配置文件已删除'); + } +} + +// ---------- 主流程 ---------- +function main() { + const args = process.argv.slice(2); + const purgeAll = args.includes('--purge') || args.includes('-p'); + const help = args.includes('--help') || args.includes('-h'); + + if (help) { + console.log(''); + console.log('GitLink CLI 卸载命令'); + console.log(''); + console.log('用法: gitlink-cli-uninstall [选项]'); + console.log(''); + console.log('选项:'); + console.log(' --purge, -p 删除所有文件(包括配置)'); + console.log(' --help, -h 显示此帮助'); + console.log(''); + console.log('示例:'); + console.log(' gitlink-cli-uninstall # 保留配置'); + console.log(' gitlink-cli-uninstall --purge # 完全删除'); + console.log(''); + process.exit(0); + } + + console.log(''); + console.log('========================================'); + info('GitLink CLI 卸载'); + console.log('========================================'); + console.log(''); + + step('卸载npm包...'); + + try { + // 执行npm uninstall + if (process.platform === 'win32') { + execSync('npm uninstall -g @gitlink-ai/cli', { stdio: 'inherit' }); + } else { + execSync('npm uninstall -g @gitlink-ai/cli', { stdio: 'inherit' }); + } + } catch (err) { + warn('npm卸载命令执行失败,请手动运行: npm uninstall -g @gitlink-ai/cli'); + } + + // 删除skills + removeSkills(); + + // 删除配置 + removeConfig(purgeAll); + + console.log(''); + console.log('========================================'); + info('卸载完成!'); + console.log('========================================'); + console.log(''); + + if (!purgeAll) { + info('以下文件可能需要手动清理:'); + console.log(` - ${path.join(os.homedir(), '.gitlink-cli')}`); + console.log(` - ${path.join(os.homedir(), '.gitlink')}`); + console.log(''); + info('如需删除,请运行: gitlink-cli-uninstall --purge'); + } + + console.log(''); + info('感谢使用 GitLink CLI!'); + console.log(''); +} + +// 运行 +try { + main(); +} catch (err) { + error(`卸载失败: ${err.message}`); + process.exit(1); +} \ No newline at end of file diff --git a/npm/package.json b/npm/package.json index d4ab32a..0c5012c 100644 --- a/npm/package.json +++ b/npm/package.json @@ -1,13 +1,16 @@ { "name": "@gitlink-ai/cli", - "version": "0.1.13", + "version": "0.2.0", "description": "GitLink 平台官方命令行工具 — 代码托管、协作开发和自动化", "bin": { "gitlink-cli": "bin/cli.js", - "gitlink-cli-install-skills": "bin/install-skills.js" + "gitlink-cli-install-skills": "bin/install-skills.js", + "gitlink-cli-uninstall": "bin/uninstall.js" }, "scripts": { "postinstall": "node scripts/install.js", + "preuninstall": "node scripts/uninstall.js", + "uninstall": "node scripts/uninstall.js", "test": "node test/install.test.js && node test/cli.test.js" }, "keywords": [ diff --git a/npm/scripts/uninstall.js b/npm/scripts/uninstall.js new file mode 100644 index 0000000..4350bf8 --- /dev/null +++ b/npm/scripts/uninstall.js @@ -0,0 +1,162 @@ +#!/usr/bin/env node +/** + * GitLink CLI npm 卸载脚本 + * npm preuninstall 钩子自动运行 + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +// 颜色输出(支持跨平台) +const colors = { + green: '\x1b[32m', + yellow: '\x1b[33m', + red: '\x1b[31m', + cyan: '\x1b[36m', + blue: '\x1b[34m', + reset: '\x1b[0m' +}; + +function info(msg) { + console.log(`${colors.green}[INFO]${colors.reset} ${msg}`); +} + +function warn(msg) { + console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`); +} + +function error(msg) { + console.log(`${colors.red}[ERROR]${colors.reset} ${msg}`); +} + +function step(msg) { + console.log(`${colors.cyan}[STEP]${colors.reset} ${msg}`); +} + +// ---------- 删除文件/目录 ---------- +function removeSync(target) { + try { + if (fs.existsSync(target)) { + const stat = fs.statSync(target); + if (stat.isDirectory()) { + fs.rmdirSync(target, { recursive: true }); + info(`已删除目录: ${target}`); + } else { + fs.unlinkSync(target); + info(`已删除文件: ${target}`); + } + return true; + } + return false; + } catch (err) { + warn(`删除失败: ${target} - ${err.message}`); + return false; + } +} + +// ---------- 删除Skills ---------- +function removeSkills() { + step('删除 Skills...'); + + const skillsDir = path.join(os.homedir(), '.gitlink', 'skills'); + + if (!fs.existsSync(skillsDir)) { + warn('Skills目录不存在'); + return; + } + + // 统计skills数量 + let skillCount = 0; + try { + const items = fs.readdirSync(skillsDir); + skillCount = items.filter(item => { + const itemPath = path.join(skillsDir, item); + return fs.statSync(itemPath).isDirectory(); + }).length; + } catch (err) { + // 忽略错误 + } + + info(`找到 ${skillCount} 个Skills`); + + if (removeSync(skillsDir)) { + info('Skills已删除'); + } +} + +// ---------- 删除配置(可选)---------- +function removeConfig(purgeAll = false) { + if (!purgeAll) { + // npm卸载通常不删除配置 + info('保留配置文件(用户数据)'); + return; + } + + step('删除配置文件...'); + + const configPaths = [ + path.join(os.homedir(), '.gitlink-cli'), + path.join(os.homedir(), '.config', 'gitlink-cli'), + path.join(os.homedir(), '.gitlink') + ]; + + let removedCount = 0; + for (const configPath of configPaths) { + if (removeSync(configPath)) { + removedCount++; + } + } + + if (removedCount > 0) { + info('配置文件已删除'); + } else { + warn('未找到配置文件'); + } +} + +// ---------- 主流程 ---------- +function main() { + console.log(''); + console.log('========================================'); + info('GitLink CLI npm 卸载'); + console.log('========================================'); + console.log(''); + + // 检查环境变量 + const purgeAll = process.env.GITLINK_UNINSTALL_PURGE === 'true' || process.argv.includes('--purge'); + + step('开始清理npm安装的文件...'); + + // 删除skills + removeSkills(); + + // 删除配置(如果指定--purge) + removeConfig(purgeAll); + + console.log(''); + console.log('========================================'); + info('卸载完成!'); + console.log('========================================'); + console.log(''); + + if (!purgeAll) { + info('以下文件可能需要手动清理:'); + console.log(` - ${path.join(os.homedir(), '.gitlink-cli')}`); + console.log(` - ${path.join(os.homedir(), '.gitlink')}`); + console.log(''); + info('如需删除,请运行: npm uninstall -g @gitlink-ai/cli --purge'); + } + + console.log(''); + info('感谢使用 GitLink CLI!'); + console.log(''); +} + +// 运行 +try { + main(); +} catch (err) { + error(`卸载失败: ${err.message}`); + process.exit(1); +} \ No newline at end of file diff --git a/npm/scripts/update.js b/npm/scripts/update.js new file mode 100644 index 0000000..89a6e73 --- /dev/null +++ b/npm/scripts/update.js @@ -0,0 +1,429 @@ +#!/usr/bin/env node + +"use strict"; + +const os = require("os"); +const path = require("path"); +const fs = require("fs"); +const https = require("https"); +const http = require("http"); +const { execSync } = require("child_process"); +const PACKAGE = require("../package.json"); + +const VERSION = PACKAGE.version; +const BINARY_NAME = "gitlink-cli"; +const RELEASE_BASE = "https://www.gitlink.org.cn"; +const REPO_OWNER = "Gitlink"; +const REPO_NAME = "gitlink-cli"; + +// 颜色输出 +const colors = { + reset: "\x1b[0m", + green: "\x1b[32m", + yellow: "\x1b[33m", + red: "\x1b[31m", + cyan: "\x1b[36m", + blue: "\x1b[34m", +}; + +function info(msg) { + console.log(`${colors.green}[INFO]${colors.reset} ${msg}`); +} + +function warn(msg) { + console.log(`${colors.yellow}[WARN]${colors.reset} ${msg}`); +} + +function error(msg) { + console.log(`${colors.red}[ERROR]${colors.reset} ${msg}`); +} + +function step(msg) { + console.log(`${colors.cyan}[STEP]${colors.reset} ${msg}`); +} + +function debug(msg) { + if (process.env.DEBUG === "true") { + console.log(`${colors.blue}[DEBUG]${colors.reset} ${msg}`); + } +} + +function getPlatformInfo(platform = os.platform(), arch = os.arch()) { + const platformMap = { + darwin: "darwin", + linux: "linux", + win32: "windows", + }; + + const archMap = { + x64: "amd64", + arm64: "arm64", + }; + + const goPlatform = platformMap[platform]; + const goArch = archMap[arch]; + + if (!goPlatform || !goArch) { + throw new Error( + `Unsupported platform: ${platform}-${arch}. ` + + `Supported: darwin-x64, darwin-arm64, linux-x64, linux-arm64, win32-x64, win32-arm64` + ); + } + + return { platform: goPlatform, arch: goArch, isWindows: platform === "win32" }; +} + +function getBinaryName(platform) { + return platform === "windows" ? `${BINARY_NAME}.exe` : BINARY_NAME; +} + +function getArchiveName(platform, arch) { + const ext = platform === "windows" ? ".zip" : ".tar.gz"; + return `${BINARY_NAME}_${VERSION}_${platform}_${arch}${ext}`; +} + +function fetch(url, options = {}) { + return new Promise((resolve, reject) => { + const maxRedirects = options.maxRedirects || 5; + let redirectCount = 0; + + function doRequest(currentUrl) { + const mod = currentUrl.startsWith("https") ? https : http; + const req = mod.get(currentUrl, (res) => { + // Follow redirects + if ( + (res.statusCode === 301 || + res.statusCode === 302 || + res.statusCode === 307 || + res.statusCode === 308) && + res.headers.location + ) { + redirectCount++; + if (redirectCount > maxRedirects) { + reject(new Error(`Too many redirects (max ${maxRedirects})`)); + return; + } + let redirectUrl = res.headers.location; + if (redirectUrl.startsWith("/")) { + const parsed = new URL(currentUrl); + redirectUrl = `${parsed.protocol}//${parsed.host}${redirectUrl}`; + } + doRequest(redirectUrl); + return; + } + + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode} when downloading ${currentUrl}`)); + return; + } + + if (options.json) { + let body = ""; + res.on("data", (chunk) => (body += chunk)); + res.on("end", () => { + try { + resolve(JSON.parse(body)); + } catch (e) { + reject(e); + } + }); + } else { + res.pipe(resolve); + } + }); + + req.on("error", reject); + req.setTimeout(options.timeout || 30000, () => { + req.destroy(); + reject(new Error(`Request timeout: ${currentUrl}`)); + }); + } + + doRequest(url); + }); +} + +// 下载文件(带重试) +async function downloadFile(url, outputPath, maxAttempts = 3) { + let attempt = 1; + + while (attempt <= maxAttempts) { + try { + step(`下载 (尝试 ${attempt}/${maxAttempts}): ${path.basename(url)}`); + debug(`URL: ${url}`); + + await new Promise((resolve, reject) => { + const file = fs.createWriteStream(outputPath); + const mod = url.startsWith("https") ? https : http; + + const req = mod.get(url, (res) => { + if (res.statusCode !== 200) { + reject(new Error(`HTTP ${res.statusCode}`)); + return; + } + + const totalSize = parseInt(res.headers["content-length"], 10); + let downloadedSize = 0; + + res.on("data", (chunk) => { + downloadedSize += chunk.length; + if (totalSize) { + const progress = ((downloadedSize / totalSize) * 100).toFixed(1); + process.stdout.write(`\r下载进度: ${progress}%`); + } + }); + + res.pipe(file); + + file.on("finish", () => { + file.close(); + process.stdout.write("\r"); + resolve(); + }); + + file.on("error", (err) => { + fs.unlink(outputPath, () => {}); + reject(err); + }); + }); + + req.on("error", (err) => { + file.destroy(); + fs.unlink(outputPath, () => {}); + reject(err); + }); + + req.setTimeout(120000, () => { + req.destroy(); + file.destroy(); + fs.unlink(outputPath, () => {}); + reject(new Error("下载超时")); + }); + }); + + if (fs.existsSync(outputPath) && fs.statSync(outputPath).size > 0) { + const sizeMB = (fs.statSync(outputPath).size / (1024 * 1024)).toFixed(2); + info(`下载成功: ${path.basename(outputPath)} (${sizeMB}MB)`); + return true; + } else { + warn("下载文件为空"); + } + } catch (err) { + warn(`下载失败: ${err.message}`); + } + + if (attempt < maxAttempts) { + const waitTime = attempt * 2; + warn(`等待 ${waitTime}s 后重试...`); + await new Promise((resolve) => setTimeout(resolve, waitTime * 1000)); + } + + attempt++; + } + + error("下载失败,已尝试 ${maxAttempts} 次"); + return false; +} + +// 获取最新版本 +async function getLatestVersion() { + try { + step("检查更新..."); + const releasesUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases.json`; + const releases = await fetch(releasesUrl, { json: true, timeout: 30000 }); + + if (releases && releases.length > 0) { + return releases[0].tag_name; + } + } catch (err) { + debug(`获取版本失败: ${err.message}`); + } + + return null; +} + +// 检查是否有更新 +async function checkForUpdate() { + try { + const latestVersion = await getLatestVersion(); + + if (!latestVersion) { + info("无法获取最新版本信息"); + return; + } + + const currentVersion = VERSION.startsWith("v") ? VERSION : `v${VERSION}`; + const latest = latestVersion.startsWith("v") ? latestVersion : `v${latestVersion}`; + + info(`当前版本: ${currentVersion}`); + info(`最新版本: ${latest}`); + + if (currentVersion === latest) { + info("已经是最新版本"); + return; + } + + // 简单的版本比较 + if (latest > currentVersion) { + warn(`发现新版本: ${latest}`); + warn("运行 'npm update -g @gitlink-ai/cli' 更新"); + } else if (latest < currentVersion) { + info("当前版本比最新发布版本更新(开发版本)"); + } + } catch (err) { + debug(`检查更新失败: ${err.message}`); + } +} + +// 安装二进制 +async function installBinary() { + const platform = getPlatformInfo(); + info(`平台: ${platform.platform}-${platform.arch}`); + + const binaryName = getBinaryName(platform.platform); + const archiveName = getArchiveName(platform.platform, platform.arch); + const npmBinDir = path.dirname(process.execPath); + const installDir = path.join(npmBinDir, ".."); + + step(`安装二进制到: ${installDir}`); + + const version = VERSION.startsWith("v") ? VERSION : `v${VERSION}`; + const binaryUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${version}/assets/${archiveName}`; + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitlink-cli-")); + const archivePath = path.join(tmpDir, archiveName); + + try { + // 下载 + const success = await downloadFile(binaryUrl, archivePath); + if (!success) { + throw new Error("下载失败"); + } + + // 解压 + step("解压..."); + if (platform.isWindows) { + const AdmZip = require("adm-zip"); + const zip = new AdmZip(archivePath); + zip.extractAllTo(installDir, true); + } else { + const tar = require("tar"); + await tar.x({ + file: archivePath, + cwd: installDir, + strip: 1, + }); + } + + // 设置执行权限(Unix) + if (!platform.isWindows) { + const binaryPath = path.join(installDir, binaryName); + if (fs.existsSync(binaryPath)) { + fs.chmodSync(binaryPath, "755"); + } + } + + info(`二进制安装成功: ${binaryName}`); + } finally { + // 清理临时文件 + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +// 安装skills +async function installSkills() { + const version = VERSION.startsWith("v") ? VERSION : `v${VERSION}`; + const skillsArchive = `${BINARY_NAME}_${version}_skills.zip`; + const skillsUrl = `${RELEASE_BASE}/api/${REPO_OWNER}/${REPO_NAME}/releases/${version}/assets/${skillsArchive}`; + + const skillsDir = path.join(os.homedir(), ".gitlink", "skills"); + + if (!fs.existsSync(skillsDir)) { + fs.mkdirSync(skillsDir, { recursive: true }); + } + + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "gitlink-skills-")); + const archivePath = path.join(tmpDir, skillsArchive); + + try { + step("安装 skills..."); + + const success = await downloadFile(skillsUrl, archivePath); + if (!success) { + warn("Skills 包下载失败(可稍后手动安装)"); + return; + } + + // 解压 + const AdmZip = require("adm-zip"); + const zip = new AdmZip(archivePath); + zip.extractAllTo(skillsDir, true); + + info("Skills 安装成功"); + } catch (err) { + warn(`Skills 安装失败: ${err.message}`); + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } +} + +// 验证安装 +function verifyInstallation() { + step("验证安装..."; + + try { + const result = execSync("gitlink-cli version", { encoding: "utf8" }); + info(`安装成功! ${result.trim()}`); + return true; + } catch (err) { + warn("验证命令失败(可能需要重启终端)"); + return false; + } +} + +// 主函数 +async function main() { + console.log(""); + console.log(" ╔══════════════════════════════════════╗"); + console.log(" ║ GitLink CLI npm 安装脚本 ║"); + console.log(" ╚══════════════════════════════════════╝"); + console.log(""); + + try { + await installBinary(); + await installSkills(); + verifyInstallation(); + + console.log(""); + info("快速开始:"); + info(" gitlink-cli auth login # 登录账号"); + info(" gitlink-cli --help # 查看所有命令"); + info(" gitlink-cli version # 查看版本信息"); + console.log(""); + } catch (err) { + error(`安装失败: ${err.message}`); + process.exit(1); + } +} + +// 如果直接运行此脚本 +if (require.main === module || process.argv[1].endsWith("update.js")) { + // 检查更新 + if (process.argv.includes("--check")) { + (async () => { + await checkForUpdate(); + })(); + } else { + // 安装 + (async () => { + await main(); + })(); + } +} + +module.exports = { + main, + checkForUpdate, + installBinary, + installSkills, +}; diff --git a/output/knowledge-graph-2026-07-06.html b/output/knowledge-graph-2026-07-06.html new file mode 100644 index 0000000..80a8d74 --- /dev/null +++ b/output/knowledge-graph-2026-07-06.html @@ -0,0 +1,110 @@ + + + + + +科研知识图谱 — 2026-07-06 + + + + +
+

科研知识图谱

+
+ 关键词:LLM,Agent — + 仓库:4 个 — + 贡献者:0 人 — + 2026-07-06 +
+
+
+ +
+
4
仓库节点
+
0
贡献者节点
+
2
主题节点
+
3
关系边
+
N/A
最热仓库
+
+ +
+

知识图谱 — 力导向布局

+
+
+ +
+

热度排行榜

+ + + +
排名仓库热度语言Stars趋势
+
+ +
+ + + + + diff --git a/output/knowledge-graph-2026-07-06.json b/output/knowledge-graph-2026-07-06.json new file mode 100644 index 0000000..e286389 --- /dev/null +++ b/output/knowledge-graph-2026-07-06.json @@ -0,0 +1,51 @@ +{ + "metadata": { + "generated_at": "2026-07-06T11:51:35+08:00", + "search_keywords": [ + "LLM", + "Agent" + ], + "total_repos_scanned": 4, + "total_contributors_found": 0, + "total_edges_inferred": 3 + }, + "nodes": [ + { + "id": "topic:llm", + "type": "topic", + "label": "LLM\n", + "symbolSize": 30, + "category": 2 + }, + { + "id": "topic:agent", + "type": "topic", + "label": "Agent\n", + "symbolSize": 30, + "category": 2 + } + ], + "edges": [ + { + "source": "repo:agent", + "target": "repo:ribo-agent", + "type": "related_to", + "weight": 0.5, + "evidence": "共同主题: Agent\n" + }, + { + "source": "repo:doutrip", + "target": "repo:agent", + "type": "related_to", + "weight": 0.5, + "evidence": "共同主题: Agent\n" + }, + { + "source": "repo:ribo-agent", + "target": "repo:wow-agent", + "type": "related_to", + "weight": 0.5, + "evidence": "共同主题: Agent\n" + } + ] +} diff --git a/output/reproducibility-gitlink-cli-2026-07-06.html b/output/reproducibility-gitlink-cli-2026-07-06.html new file mode 100644 index 0000000..bbefc5d --- /dev/null +++ b/output/reproducibility-gitlink-cli-2026-07-06.html @@ -0,0 +1,170 @@ + + + + + +zzx-coder/gitlink-cli — 复现性评分卡 + + + + +
+

zzx-coder/gitlink-cli — 科研复现性评分卡

+
2026-07-06
+
+
+ +
+
+
F
+
5.0 / 100
+
+ + + + + 差 — 几乎不可复现 +
+
+
+

维度雷达图

+
+
+
+

维度明细

+ + + + + + + + + + +
维度评分权重
许可证0%15%
无密钥/PII0%15%
README 完整0%15%
依赖声明0%15%
构建说明50%10%
CI 配置0%10%
测试证据0%10%
数据可用性0%10%
+
+
+ +
+

详细评估与改进建议

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
维度评分证据建议
许可证未扫描(无本地仓库)建议添加 MIT/Apache-2.0/GPL-3.0 许可证
无密钥/PII⚠️未扫描(无本地仓库)立即移除泄露的密钥,使用环境变量管理敏感信息
README 完整README 缺失或过于简略补充项目目的、安装、使用、许可和引用章节
依赖声明无依赖声明添加 package.json/go.mod/requirements.txt 等标准依赖文件
构建说明⚠️部分构建说明添加 Makefile/Dockerfile + README 中的构建步骤
CI 配置无 CI 配置配置 GitLink CI 或 GitHub Actions 自动构建和测试
测试证据无测试证据添加单元测试和集成测试,在 README 中说明如何运行
数据可用性无数据可用性声明说明数据集来源,提供 Zenodo/Figshare 链接或生成脚本
+
+ +
+ + + + + diff --git a/output/research-insights-gitlink-cli-2026-07-06.html b/output/research-insights-gitlink-cli-2026-07-06.html new file mode 100644 index 0000000..cbc6c46 --- /dev/null +++ b/output/research-insights-gitlink-cli-2026-07-06.html @@ -0,0 +1,149 @@ + + + + + +zzx-coder/gitlink-cli — 科研项目洞察报告 + + + + +
+

zzx-coder/gitlink-cli

+
科研项目洞察报告 — 2026-07-06
+
+
+ +
+
+
热度评分
+
54.3
+
Hot
+
+
+
Stars
+
0
+
+
+
Forks
+
0
+
+
+
贡献者
+
3
+
+
+
开放 Issues
+
44
+
+
+
PR 合并率
+
50.0%
+
+
+ +
+
+

项目概况

+ + + + + + + + + + +
项目名称gitlink-cli
描述No description
主要语言Unknown
技术栈Unknown
创建时间
最后更新 (365 天前)
科研特征
+
+ +
+

活动概览

+
+
+
+ +
+
+

健康指标

+ + + + + + + + +
指标数值状态
Issue 总量44 开放 / 44 已关闭需关注
PR 合并率50.0%需改进
Release 数6已发布
CI 通过率0% (0 次构建)不稳定
贡献者数3 人单人项目
活跃度365 天前更新不活跃
+
+ +
+

热度构成

+
+
+
+ +
+ + + + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..28e1fcb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "gitlink-cli", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/pr-test-file.txt b/pr-test-file.txt deleted file mode 100644 index d848ff9..0000000 --- a/pr-test-file.txt +++ /dev/null @@ -1 +0,0 @@ -PR Test 2026年 4月 7日 星期二 11时45分56秒 CST diff --git a/shortcuts/branch/branch.go b/shortcuts/branch/branch.go index 37684b6..3f51635 100644 --- a/shortcuts/branch/branch.go +++ b/shortcuts/branch/branch.go @@ -25,7 +25,7 @@ func Shortcuts() []*common.Shortcut { q.Set("limit", ctx.Arg("limit")) env, err := ctx.CallAPIWithQuery("GET", "/v1"+ctx.RepoPath()+"/branches", q) if err != nil { - return err + return fmt.Errorf("获取分支列表失败: %w", err) } return ctx.Output(env) }, @@ -33,6 +33,15 @@ func Shortcuts() []*common.Shortcut { { Name: "create", Description: "Create a branch", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + name := ctx.Arg("name") + from := ctx.Arg("from") + if from == "" { + from = "master" + } + return fmt.Sprintf("Create branch: %s (from %s)", name, from), nil + }, Flags: []common.Flag{ {Name: "name", Short: "n", Usage: "Branch name", Required: true}, {Name: "from", Short: "f", Usage: "Source branch or commit", Default: "master"}, @@ -41,7 +50,10 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - name, _ := ctx.RequireArg("name") + name, err := ctx.RequireArg("name", "--name feature/new-thing") + if err != nil { + return err + } from := ctx.Arg("from") if from == "" { from = "master" @@ -52,7 +64,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches", payload) if err != nil { - return err + return fmt.Errorf("创建分支失败: %w", err) } return ctx.Output(env) }, @@ -60,6 +72,11 @@ func Shortcuts() []*common.Shortcut { { Name: "delete", Description: "Delete a branch", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + name := ctx.Arg("name") + return fmt.Sprintf("Delete branch: %s", name), nil + }, Flags: []common.Flag{ {Name: "name", Short: "n", Usage: "Branch name", Required: true}, }, @@ -67,13 +84,16 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - name, _ := ctx.RequireArg("name") + name, err := ctx.RequireArg("name", "--name feature/new-thing") + if err != nil { + return err + } payload := map[string]interface{}{ "branch_name": name, } env, err := ctx.CallAPI("POST", "/v1"+ctx.RepoPath()+"/branches/delete", payload) if err != nil { - return err + return fmt.Errorf("删除分支失败: %w", err) } return ctx.Output(env) }, @@ -81,6 +101,11 @@ func Shortcuts() []*common.Shortcut { { Name: "protect", Description: "Set branch protection", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + name := ctx.Arg("name") + return fmt.Sprintf("Protect branch: %s", name), nil + }, Flags: []common.Flag{ {Name: "name", Short: "n", Usage: "Branch name", Required: true}, }, @@ -88,13 +113,16 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - name, _ := ctx.RequireArg("name") + name, err := ctx.RequireArg("name", "--name feature/new-thing") + if err != nil { + return err + } payload := map[string]interface{}{ "branch_name": name, } env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/protected_branches", payload) if err != nil { - return err + return fmt.Errorf("设置分支保护失败: %w", err) } return ctx.Output(env) }, @@ -102,6 +130,11 @@ func Shortcuts() []*common.Shortcut { { Name: "unprotect", Description: "Remove branch protection", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + name := ctx.Arg("name") + return fmt.Sprintf("Unprotect branch: %s", name), nil + }, Flags: []common.Flag{ {Name: "name", Short: "n", Usage: "Branch name", Required: true}, }, @@ -109,11 +142,14 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - name, _ := ctx.RequireArg("name") - env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil) + name, err := ctx.RequireArg("name", "--name feature/new-thing") if err != nil { return err } + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/protected_branches/%s", ctx.RepoPath(), url.PathEscape(name)), nil) + if err != nil { + return fmt.Errorf("取消分支保护失败: %w", err) + } return ctx.Output(env) }, }, diff --git a/shortcuts/ci/ci.go b/shortcuts/ci/ci.go index eb15ad3..eb14639 100644 --- a/shortcuts/ci/ci.go +++ b/shortcuts/ci/ci.go @@ -25,7 +25,7 @@ func Shortcuts() []*common.Shortcut { q.Set("limit", ctx.Arg("limit")) env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/builds", q) if err != nil { - return err + return fmt.Errorf("获取 CI 构建列表失败: %w", err) } return ctx.Output(env) }, @@ -42,7 +42,10 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - build, _ := ctx.RequireArg("build") + build, err := ctx.RequireArg("build", "--build 42") + if err != nil { + return err + } stage := ctx.Arg("stage") step := ctx.Arg("step") if stage == "" { @@ -53,7 +56,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/builds/%s/logs/%s/%s", ctx.RepoPath(), build, stage, step), nil) if err != nil { - return err + return fmt.Errorf("获取构建日志失败: %w", err) } return ctx.Output(env) }, @@ -61,6 +64,11 @@ func Shortcuts() []*common.Shortcut { { Name: "restart", Description: "Restart a build", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + build := ctx.Arg("build") + return fmt.Sprintf("Restart build #%s", build), nil + }, Flags: []common.Flag{ {Name: "build", Short: "b", Usage: "Build number", Required: true}, }, @@ -68,17 +76,25 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - build, _ := ctx.RequireArg("build") - env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/builds/%s/restart", ctx.RepoPath(), build), nil) + build, err := ctx.RequireArg("build", "--build 42") if err != nil { return err } + env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/builds/%s/restart", ctx.RepoPath(), build), nil) + if err != nil { + return fmt.Errorf("重启构建失败: %w", err) + } return ctx.Output(env) }, }, { Name: "stop", Description: "Stop a build", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + build := ctx.Arg("build") + return fmt.Sprintf("Stop build #%s", build), nil + }, Flags: []common.Flag{ {Name: "build", Short: "b", Usage: "Build number", Required: true}, }, @@ -86,11 +102,14 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - build, _ := ctx.RequireArg("build") - env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil) + build, err := ctx.RequireArg("build", "--build 42") if err != nil { return err } + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/builds/%s/stop", ctx.RepoPath(), build), nil) + if err != nil { + return fmt.Errorf("停止构建失败: %w", err) + } return ctx.Output(env) }, }, diff --git a/shortcuts/common/error_print.go b/shortcuts/common/error_print.go new file mode 100644 index 0000000..bbb8a5e --- /dev/null +++ b/shortcuts/common/error_print.go @@ -0,0 +1,68 @@ +package common + +import ( + "errors" + + "github.com/gitlink-org/gitlink-cli/internal/client" + clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" + "github.com/gitlink-org/gitlink-cli/internal/output" +) + +// TryPrintError 尝试把 error 转换为 envelope 格式并输出到 stdout。 +// 返回 true 表示已识别并处理;false 表示该 error 类型不被识别,应由调用方走原有路径。 +// +// 设计意图:让 shortcut 的错误处理路径与 api 命令对齐, +// 使 `--format json` 输出可被 jq 解析的标准 envelope: +// +// {"ok":false, "error":{"code":N, "message":"...", "suggestion":"..."}} +// +// 支持的 error 类型: +// - *client.APIError : HTTP 错误(404/403/401 等),code = HTTP 状态码 +// - *clierrors.CLIError : 业务级错误(输入/认证/网络等),code 由 kind 映射 +// +// 注:放在 common 包(而非 output 包)以避免与 client 包形成导入循环。 +func TryPrintError(err error, format string) bool { + if err == nil { + return false + } + + var apiErr *client.APIError + if errors.As(err, &apiErr) { + env := output.ErrorEnvelope(apiErr.Code, apiErr.Message, apiErr.Suggestion) + _ = output.Print(env, format) + return true + } + + var cliErr *clierrors.CLIError + if errors.As(err, &cliErr) { + env := output.ErrorEnvelope(kindToCode(cliErr.Kind), cliErr.Message, cliErr.Suggestion) + _ = output.Print(env, format) + return true + } + + return false +} + +// kindToCode 把 CLIError.Kind 映射到近似的 HTTP 状态码,用于 envelope.error.code 字段 +func kindToCode(kind clierrors.ErrorKind) int { + switch kind { + case clierrors.KindAuth: + return 401 + case clierrors.KindInput: + return 400 + case clierrors.KindNotFound: + return 404 + case clierrors.KindForbidden: + return 403 + case clierrors.KindNetwork: + return 503 + case clierrors.KindServer: + return 500 + case clierrors.KindConfig: + return 500 + case clierrors.KindGit: + return 500 + default: + return 500 + } +} diff --git a/shortcuts/common/runner.go b/shortcuts/common/runner.go index 056b595..9872e0b 100644 --- a/shortcuts/common/runner.go +++ b/shortcuts/common/runner.go @@ -1,9 +1,13 @@ package common import ( + "fmt" + "os" "strconv" "github.com/spf13/cobra" + + "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" ) // MountShortcut converts a Shortcut into a cobra.Command and adds it as a subcommand. @@ -26,33 +30,70 @@ func MountShortcut(parent *cobra.Command, s *Shortcut) { } } - ctx, err := NewRuntimeContext(flagValues) + commandName := parent.Use + " +" + s.Name + ctx, err := NewRuntimeContext(flagValues, commandName) if err != nil { return err } - return s.Run(ctx) + // Dry-run interception + if s.DryRun { + if dryRunVal, _ := cmd.Flags().GetBool("dry-run"); dryRunVal { + flagValues["dry-run"] = "true" + if s.DryRunHint != nil { + hint, err := s.DryRunHint(ctx) + if err != nil { + return err + } + fmt.Fprintf(os.Stderr, "[dry-run] %s\n", hint) + } + proceed, err := ConfirmAction(ctx) + if err != nil { + return err + } + if !proceed { + return nil + } + } + } + + err = s.Run(ctx) + if err != nil { + // 当错误为已识别的 API/CLI 错误时,按 envelope 格式输出到 stdout, + // 让 `--format json` 输出可被 jq 解析的标准结构。 + // 已识别后返回 ErrSilent:保留非零退出码,但 cmd.Execute 不会再 stderr 重复输出。 + if TryPrintError(err, ctx.Format) { + return cmdutil.ErrSilent + } + } + return err }, } for _, f := range s.Flags { + usage := f.Usage + if f.Required { + usage = usage + " [required]" + } if f.Bool { defaultValue, _ := strconv.ParseBool(f.Default) if f.Short != "" { - cmd.Flags().BoolP(f.Name, f.Short, defaultValue, f.Usage) + cmd.Flags().BoolP(f.Name, f.Short, defaultValue, usage) } else { - cmd.Flags().Bool(f.Name, defaultValue, f.Usage) + cmd.Flags().Bool(f.Name, defaultValue, usage) } } else if f.Short != "" { - cmd.Flags().StringP(f.Name, f.Short, f.Default, f.Usage) + cmd.Flags().StringP(f.Name, f.Short, f.Default, usage) } else { - cmd.Flags().String(f.Name, f.Default, f.Usage) - } - if f.Required { - cmd.MarkFlagRequired(f.Name) + cmd.Flags().String(f.Name, f.Default, usage) } } + // Auto-register --dry-run flag for shortcuts that support it + if s.DryRun { + cmd.Flags().Bool("dry-run", false, "Preview the operation without executing") + } + parent.AddCommand(cmd) } diff --git a/shortcuts/common/types.go b/shortcuts/common/types.go index 15441c9..17e0bd0 100644 --- a/shortcuts/common/types.go +++ b/shortcuts/common/types.go @@ -1,13 +1,19 @@ package common import ( + "bufio" "encoding/json" "fmt" + "net/http" "net/url" + "os" + "strings" "github.com/gitlink-org/gitlink-cli/cmd/cmdutil" "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/internal/config" "github.com/gitlink-org/gitlink-cli/internal/context" + clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" "github.com/gitlink-org/gitlink-cli/internal/output" ) @@ -16,6 +22,8 @@ type Shortcut struct { Name string Description string Flags []Flag + DryRun bool // 是否支持 dry-run + DryRunHint func(ctx *RuntimeContext) (string, error) // 返回预览描述 Run func(ctx *RuntimeContext) error } @@ -31,15 +39,18 @@ type Flag struct { // RuntimeContext provides helpers for shortcut implementations. type RuntimeContext struct { - Client *client.Client - Owner string - Repo string - Format string - Args map[string]string + Client *client.Client + Owner string + Repo string + Format string + CommandName string + Args map[string]string + GatewayBaseURL string + GatewayHTTPClient *http.Client // optional; nil = use auth.NewHTTPClient (mainly for tests) } // NewRuntimeContext creates a RuntimeContext with auto-resolved owner/repo. -func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) { +func NewRuntimeContext(args map[string]string, commandName string) (*RuntimeContext, error) { cli, err := client.New() if err != nil { return nil, err @@ -51,12 +62,20 @@ func NewRuntimeContext(args map[string]string) (*RuntimeContext, error) { format = "json" } + gatewayBaseURL := config.DefaultGatewayBaseURL + if cfg, err := config.Load(); err == nil && cfg.GatewayBaseURL != "" { + gatewayBaseURL = cfg.GatewayBaseURL + } + return &RuntimeContext{ - Client: cli, - Owner: cmdutil.Owner, - Repo: cmdutil.Repo, - Format: format, - Args: args, + Client: cli, + Owner: cmdutil.Owner, + Repo: cmdutil.Repo, + Format: format, + CommandName: commandName, + Args: args, + GatewayBaseURL: gatewayBaseURL, + GatewayHTTPClient: nil, }, nil } @@ -109,11 +128,39 @@ func (ctx *RuntimeContext) Arg(name string) string { return "" } -// RequireArg returns a flag value or an error if not set. -func (ctx *RuntimeContext) RequireArg(name string) (string, error) { +// RequireArg returns a flag value or a CLIError if not set. +func (ctx *RuntimeContext) RequireArg(name, example string) (string, error) { v := ctx.Arg(name) if v == "" { - return "", fmt.Errorf("required flag --%s is missing", name) + suggestion := fmt.Sprintf("请提供 --%s 参数", name) + if example != "" { + suggestion += fmt.Sprintf(",例如:%s", example) + } + return "", clierrors.InputError( + fmt.Sprintf("required flag --%s is missing", name), + suggestion, + ).WithCommand(ctx.CommandName) } return v, nil } + +// IsDryRun checks the --dry-run flag. +func (ctx *RuntimeContext) IsDryRun() bool { + return ctx.Arg("dry-run") == "true" +} + +// ConfirmAction prompts the user for confirmation when --dry-run is set. +func ConfirmAction(ctx *RuntimeContext) (bool, error) { + if !ctx.IsDryRun() { + return true, nil + } + fmt.Fprint(os.Stderr, "\nProceed? [y/N] ") + reader := bufio.NewReader(os.Stdin) + answer, _ := reader.ReadString('\n') + answer = strings.TrimSpace(strings.ToLower(answer)) + if answer == "y" || answer == "yes" { + return true, nil + } + fmt.Fprintln(os.Stderr, "Aborted.") + return false, nil +} diff --git a/shortcuts/compliance/compliance.go b/shortcuts/compliance/compliance.go new file mode 100644 index 0000000..9143b9b --- /dev/null +++ b/shortcuts/compliance/compliance.go @@ -0,0 +1,122 @@ +package compliance + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "scan", + Description: "Full compliance scan (all five modules)", + Flags: []common.Flag{ + {Name: "module", Short: "m", Usage: "Comma-separated modules: license,deps,secrets,exposure,vocab"}, + }, + Run: runScan, + }, + {Name: "license", Description: "License compliance check", Run: runLicense}, + {Name: "deps", Description: "Dependency license check", Run: runDeps}, + {Name: "secrets", Description: "Hardcoded secrets scan", Run: runSecrets}, + {Name: "exposure", Description: "PII and network exposure scan", Run: runExposure}, + {Name: "vocab", Description: "Sensitive vocabulary scan", Run: runVocab}, + } +} + +func repoRoot() (string, error) { + dir, err := os.Getwd() + if err != nil { + return "", fmt.Errorf("get working directory: %w", err) + } + // find git root + for { + if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { + return dir, nil + } + parent := filepath.Dir(dir) + if parent == dir { + return dir, nil // fallback to cwd + } + dir = parent + } +} + +// runScan is the full scan (all modules). +func runScan(ctx *common.RuntimeContext) error { + root, err := repoRoot() + if err != nil { + return err + } + + selected := ctx.Arg("module") + var modules []string + if selected != "" { + modules = parseModules(selected) + } else { + modules = []string{"secrets", "exposure", "vocab"} + } + + // license and deps checks + var allFindings []Finding + for _, m := range modules { + switch m { + case "license": + allFindings = append(allFindings, checkLicense(root)...) + case "deps": + allFindings = append(allFindings, checkDeps(root)...) + case "secrets", "exposure", "vocab": + rules := allRules()[m] + allFindings = append(allFindings, scanFiles(root, rules)...) + } + } + + return outputReport(ctx, allFindings, modules) +} + +func runLicense(ctx *common.RuntimeContext) error { + root, _ := repoRoot() + findings := checkLicense(root) + return outputReport(ctx, findings, []string{"license"}) +} + +func runDeps(ctx *common.RuntimeContext) error { + root, _ := repoRoot() + findings := checkDeps(root) + return outputReport(ctx, findings, []string{"deps"}) +} + +func runSecrets(ctx *common.RuntimeContext) error { + root, _ := repoRoot() + findings := scanFiles(root, allRules()["secrets"]) + return outputReport(ctx, findings, []string{"secrets"}) +} + +func runExposure(ctx *common.RuntimeContext) error { + root, _ := repoRoot() + findings := scanFiles(root, allRules()["exposure"]) + return outputReport(ctx, findings, []string{"exposure"}) +} + +func runVocab(ctx *common.RuntimeContext) error { + root, _ := repoRoot() + findings := scanFiles(root, allRules()["vocab"]) + return outputReport(ctx, findings, []string{"vocab"}) +} + +func parseModules(s string) []string { + var result []string + seen := map[string]bool{} + for _, m := range strings.Split(s, ",") { + m = strings.TrimSpace(m) + valid := map[string]bool{"license": true, "deps": true, "secrets": true, "exposure": true, "vocab": true} + if valid[m] && !seen[m] { + result = append(result, m) + seen[m] = true + } + } + return result +} diff --git a/shortcuts/compliance/license.go b/shortcuts/compliance/license.go new file mode 100644 index 0000000..2a5413c --- /dev/null +++ b/shortcuts/compliance/license.go @@ -0,0 +1,188 @@ +package compliance + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +// checkLicense performs static license compliance checks (no regex scanning needed). +func checkLicense(root string) []Finding { + var findings []Finding + + // L-001: LICENSE file existence + files := []string{"LICENSE", "LICENSE.md", "LICENSE.txt"} + found := false + for _, name := range files { + if _, err := os.Stat(filepath.Join(root, name)); err == nil { + found = true + break + } + } + if !found { + findings = append(findings, Finding{ + ID: "L-001", Severity: "medium", Module: "license", + File: "-", Line: 0, + Summary: "缺少 LICENSE 文件", + }) + } + + // L-002: check npm/package.json license vs root LICENSE + rootLicense := detectLicense(root) + npmLicense := detectNpmLicense(root) + if rootLicense != "" && npmLicense != "" && !strings.EqualFold(rootLicense, npmLicense) { + findings = append(findings, Finding{ + ID: "L-002", Severity: "medium", Module: "license", + File: "npm/package.json", Line: 1, + Summary: fmt.Sprintf("许可证声明不一致:根 LICENSE 为 %s,npm/package.json 声明 %s", rootLicense, npmLicense), + }) + } + + // L-004: placeholder check in LICENSE file + for _, name := range files { + path := filepath.Join(root, name) + if f, err := os.Open(path); err == nil { + sc := bufio.NewScanner(f) + line := 0 + for sc.Scan() { + line++ + t := sc.Text() + if strings.Contains(t, "[year]") || strings.Contains(t, "[Year]") || + strings.Contains(t, "[name of copyright holder]") || strings.Contains(t, "[yyyy]") { + findings = append(findings, Finding{ + ID: "L-004", Severity: "low", Module: "license", + File: name, Line: line, + Summary: "LICENSE 中占位符未填写([Year] / [name of copyright holder])", + }) + break + } + } + f.Close() + break + } + } + + return findings +} + +func detectLicense(root string) string { + for _, name := range []string{"LICENSE", "LICENSE.md", "LICENSE.txt"} { + path := filepath.Join(root, name) + data, err := os.ReadFile(path) + if err != nil { + continue + } + text := string(data) + switch { + case strings.Contains(text, "Mulan Permissive Software License"): + return "MulanPSL-2.0" + case strings.Contains(text, "Apache License") && strings.Contains(text, "Version 2.0"): + return "Apache-2.0" + case strings.Contains(text, "MIT License") || strings.Contains(text, "Permission is hereby granted, free of charge"): + return "MIT" + case strings.Contains(text, "GNU AFFERO GENERAL PUBLIC LICENSE"): + return "AGPL-3.0" + case strings.Contains(text, "GNU GENERAL PUBLIC LICENSE") && strings.Contains(text, "Version 3"): + return "GPL-3.0" + case strings.Contains(text, "GNU GENERAL PUBLIC LICENSE") && strings.Contains(text, "Version 2"): + return "GPL-2.0" + case strings.Contains(text, "GNU LESSER GENERAL PUBLIC LICENSE"): + return "LGPL" + case strings.Contains(text, "BSD") && strings.Count(text, "Redistribution") >= 3: + return "BSD-3-Clause" + case strings.Contains(text, "BSD"): + return "BSD-2-Clause" + case strings.Contains(text, "Mozilla Public License"): + return "MPL-2.0" + default: + return "unknown" + } + } + return "" +} + +func detectNpmLicense(root string) string { + path := filepath.Join(root, "npm", "package.json") + data, err := os.ReadFile(path) + if err != nil { + return "" + } + // simple string search for "license": "xxx" + for _, line := range strings.Split(string(data), "\n") { + if strings.Contains(line, "\"license\"") { + line = strings.TrimSpace(line) + // "license": "Apache-2.0", + parts := strings.SplitN(line, ":", 2) + if len(parts) == 2 { + v := strings.TrimSpace(parts[1]) + v = strings.Trim(v, "\",") + return v + } + } + } + return "" +} + +// checkDeps inspects go.mod for copyleft dependencies. +func checkDeps(root string) []Finding { + var findings []Finding + path := filepath.Join(root, "go.mod") + f, err := os.Open(path) + if err != nil { + // no go.mod — not a Go project + return nil + } + defer f.Close() + + // GPL/AGPL keywords in module names + copyleft := []string{"gpl", "agpl", "gnu"} + sc := bufio.NewScanner(f) + line := 0 + for sc.Scan() { + line++ + text := strings.ToLower(sc.Text()) + if !strings.Contains(text, "require") && !strings.Contains(text, "require") { + continue + } + // Check lines after "require" block until blank + } + f.Close() + + // re-read go.mod and check require block + data, err := os.ReadFile(path) + if err != nil { + return nil + } + lines := strings.Split(string(data), "\n") + inRequire := false + for _, l := range lines { + trimmed := strings.TrimSpace(l) + if strings.HasPrefix(trimmed, "require") && !strings.Contains(trimmed, "// indirect") { + inRequire = true + continue + } + if inRequire && trimmed == "" { + break + } + if inRequire && strings.HasPrefix(trimmed, ")") { + break + } + if inRequire { + lower := strings.ToLower(trimmed) + for _, kw := range copyleft { + if strings.Contains(lower, kw) { + findings = append(findings, Finding{ + ID: "D-003", Severity: "high", Module: "deps", + File: "go.mod", Line: 0, + Summary: fmt.Sprintf("Copyleft 依赖风险: %s", trimmed), + }) + break + } + } + } + } + + return findings +} diff --git a/shortcuts/compliance/report.go b/shortcuts/compliance/report.go new file mode 100644 index 0000000..dcdaf24 --- /dev/null +++ b/shortcuts/compliance/report.go @@ -0,0 +1,77 @@ +package compliance + +import ( + "fmt" + "strings" + + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// summary holds aggregated stats. +type summary struct { + Total int `json:"total"` + Critical int `json:"critical"` + High int `json:"high"` + Medium int `json:"medium"` + Low int `json:"low"` +} + +type reportData struct { + Modules []string `json:"modules"` + Findings []Finding `json:"findings"` + Summary summary `json:"summary"` +} + +func outputReport(ctx *common.RuntimeContext, findings []Finding, modules []string) error { + s := summary{} + for _, f := range findings { + s.Total++ + switch f.Severity { + case "critical": s.Critical++ + case "high": s.High++ + case "medium": s.Medium++ + case "low": s.Low++ + } + } + + if ctx.Format == "json" { + return ctx.OutputData(reportData{Modules: modules, Findings: findings, Summary: s}) + } + + // human-readable output + fmt.Println() + printHR() + fmt.Printf(" Compliance Scan Report\n") + fmt.Printf(" Modules: %s | Findings: %d (critical:%d high:%d medium:%d low:%d)\n", + strings.Join(modules, ", "), s.Total, s.Critical, s.High, s.Medium, s.Low) + printHR() + + if len(findings) == 0 { + fmt.Println(" All clear — no issues found.") + } else { + printFindings(findings) + } + printHR() + return nil +} + +func printHR() { + fmt.Println(strings.Repeat("─", 60)) +} + +func printFindings(findings []Finding) { + labels := map[string]string{ + "critical": "CRIT", "high": "HIGH", "medium": "MED", "low": "LOW", + } + for _, f := range findings { + label := labels[f.Severity] + if label == "" { + label = f.Severity + } + fmt.Printf(" [%s] %s %s:%d %s\n", label, f.ID, f.File, f.Line, f.Summary) + } +} + +// Ensure output import is used +var _ = output.SuccessEnvelope diff --git a/shortcuts/compliance/rules.go b/shortcuts/compliance/rules.go new file mode 100644 index 0000000..b183ba2 --- /dev/null +++ b/shortcuts/compliance/rules.go @@ -0,0 +1,58 @@ +package compliance + +import "regexp" + +// allRules returns the complete set of scan rules grouped by module. +func allRules() map[string][]scanRule { + return map[string][]scanRule{ + "secrets": secretRules(), + "exposure": exposureRules(), + "vocab": vocabRules(), + } +} + +func compileRE(expr string) *regexp.Regexp { + return regexp.MustCompile(expr) +} + +// ----- secrets (S-001 ~ S-010) ----- +func secretRules() []scanRule { + return []scanRule{ + {SID("001"), "high", compileRE(`(?i)access_token|private_token`), "Token 作为 URL 查询参数泄露风险", nil}, + {SID("002"), "critical", compileRE(`(?i)password\s*[:=]\s*"[^"]+"`), "硬编码密码", nil}, + {SID("003"), "critical", compileRE(`(?i)api[_-]?key\s*[:=]\s*"[a-zA-Z0-9_-]{8,}"`), "硬编码 API Key", nil}, + {SID("004"), "critical", compileRE(`BEGIN.*PRIVATE KEY`), "私钥文件内容", []string{"*"}}, + {SID("005"), "high", compileRE(`token\s*[:=]\s*"[A-Za-z0-9+/=_-]{32,}"`), "长 Token 硬编码", nil}, + {SID("006"), "high", compileRE(`(?i)secret\s*[:=]\s*"[^"]{8,}"`), "Secret 硬编码", nil}, + {SID("008"), "medium", compileRE(`(?i)(fmt|log)\.(Print|Debug|Info).*[Tt]oken`), "Debug 输出可能泄露 Token", []string{"*.go"}}, + {SID("010"), "high", compileRE(`(?i)(mongodb|mysql|postgres|redis)://[^@]*@`), "数据库连接串含凭据", nil}, + } +} + +// ----- exposure (P-001 ~ E-005) ----- +func exposureRules() []scanRule { + return []scanRule{ + {PID("001"), "low", compileRE(`[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}`), "邮箱地址泄露", nil}, + {PID("002"), "low", compileRE(`\b1[3-9]\d{9}\b`), "手机号泄露", nil}, + {EID("001"), "medium", compileRE(`\b(10\.\d+\.\d+\.\d+|172\.(1[6-9]|2\d|3[01])\.\d+\.\d+|192\.168\.\d+\.\d+)\b`), "内网 IP 暴露", nil}, + {EID("002"), "low", compileRE(`(localhost|127\.0\.0\.1):\d+`), "本地开发地址残留", nil}, + {EID("003"), "low", compileRE(`\b\w+\.(local|internal|test)\b`), "内部域名暴露", nil}, + } +} + +// ----- vocab (C-001 ~ C-006) ----- +func vocabRules() []scanRule { + return []scanRule{ + {CID("001"), "high", compileRE(`军|部队|军区|武装|国防|武器|弹药|导弹|雷达|舰艇|战机|潜艇|航母|核武器|火箭军|军事基地|作战指挥|军事演习|战备|动员令|驻地|番号`), "军事相关敏感词汇", nil}, + {CID("002"), "high", compileRE(`中央委员会|国务院|中央军委|部委|党政机关|机要局|保密局|国家安全|公安内网|政务内网|红头文件|绝密|机密文件|内参|机要文件`), "党政机关敏感词汇", nil}, + {CID("003"), "medium", compileRE(`内部系统|内部平台|内网地址|专网|涉密|非密|脱密|密码机|加密机|堡垒机|入侵检测|安全监测`), "内部系统标识泄露", nil}, + {CID("004"), "medium", compileRE(`反洗钱|征信系统|个人隐私数据|数据出境|跨境传输|敏感个人信息|涉密数据|关键信息基础设施|网络安全等级|等保|密评|商用密码`), "监管合规敏感词", nil}, + {CID("005"), "low", compileRE(`内部代号|项目代号|内部项目|未公开|NDA|保密协议|客户名录|内部API|私有接口|内部对接`), "组织内部敏感信息", nil}, + {CID("006"), "medium", compileRE(`国密|SM2|SM3|SM4|SM9|密码卡|防火墙设备|入侵防御|WAF|DLP|上网行为|日志审计|终端管控`), "安全产品/密码学敏感词", nil}, + } +} + +func SID(num string) string { return "S-" + num } +func PID(num string) string { return "P-" + num } +func EID(num string) string { return "E-" + num } +func CID(num string) string { return "C-" + num } diff --git a/shortcuts/compliance/scanner.go b/shortcuts/compliance/scanner.go new file mode 100644 index 0000000..0ef7402 --- /dev/null +++ b/shortcuts/compliance/scanner.go @@ -0,0 +1,210 @@ +package compliance + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" +) + +// Finding represents a single scan result. +type Finding struct { + ID string `json:"id"` + Severity string `json:"severity"` // critical, high, medium, low + Module string `json:"module"` // license, deps, secrets, exposure, vocab + File string `json:"file"` + Line int `json:"line"` + Summary string `json:"summary"` +} + +// ScanResult holds all findings for a module. +type ScanResult struct { + Module string `json:"module"` + Findings []Finding `json:"findings"` +} + +// scanRule defines a pattern to search for. +type scanRule struct { + ID string + Severity string + Pattern *regexp.Regexp + Summary string + Globs []string // file globs to include, empty = all text files +} + +// excludedDirs are directories skipped during scanning. +var excludedDirs = map[string]bool{ + "vendor": true, "node_modules": true, ".git": true, ".claude": true, + "skills": true, // skill documentation, not project source +} + +// excludedPaths are relative paths skipped (scanner's own source to avoid self-scan). +var excludedPaths = map[string]bool{ + "shortcuts/compliance": true, +} + +// excludedExts are file extensions skipped during scanning. +var excludedExts = map[string]bool{ + ".exe": true, ".dll": true, ".so": true, ".dylib": true, + ".bin": true, ".jpg": true, ".jpeg": true, ".png": true, + ".gif": true, ".ico": true, ".svg": true, ".pdf": true, + ".zip": true, ".gz": true, ".tgz": true, +} + +// excludeFiles are specific files skipped during scanning. +var excludeFiles = map[string]bool{ + "go.sum": true, "package-lock.json": true, +} + +// textExts are extensions treated as text files. +var textExts = map[string]bool{ + ".go": true, ".js": true, ".ts": true, ".tsx": true, ".jsx": true, + ".py": true, ".rb": true, ".java": true, ".c": true, ".h": true, + ".cpp": true, ".hpp": true, ".rs": true, ".swift": true, ".kt": true, + ".yaml": true, ".yml": true, ".json": true, ".xml": true, ".toml": true, + ".md": true, ".txt": true, ".sh": true, ".bash": true, ".ps1": true, + ".css": true, ".html": true, ".htm": true, ".sql": true, ".proto": true, + ".cfg": true, ".conf": true, ".ini": true, ".env": true, ".lock": true, + ".mod": true, +} + +func isTextFile(path string) bool { + ext := strings.ToLower(filepath.Ext(path)) + return textExts[ext] +} + +func shouldSkip(path string, info os.FileInfo) bool { + name := info.Name() + if info.IsDir() { + if excludedDirs[name] { + return true + } + return false + } + if excludedExts[strings.ToLower(filepath.Ext(name))] { + return true + } + if excludeFiles[name] { + return true + } + return false +} + +// walkFiles walks the repo and yields text file paths (relative to root). +func walkFiles(root string) ([]string, error) { + var files []string + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return nil + } + if shouldSkip(path, info) { + if info.IsDir() { + return filepath.SkipDir + } + return nil + } + if !info.IsDir() && isTextFile(path) { + rel, _ := filepath.Rel(root, path) + rel = filepath.ToSlash(rel) + // skip excluded paths (scanner's own source) + for prefix := range excludedPaths { + if strings.HasPrefix(rel, prefix) { + return nil + } + } + files = append(files, rel) + } + return nil + }) + return files, err +} + +// scanFiles scans files against rules and returns deduplicated findings. +// When a line matches multiple rules, only the highest-severity rule is reported. +func scanFiles(root string, rules []scanRule) []Finding { + files, err := walkFiles(root) + if err != nil { + return []Finding{{ID: "ERR", Severity: "critical", Module: "scanner", Summary: fmt.Sprintf("walk error: %v", err)}} + } + + // dedup by file+line, keeping the highest severity + sevRank := map[string]int{"critical": 4, "high": 3, "medium": 2, "low": 1} + seen := make(map[string]Finding) // key: "file:line" + + for _, f := range files { + for _, rule := range rules { + if !ruleMatchesFile(f, rule.Globs) { + continue + } + for _, m := range scanFile(filepath.Join(root, f), rule) { + key := fmt.Sprintf("%s:%d", m.File, m.Line) + if prev, ok := seen[key]; !ok || sevRank[m.Severity] > sevRank[prev.Severity] { + seen[key] = m + } + } + } + } + + var findings []Finding + for _, f := range seen { + findings = append(findings, f) + } + return findings +} + +func ruleMatchesFile(file string, globs []string) bool { + if len(globs) == 0 { + return true + } + for _, g := range globs { + matched, _ := filepath.Match(g, filepath.Base(file)) + if matched { + return true + } + } + return false +} + +func scanFile(path string, rule scanRule) []Finding { + f, err := os.Open(path) + if err != nil { + return nil + } + defer f.Close() + + var findings []Finding + scanner := bufio.NewScanner(f) + lineNum := 0 + for scanner.Scan() { + lineNum++ + if rule.Pattern.MatchString(scanner.Text()) { + findings = append(findings, Finding{ + ID: rule.ID, + Severity: rule.Severity, + Module: ruleMod(rule.ID), + File: path, + Line: lineNum, + Summary: rule.Summary, + }) + } + } + return findings +} + +func ruleMod(id string) string { + switch { + case strings.HasPrefix(id, "L-"): + return "license" + case strings.HasPrefix(id, "D-"): + return "deps" + case strings.HasPrefix(id, "S-"): + return "secrets" + case strings.HasPrefix(id, "P-"), strings.HasPrefix(id, "E-"): + return "exposure" + case strings.HasPrefix(id, "C-"): + return "vocab" + } + return "unknown" +} diff --git a/shortcuts/contrib/contrib.go b/shortcuts/contrib/contrib.go new file mode 100644 index 0000000..bc9357a --- /dev/null +++ b/shortcuts/contrib/contrib.go @@ -0,0 +1,69 @@ +package contrib + +import ( + "fmt" + "os/exec" + "runtime" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "report", + Description: "Generate a contribution report with pie chart", + Flags: []common.Flag{ + {Name: "output", Short: "o", Usage: "Output HTML file path", Default: "contrib-report.html"}, + {Name: "open", Usage: "Open browser after generating", Bool: true, Default: "true"}, + }, + Run: runReport, + }, + } +} + +func runReport(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + // 1. 获取贡献者列表(从 issue 和 PR 数据中提取) + contributors, err := fetchContributors(ctx) + if err != nil { + return fmt.Errorf("fetch contributors: %w", err) + } + + // 2. 计算加权贡献分数 + reportData := calculateScores(contributors, nil, nil) + + // 3. 生成 HTML 报告 + outputPath := ctx.Arg("output") + if err := generateHTML(ctx.Owner, ctx.Repo, reportData, outputPath); err != nil { + return fmt.Errorf("generate HTML: %w", err) + } + + fmt.Printf("Report generated: %s\n", outputPath) + + // 4. 打开浏览器 + if ctx.Arg("open") != "false" { + if err := openBrowser(outputPath); err != nil { + fmt.Printf("Warning: failed to open browser: %v\n", err) + } + } + + return nil +} + +// openBrowser 打开浏览器 +func openBrowser(url string) error { + var cmd *exec.Cmd + switch runtime.GOOS { + case "windows": + cmd = exec.Command("rundll32", "url.dll,FileProtocolHandler", url) + case "darwin": + cmd = exec.Command("open", url) + default: // linux + cmd = exec.Command("xdg-open", url) + } + return cmd.Start() +} diff --git a/shortcuts/contrib/data.go b/shortcuts/contrib/data.go new file mode 100644 index 0000000..388fa66 --- /dev/null +++ b/shortcuts/contrib/data.go @@ -0,0 +1,290 @@ +package contrib + +import ( + "fmt" + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// Contributor 贡献者信息 +type Contributor struct { + Login string + Name string + Commits int + Additions int + Deletions int + Issues int + PRs int + Score float64 +} + +// AHP 权重(基于层次分析法计算) +const ( + WeightCommits = 0.143 + WeightCodeLines = 0.286 + WeightPRs = 0.071 + WeightIssues = 0.05 + WeightIssueSolve = 0.1 + WeightRelease = 0.1 + WeightComments = 0.083 + WeightPRReview = 0.083 + WeightWiki = 0.083 +) + +// fetchContributors 从 issue 和 PR 数据中提取贡献者 +func fetchContributors(ctx *common.RuntimeContext) ([]Contributor, error) { + contributorMap := make(map[string]*Contributor) + + // 1. 从 Issue 列表中提取贡献者 + issues, err := fetchAllIssues(ctx) + if err != nil { + return nil, fmt.Errorf("fetch issues: %w", err) + } + for _, issue := range issues { + login := issue["login"] + name := issue["name"] + if login == "" { + continue + } + if _, exists := contributorMap[login]; !exists { + contributorMap[login] = &Contributor{ + Login: login, + Name: name, + } + if contributorMap[login].Name == "" { + contributorMap[login].Name = login + } + } + contributorMap[login].Issues++ + } + + // 2. 从 PR 列表中提取贡献者 + prs, err := fetchAllPRs(ctx) + if err != nil { + return nil, fmt.Errorf("fetch PRs: %w", err) + } + for _, pr := range prs { + login := pr["login"] + name := pr["name"] + if login == "" { + continue + } + if _, exists := contributorMap[login]; !exists { + contributorMap[login] = &Contributor{ + Login: login, + Name: name, + } + if contributorMap[login].Name == "" { + contributorMap[login].Name = login + } + } + contributorMap[login].PRs++ + } + + // 转换为切片 + var contributors []Contributor + for _, c := range contributorMap { + contributors = append(contributors, *c) + } + + if len(contributors) == 0 { + return nil, fmt.Errorf("no contributors found") + } + + return contributors, nil +} + +// fetchAllIssues 获取所有 Issue +func fetchAllIssues(ctx *common.RuntimeContext) ([]map[string]string, error) { + var results []map[string]string + page := 1 + + for { + q := url.Values{} + q.Set("page", fmt.Sprintf("%d", page)) + q.Set("limit", "100") + q.Set("state", "all") + + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/issues", ctx.Owner, ctx.Repo), q) + if err != nil { + return nil, err + } + + data, ok := env.Data.(map[string]interface{}) + if !ok { + break + } + + issues, ok := data["issues"].([]interface{}) + if !ok || len(issues) == 0 { + break + } + + for _, item := range issues { + issue, ok := item.(map[string]interface{}) + if !ok { + continue + } + author, ok := issue["author"].(map[string]interface{}) + if !ok { + continue + } + login := getString(author, "login") + name := getString(author, "name") + if login != "" { + results = append(results, map[string]string{ + "login": login, + "name": name, + }) + } + } + + totalCount := getInt(data, "total_count") + if page*100 >= totalCount { + break + } + page++ + } + + return results, nil +} + +// fetchAllPRs 获取所有 PR +func fetchAllPRs(ctx *common.RuntimeContext) ([]map[string]string, error) { + var results []map[string]string + page := 1 + + for { + q := url.Values{} + q.Set("page", fmt.Sprintf("%d", page)) + q.Set("limit", "100") + q.Set("state", "all") + + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/pulls", ctx.Owner, ctx.Repo), q) + if err != nil { + return nil, err + } + + data, ok := env.Data.(map[string]interface{}) + if !ok { + break + } + + // PR 列表字段是 "pulls" + prs, ok := data["pulls"].([]interface{}) + if !ok || len(prs) == 0 { + break + } + + for _, item := range prs { + pr, ok := item.(map[string]interface{}) + if !ok { + continue + } + // PR 的作者信息在 issue.author 中 + login := "" + name := "" + if issue, ok := pr["issue"].(map[string]interface{}); ok { + if author, ok := issue["author"].(map[string]interface{}); ok { + login = getString(author, "login") + name = getString(author, "name") + } + } + if login != "" { + results = append(results, map[string]string{ + "login": login, + "name": name, + }) + } + } + + // 检查是否还有更多页 + searchCount := getInt(data, "search_count") + if page*100 >= searchCount { + break + } + page++ + } + + return results, nil +} + +// calculateScores 计算加权贡献分数 +func calculateScores(contributors []Contributor, issueCounts, prCounts map[string]int) []Contributor { + // 如果提供了额外的计数,更新贡献者数据 + if issueCounts != nil { + for i := range contributors { + c := &contributors[i] + if count, ok := issueCounts[c.Login]; ok { + c.Issues = count + } + } + } + if prCounts != nil { + for i := range contributors { + c := &contributors[i] + if count, ok := prCounts[c.Login]; ok { + c.PRs = count + } + } + } + + // 找到各指标的最大值(用于归一化) + maxIssues := 0 + maxPRs := 0 + + for i := range contributors { + c := &contributors[i] + if c.Issues > maxIssues { + maxIssues = c.Issues + } + if c.PRs > maxPRs { + maxPRs = c.PRs + } + } + + // 计算加权分数(归一化后) + for i := range contributors { + c := &contributors[i] + + // 归一化到 0-1 + normIssues := 0.0 + normPRs := 0.0 + + if maxIssues > 0 { + normIssues = float64(c.Issues) / float64(maxIssues) + } + if maxPRs > 0 { + normPRs = float64(c.PRs) / float64(maxPRs) + } + + // 加权求和(只用 issue 和 PR,因为 commits API 不可用) + c.Score = normIssues*WeightIssues + normPRs*WeightPRs + } + + return contributors +} + +// getString 从 map 中获取字符串 +func getString(m map[string]interface{}, key string) string { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +// getInt 从 map 中获取整数 +func getInt(m map[string]interface{}, key string) int { + if v, ok := m[key]; ok { + switch n := v.(type) { + case float64: + return int(n) + case int: + return n + } + } + return 0 +} diff --git a/shortcuts/contrib/html.go b/shortcuts/contrib/html.go new file mode 100644 index 0000000..9c2bfd9 --- /dev/null +++ b/shortcuts/contrib/html.go @@ -0,0 +1,493 @@ +package contrib + +import ( + "fmt" + "html/template" + "os" + "sort" +) + +// ReportData 报告数据 +type ReportData struct { + Owner string + Repo string + Contributors []Contributor + ChartLabels template.JS + ChartValues template.JS + TableRows []TableRow +} + +// TableRow 表格行 +type TableRow struct { + Rank int + Login string + Name string + Commits int + CodeLines int + Issues int + PRs int + Score float64 + ScorePct float64 +} + +// generateHTML 生成 HTML 报告 +func generateHTML(owner, repo string, contributors []Contributor, outputPath string) error { + // 按分数排序 + sort.Slice(contributors, func(i, j int) bool { + return contributors[i].Score > contributors[j].Score + }) + + // 准备图表数据 + var labels []string + var values []string + var tableRows []TableRow + + totalScore := 0.0 + for _, c := range contributors { + totalScore += c.Score + } + + for i, c := range contributors { + labels = append(labels, fmt.Sprintf("%q", c.Login)) + values = append(values, fmt.Sprintf("\"%.4f\"", c.Score)) + + scorePct := 0.0 + if totalScore > 0 { + scorePct = c.Score / totalScore * 100 + } + + tableRows = append(tableRows, TableRow{ + Rank: i + 1, + Login: c.Login, + Name: c.Name, + Commits: c.Commits, + CodeLines: c.Additions + c.Deletions, + Issues: c.Issues, + PRs: c.PRs, + Score: c.Score, + ScorePct: scorePct, + }) + } + + data := ReportData{ + Owner: owner, + Repo: repo, + Contributors: contributors, + ChartLabels: template.JS(fmt.Sprintf("[%s]", joinStrings(labels, ","))), + ChartValues: template.JS(fmt.Sprintf("[%s]", joinStrings(values, ","))), + TableRows: tableRows, + } + + // 创建 HTML 文件 + file, err := os.Create(outputPath) + if err != nil { + return err + } + defer file.Close() + + // 解析并执行模板 + tmpl, err := template.New("report").Parse(htmlTemplate) + if err != nil { + return err + } + + return tmpl.Execute(file, data) +} + +// joinStrings 连接字符串切片 +func joinStrings(strs []string, sep string) string { + result := "" + for i, s := range strs { + if i > 0 { + result += sep + } + result += s + } + return result +} + +// htmlTemplate HTML 模板 +const htmlTemplate = ` + + + + + 贡献者报告 - {{.Owner}}/{{.Repo}} + + + + +
+
+

贡献者报告

+

{{.Owner}}/{{.Repo}} - 团队成员贡献分析

+
+ +
+
+
{{len .Contributors}}
+
贡献者总数
+
+
+
0
+
总提交数
+
+
+
0
+
总 Issue 数
+
+
+
0
+
总 PR 数
+
+
+ +
+

贡献占比分布

+
+
+ +
+

详细排名

+
+ + + + + + + + + + + + + + + {{range .TableRows}} + + + + + + + + + + + {{end}} + +
排名成员Commits代码行数IssuesPRs贡献分数占比
{{.Rank}} + + {{.Commits}}{{.CodeLines}}{{.Issues}}{{.PRs}}{{printf "%.4f" .Score}} +
+
+
+ {{printf "%.1f" .ScorePct}}% +
+
+
+ +
+

AHP 权重说明

+
+

层次分析法 (AHP) 权重分配

+
+
+ Commits + 14.3% +
+
+ 代码行数 + 28.6% +
+
+ PR 合并数 + 7.1% +
+
+ Issue 创建 + 5.0% +
+
+ Issue 解决 + 10.0% +
+
+ Release + 10.0% +
+
+ Issue 评论 + 8.3% +
+
+ PR 评审 + 8.3% +
+
+ Wiki + 8.3% +
+
+
+
+
+ + + +` diff --git a/shortcuts/issue/batch.go b/shortcuts/issue/batch.go index 2345b6c..54aef1e 100644 --- a/shortcuts/issue/batch.go +++ b/shortcuts/issue/batch.go @@ -10,32 +10,113 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) -const closedIssueStatusID = 5 +// Priority constants +const ( + priorityLow = 1 + priorityNormal = 2 + priorityHigh = 3 + priorityUrgent = 4 +) -type batchCloseResult struct { +// Status constants +const ( + statusNew = 1 + statusInProgress = 2 + statusResolved = 3 + statusClosed = 5 + statusRejected = 6 +) + +// Tracker constants +const ( + trackerBug = 1 + trackerFeature = 2 + trackerSupport = 3 + trackerDoc = 4 + trackerTest = 5 + trackerDuplicate = 6 + trackerQuestion = 7 +) + +var priorityNames = map[int]string{ + priorityLow: "low", + priorityNormal: "normal", + priorityHigh: "high", + priorityUrgent: "urgent", +} + +var statusNames = map[int]string{ + statusNew: "new", + statusInProgress: "in-progress", + statusResolved: "resolved", + statusClosed: "closed", + statusRejected: "rejected", +} + +var trackerNames = map[int]string{ + trackerBug: "bug", + trackerFeature: "feature", + trackerSupport: "support", + trackerDoc: "doc", + trackerTest: "test", + trackerDuplicate: "duplicate", + trackerQuestion: "question", +} + +// Tag name → GitLink tag ID mapping +// Collect IDs from web UI DevTools: change tag → capture PATCH payload → get issue_tag_ids value +var tagIDs = map[string]int{ + "缺陷": 315526, + "功能": 315527, + "文档": 315533, + "重复": 315525, + "疑问": 315528, + "支持": 315529, + "任务": 315530, + "测试": 315534, + "协助": 315531, + "搁置": 315532, +} + +// labelNames returns all known tag names from the given mapping. +func labelNames(tags map[string]int) string { + var names []string + for name := range tags { + names = append(names, name) + } + return strings.Join(names, ", ") +} + +// BatchResult is a single item result in a batch operation. +type BatchResult struct { Number string `json:"number" yaml:"number"` Action string `json:"action" yaml:"action"` Status string `json:"status" yaml:"status"` Error string `json:"error,omitempty" yaml:"error,omitempty"` } -type batchCloseSummary struct { - Repository string `json:"repository" yaml:"repository"` - DryRun bool `json:"dry_run" yaml:"dry_run"` - Total int `json:"total" yaml:"total"` - Succeeded int `json:"succeeded" yaml:"succeeded"` - Failed int `json:"failed" yaml:"failed"` - Results []batchCloseResult `json:"results" yaml:"results"` +// BatchSummary is the aggregate result of a batch operation. +type BatchSummary struct { + Repository string `json:"repository" yaml:"repository"` + Action string `json:"action" yaml:"action"` + Value string `json:"value,omitempty" yaml:"value,omitempty"` + DryRun bool `json:"dry_run" yaml:"dry_run"` + Total int `json:"total" yaml:"total"` + Succeeded int `json:"succeeded" yaml:"succeeded"` + Failed int `json:"failed" yaml:"failed"` + Results []BatchResult `json:"results" yaml:"results"` } +// ---- batch-close ---- + func newBatchCloseShortcut() *common.Shortcut { return &common.Shortcut{ Name: "batch-close", Description: "Close multiple issues by issue numbers or a CSV file", Flags: []common.Flag{ - {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers from the web URL, for example: 1,2,3"}, - {Name: "from", Usage: "Read issue numbers from a CSV file. Supports a number/issue_number/project_issues_index column or first column without header"}, - {Name: "dry-run", Usage: "Preview the issues that would be closed without changing them", Bool: true, Default: "false"}, + {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"}, + {Name: "from", Usage: "Read issue numbers from a CSV file"}, + {Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"}, }, Run: runBatchClose, } @@ -45,7 +126,6 @@ func runBatchClose(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from")) if err != nil { return err @@ -55,23 +135,23 @@ func runBatchClose(ctx *common.RuntimeContext) error { } dryRun := parseBool(ctx.Arg("dry-run")) - summary := batchCloseSummary{ + summary := BatchSummary{ Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + Action: "close", DryRun: dryRun, Total: len(numbers), - Results: make([]batchCloseResult, 0, len(numbers)), + Results: make([]BatchResult, 0, len(numbers)), } for _, number := range numbers { - result := batchCloseResult{Number: number, Action: "close"} + result := BatchResult{Number: number, Action: "close"} if dryRun { result.Status = "planned" summary.Succeeded++ summary.Results = append(summary.Results, result) continue } - - if err := closeIssue(ctx, number); err != nil { + if err := updateIssueField(ctx, number, map[string]interface{}{"status_id": statusClosed}); err != nil { result.Status = "failed" result.Error = err.Error() summary.Failed++ @@ -86,28 +166,439 @@ func runBatchClose(ctx *common.RuntimeContext) error { return err } if summary.Failed > 0 { - return fmt.Errorf("%d of %d issue(s) failed to close", summary.Failed, summary.Total) + return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total) } return nil } -func closeIssue(ctx *common.RuntimeContext, number string) error { +// ---- batch-status ---- + +func newBatchStatusShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-status", + Description: "Change status for multiple issues", + Flags: []common.Flag{ + {Name: "state", Short: "s", Usage: "Target state: new, in-progress, resolved, closed, rejected", Required: true}, + {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"}, + {Name: "from", Usage: "Read issue numbers from a CSV file"}, + {Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"}, + }, + Run: runBatchStatus, + } +} + +func runBatchStatus(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + state := ctx.Arg("state") + statusID, err := parseStatus(state) + if err != nil { + return err + } + + numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from")) + if err != nil { + return err + } + if len(numbers) == 0 { + return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") + } + + dryRun := parseBool(ctx.Arg("dry-run")) + summary := BatchSummary{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + Action: "set-status", + Value: state, + DryRun: dryRun, + Total: len(numbers), + Results: make([]BatchResult, 0, len(numbers)), + } + + for _, number := range numbers { + result := BatchResult{Number: number, Action: "set-status"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + if err := updateIssueField(ctx, number, map[string]interface{}{"status_id": statusID}); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = state + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total) + } + return nil +} + +// ---- batch-priority ---- + +func newBatchPriorityShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-priority", + Description: "Change priority for multiple issues", + Flags: []common.Flag{ + {Name: "priority", Short: "p", Usage: "Target priority: low, normal, high, urgent", Required: true}, + {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"}, + {Name: "from", Usage: "Read issue numbers from a CSV file"}, + {Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"}, + }, + Run: runBatchPriority, + } +} + +func runBatchPriority(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + priority := ctx.Arg("priority") + priorityID, err := parsePriority(priority) + if err != nil { + return err + } + + numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from")) + if err != nil { + return err + } + if len(numbers) == 0 { + return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") + } + + dryRun := parseBool(ctx.Arg("dry-run")) + summary := BatchSummary{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + Action: "set-priority", + Value: priority, + DryRun: dryRun, + Total: len(numbers), + Results: make([]BatchResult, 0, len(numbers)), + } + + for _, number := range numbers { + result := BatchResult{Number: number, Action: "set-priority"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + if err := updateIssueField(ctx, number, map[string]interface{}{"priority_id": priorityID}); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = priority + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total) + } + return nil +} + +// ---- batch-assign ---- + +func newBatchAssignShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-assign", + Description: "Change assignee for multiple issues", + Flags: []common.Flag{ + {Name: "assignee", Short: "a", Usage: "Assignee login name or user ID", Required: true}, + {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"}, + {Name: "from", Usage: "Read issue numbers from a CSV file"}, + {Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"}, + }, + Run: runBatchAssign, + } +} + +func runBatchAssign(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + assignee := ctx.Arg("assignee") + + numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from")) + if err != nil { + return err + } + if len(numbers) == 0 { + return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") + } + + dryRun := parseBool(ctx.Arg("dry-run")) + summary := BatchSummary{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + Action: "set-assignee", + Value: assignee, + DryRun: dryRun, + Total: len(numbers), + Results: make([]BatchResult, 0, len(numbers)), + } + + var assigneeID interface{} + if !dryRun { + id, err := resolveUserID(ctx, assignee) + if err != nil { + return fmt.Errorf("cannot resolve assignee %q: %w", assignee, err) + } + assigneeID = id + } + + for _, number := range numbers { + result := BatchResult{Number: number, Action: "set-assignee"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + if err := updateIssueField(ctx, number, map[string]interface{}{"assigner_ids": []interface{}{assigneeID}}); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "assigned" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total) + } + return nil +} + +// ---- batch-label ---- + +func newBatchLabelShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-label", + Description: "Change tracker label for multiple issues", + Flags: []common.Flag{ + {Name: "label", Short: "l", Usage: "Target label: bug, feature, support, doc, test, duplicate, question, or Chinese names (缺陷/功能/文档/重复/疑问/支持/任务/测试/协助/搁置)", Required: true}, + {Name: "numbers", Short: "n", Usage: "Comma-separated issue numbers, e.g. 1,2,3"}, + {Name: "from", Usage: "Read issue numbers from a CSV file"}, + {Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"}, + }, + Run: runBatchLabel, + } +} + +func runBatchLabel(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + label := ctx.Arg("label") + + tags, err := resolveIssueTags(ctx) + if err != nil { + return fmt.Errorf("cannot resolve issue tags: %w", err) + } + tagID, err := parseLabel(label, tags) + if err != nil { + return err + } + + numbers, err := collectIssueNumbers(ctx.Arg("numbers"), ctx.Arg("from")) + if err != nil { + return err + } + if len(numbers) == 0 { + return fmt.Errorf("no issue numbers provided; use --numbers 1,2,3 or --from issues.csv") + } + + dryRun := parseBool(ctx.Arg("dry-run")) + summary := BatchSummary{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + Action: "set-label", + Value: label, + DryRun: dryRun, + Total: len(numbers), + Results: make([]BatchResult, 0, len(numbers)), + } + + for _, number := range numbers { + result := BatchResult{Number: number, Action: "set-label"} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + if err := updateIssueField(ctx, number, map[string]interface{}{"issue_tag_ids": []int{tagID}}); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = label + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d of %d issue(s) failed", summary.Failed, summary.Total) + } + return nil +} + +// ---- shared helpers ---- + +// updateIssueField fetches the current issue to preserve subject/description, +// then PATCHes with the given fields merged in. +func updateIssueField(ctx *common.RuntimeContext, number string, fields map[string]interface{}) error { current, err := fetchExistingIssue(ctx, number) if err != nil { - return fmt.Errorf("fetch issue: %w", err) + return fmt.Errorf("fetch issue #%s: %w", number, err) } body := map[string]interface{}{ "subject": current.Subject, "description": current.Description, - "status_id": closedIssueStatusID, } + for k, v := range fields { + body[k] = v + } + if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body); err != nil { - return fmt.Errorf("close issue: %w", err) + return fmt.Errorf("update issue #%s: %w", number, err) } return nil } +// resolveUserID converts a login name to a numeric user ID via the users API. +func resolveUserID(ctx *common.RuntimeContext, login string) (interface{}, error) { + if id, err := strconv.Atoi(login); err == nil { + return id, nil + } + + env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil) + if err != nil { + return nil, fmt.Errorf("lookup user %q: %w", login, err) + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("unexpected response for user %q", login) + } + idFloat, ok := data["id"].(float64) + if ok { + return int(idFloat), nil + } + userIDFloat, ok := data["user_id"].(float64) + if ok { + return int(userIDFloat), nil + } + return nil, fmt.Errorf("cannot determine user ID for %q", login) +} + +func parseStatus(state string) (int, error) { + switch strings.ToLower(strings.TrimSpace(state)) { + case "new": + return statusNew, nil + case "in-progress", "in_progress", "inprogress": + return statusInProgress, nil + case "resolved": + return statusResolved, nil + case "closed": + return statusClosed, nil + case "rejected": + return statusRejected, nil + default: + if id, err := strconv.Atoi(state); err == nil { + return id, nil + } + return 0, fmt.Errorf("invalid state %q: use new, in-progress, resolved, closed, or rejected", state) + } +} + +func parsePriority(p string) (int, error) { + switch strings.ToLower(strings.TrimSpace(p)) { + case "low": + return priorityLow, nil + case "normal": + return priorityNormal, nil + case "high": + return priorityHigh, nil + case "urgent": + return priorityUrgent, nil + default: + if id, err := strconv.Atoi(p); err == nil { + return id, nil + } + return 0, fmt.Errorf("invalid priority %q: use low, normal, high, or urgent", p) + } +} + +func parseTracker(label string) (int, error) { + trimmed := strings.TrimSpace(label) + + // Check Chinese tag names first + if id, ok := tagIDs[trimmed]; ok { + return id, nil + } + + switch strings.ToLower(trimmed) { + case "bug": + return trackerBug, nil + case "feature": + return trackerFeature, nil + case "support": + return trackerSupport, nil + case "doc": + return trackerDoc, nil + case "test": + return trackerTest, nil + case "duplicate": + return trackerDuplicate, nil + case "question": + return trackerQuestion, nil + default: + if id, err := strconv.Atoi(label); err == nil { + return id, nil + } + return 0, fmt.Errorf("invalid label %q: use bug, feature, support, doc, test, duplicate, question, or Chinese names (%s)", label, labelNames(tagIDs)) + } +} + +// parseLabel converts a label name to its GitLink tag ID. +// tags is the project's name→id mapping from resolveIssueTags. +func parseLabel(name string, tags map[string]int) (int, error) { + if id, ok := tags[name]; ok && id != 0 { + return id, nil + } + if id, err := strconv.Atoi(name); err == nil { + return id, nil + } + return 0, fmt.Errorf("invalid label %q: not found in project issue tags", name) +} + func collectIssueNumbers(numbersValue, csvPath string) ([]string, error) { numbers, err := parseIssueNumbers(numbersValue) if err != nil { @@ -134,7 +625,7 @@ func parseIssueNumbers(value string) ([]string, error) { func readIssueNumbersFromCSV(path string) ([]string, error) { file, err := os.Open(path) if err != nil { - return nil, fmt.Errorf("read issue numbers from CSV: %w", err) + return nil, fmt.Errorf("read CSV: %w", err) } defer file.Close() @@ -142,7 +633,7 @@ func readIssueNumbersFromCSV(path string) ([]string, error) { reader.TrimLeadingSpace = true records, err := reader.ReadAll() if err != nil { - return nil, fmt.Errorf("parse issue numbers from CSV: %w", err) + return nil, fmt.Errorf("parse CSV: %w", err) } if len(records) == 0 { return nil, nil @@ -180,7 +671,7 @@ func normalizeIssueNumbers(values []string) ([]string, error) { continue } if _, err := strconv.ParseInt(number, 10, 64); err != nil { - return nil, fmt.Errorf("invalid issue number %q: issue numbers must be integers", number) + return nil, fmt.Errorf("invalid issue number %q: must be an integer", number) } if seen[number] { continue diff --git a/shortcuts/issue/batch_create.go b/shortcuts/issue/batch_create.go new file mode 100644 index 0000000..1553429 --- /dev/null +++ b/shortcuts/issue/batch_create.go @@ -0,0 +1,409 @@ +package issue + +import ( + "encoding/csv" + "fmt" + "net/url" + "os" + "strconv" + "strings" + "sync" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +var issueTagCache sync.Map //全局缓存变量 + +// 获取项目标签映射 +// resolveIssueTags fetches the project's issue tags and returns a name→id mapping. +// Results are cached per owner/repo. +func resolveIssueTags(ctx *common.RuntimeContext) (map[string]int, error) { + key := ctx.Owner + "/" + ctx.Repo + if cached, ok := issueTagCache.Load(key); ok { //先从缓存查 + return cached.(map[string]int), nil // 命中缓存则直接返回 + } + + path := fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo) //api路径 + q := url.Values{} + q.Set("only_name", "true") + env, err := ctx.CallAPIWithQuery("GET", path, q) // 发送GET请求获取项目标签列表 + if err != nil { + return nil, fmt.Errorf("获取项目标签列表失败: %w", err) + } + + data, ok := env.Data.(map[string]interface{}) + if !ok { + return nil, fmt.Errorf("标签列表响应格式异常") + } + + rawTags, ok := data["issue_tags"].([]interface{}) + if !ok { + return nil, fmt.Errorf("标签列表响应缺少 issue_tags 字段") + } + + tags := make(map[string]int, len(rawTags)) + for _, item := range rawTags { + tag, ok := item.(map[string]interface{}) + if !ok { + continue + } + name, _ := tag["name"].(string) + if name == "" { + continue + } + var id int + switch v := tag["id"].(type) { + case float64: + id = int(v) + case int: + id = v + default: + id, _ = strconv.Atoi(fmt.Sprintf("%v", v)) + } + if id == 0 { + continue + } + tags[name] = id + } + + if len(tags) == 0 { + return nil, fmt.Errorf("项目没有配置任何标签,请先在 GitLink 网页端创建标签") + } + + issueTagCache.Store(key, tags) + return tags, nil +} + +func newBatchCreateShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-create", + Description: "Create multiple issues from CLI flags or a CSV file", + Flags: []common.Flag{ + {Name: "titles", Usage: "Comma-separated issue titles, e.g. 标题1,标题2"}, + {Name: "priority", Short: "p", Usage: "Priority: low, normal, high, urgent (default: normal)"}, + {Name: "label", Short: "l", Usage: "Label name, e.g. 缺陷"}, + {Name: "assignee", Short: "a", Usage: "Assignee login name"}, + {Name: "state", Short: "s", Usage: "Initial state: new, in-progress, resolved, closed, rejected (default: new)", Default: "new"}, + {Name: "from", Usage: "CSV file path"}, + {Name: "template", Short: "t", Usage: "Template: bug or feature (only with --from)"}, + {Name: "dry-run", Usage: "Preview without creating issues", Bool: true, Default: "false"}, + }, + Run: runBatchCreate, + } +} + +type createIssueInput struct { + Title string + Body string + Priority string + Label string + Assignee string + Status string + // template-specific fields + Version string + Severity string + Steps string + Expected string + Actual string + UserStory string + Acceptance string +} + +func runBatchCreate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + tags, err := resolveIssueTags(ctx) + if err != nil { + return err + } + + dryRun := parseBool(ctx.Arg("dry-run")) + template := strings.ToLower(strings.TrimSpace(ctx.Arg("template"))) + + // Collect inputs from --titles and/or --from + var inputs []createIssueInput + if titlesStr := ctx.Arg("titles"); titlesStr != "" { + inputs = append(inputs, parseTitles(titlesStr, ctx)...) + } + if csvPath := ctx.Arg("from"); csvPath != "" { + csvInputs, err := readCreateInputsFromCSV(csvPath, template) + if err != nil { + return err + } + inputs = append(inputs, csvInputs...) + } + if len(inputs) == 0 { + return fmt.Errorf("no issue titles provided; use --titles 标题1,标题2 or --from issues.csv") + } + + // Apply CLI --state as fallback for inputs without an explicit status + cliState := ctx.Arg("state") + for i := range inputs { + if inputs[i].Status == "" { + inputs[i].Status = cliState + } + } + + summary := BatchSummary{ + Repository: fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + Action: "create", + Value: template, + DryRun: dryRun, + Total: len(inputs), + Results: make([]BatchResult, 0, len(inputs)), + } + + for i, input := range inputs { + label := fmt.Sprintf("#%d", i+1) + if input.Title != "" { + label = truncate(input.Title, 40) // 用标题的前40个字符 + } + result := BatchResult{Number: label, Action: "create"} + + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + //把 createIssueInput 转换成 API 需要的 JSON map + body := buildCreateBody(ctx, input, template, tags) + env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body) + if err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "created" + if data, ok := env.Data.(map[string]interface{}); ok { + if num, ok := data["project_issues_index"]; ok { + result.Number = fmt.Sprintf("%v", num) + } + } + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d of %d issue(s) failed to create", summary.Failed, summary.Total) + } + return nil +} + +func buildCreateBody(ctx *common.RuntimeContext, input createIssueInput, template string, tags map[string]int) map[string]interface{} { + statusID := statusNew + if input.Status != "" { + if sid, err := parseStatus(input.Status); err == nil { + statusID = sid + } + } + body := map[string]interface{}{ + "subject": input.Title, + "status_id": statusID, + "priority_id": priorityNormal, + "done_ratio": 0, + } + + if template != "" { + body["description"] = buildTemplateDescription(input, template) + if template == "bug" { + body["issue_tag_ids"] = []interface{}{tags["缺陷"]} + } else if template == "feature" { + body["issue_tag_ids"] = []interface{}{tags["功能"]} + } + } else if input.Body != "" { + body["description"] = input.Body + } + + if input.Priority != "" { + if pid, err := parsePriority(input.Priority); err == nil { + body["priority_id"] = pid + } + } + if input.Label != "" { + if tid, err := parseLabel(input.Label, tags); err == nil { + body["issue_tag_ids"] = []interface{}{tid} + } + } + if input.Assignee != "" { + if id, err := resolveUserID(ctx, input.Assignee); err == nil { + body["assigner_ids"] = []interface{}{id} + } + } + + return body +} + +func buildTemplateDescription(input createIssueInput, template string) string { + switch template { + case "bug": + return buildBugDescription(input) + case "feature": + return buildFeatureDescription(input) + default: + return input.Body + } +} + +func buildBugDescription(input createIssueInput) string { + var b strings.Builder + b.WriteString("## Bug 描述\n") + b.WriteString(input.Title) + b.WriteString("\n") + + if input.Version != "" { + b.WriteString("\n## 版本\n") + b.WriteString(input.Version) + b.WriteString("\n") + } + if input.Severity != "" { + b.WriteString("\n## 严重程度\n") + b.WriteString(input.Severity) + b.WriteString("\n") + } + if input.Steps != "" { + b.WriteString("\n## 复现步骤\n") + b.WriteString(input.Steps) + b.WriteString("\n") + } + if input.Expected != "" { + b.WriteString("\n## 期望结果\n") + b.WriteString(input.Expected) + b.WriteString("\n") + } + if input.Actual != "" { + b.WriteString("\n## 实际结果\n") + b.WriteString(input.Actual) + b.WriteString("\n") + } + return b.String() +} + +func buildFeatureDescription(input createIssueInput) string { + var b strings.Builder + b.WriteString("## 用户故事\n") + if input.UserStory != "" { + b.WriteString(input.UserStory) + } else { + b.WriteString(input.Title) + } + b.WriteString("\n") + + if input.Body != "" { + b.WriteString("\n## 描述\n") + b.WriteString(input.Body) + b.WriteString("\n") + } + if input.Acceptance != "" { + b.WriteString("\n## 验收标准\n") + b.WriteString(input.Acceptance) + b.WriteString("\n") + } + if input.Priority != "" { + b.WriteString("\n## 优先级\n") + b.WriteString(input.Priority) + b.WriteString("\n") + } + return b.String() +} + +func readCreateInputsFromCSV(path string, template string) ([]createIssueInput, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("read CSV: %w", err) + } + defer file.Close() + + reader := csv.NewReader(file) + reader.TrimLeadingSpace = true + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("parse CSV: %w", err) + } + if len(records) < 2 { + return nil, fmt.Errorf("CSV must have a header row and at least one data row") + } + + header := records[0] + col := make(map[string]int) + for i, h := range header { + col[normalizeHeader(h)] = i + } + + if _, ok := col["title"]; !ok { + return nil, fmt.Errorf("CSV must have a 'title' column") + } + + var inputs []createIssueInput + for _, record := range records[1:] { + input := createIssueInput{ + Title: getCol(record, col, "title"), + Body: getCol(record, col, "body"), + Priority: getCol(record, col, "priority"), + Label: getCol(record, col, "label"), + Assignee: getCol(record, col, "assignee"), + Status: getCol(record, col, "status"), + Version: getCol(record, col, "version"), + Severity: getCol(record, col, "severity"), + Steps: getCol(record, col, "steps"), + Expected: getCol(record, col, "expected"), + Actual: getCol(record, col, "actual"), + // Support alternate heading for feature template + UserStory: getCol(record, col, "user_story"), + Acceptance: getCol(record, col, "acceptance"), + } + if input.UserStory == "" { + input.UserStory = getCol(record, col, "user story") + } + if input.Title == "" { + continue + } + inputs = append(inputs, input) + } + return inputs, nil +} + +func parseTitles(titlesStr string, ctx *common.RuntimeContext) []createIssueInput { + parts := strings.Split(titlesStr, ",") + inputs := make([]createIssueInput, 0, len(parts)) + for _, title := range parts { + title = strings.TrimSpace(title) + if title == "" { + continue + } + inputs = append(inputs, createIssueInput{ + Title: title, + Priority: ctx.Arg("priority"), + Label: ctx.Arg("label"), + Assignee: ctx.Arg("assignee"), + Status: ctx.Arg("state"), + }) + } + return inputs +} + +func normalizeHeader(h string) string { + return strings.ToLower(strings.TrimSpace(h)) +} + +func getCol(record []string, col map[string]int, name string) string { + if idx, ok := col[name]; ok && idx < len(record) { + return strings.TrimSpace(record[idx]) + } + return "" +} + +func truncate(s string, n int) string { + runes := []rune(s) + if len(runes) <= n { + return s + } + return string(runes[:n]) + "..." +} diff --git a/shortcuts/issue/batch_create_test.go b/shortcuts/issue/batch_create_test.go new file mode 100644 index 0000000..b91680e --- /dev/null +++ b/shortcuts/issue/batch_create_test.go @@ -0,0 +1,678 @@ +package issue + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// testTags is a static name→id mapping used by unit tests. +var testTags = map[string]int{ + "缺陷": 315526, + "功能": 315527, + "文档": 315533, + "任务": 315530, + "测试": 315534, +} + +// ---- helpers ---- + +func findShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, s := range Shortcuts() { + if s.Name == name { + return s + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +// mockTagsHandler returns a handler that responds to the issue_tags API. +func mockTagsHandler(t *testing.T) http.HandlerFunc { + t.Helper() + return func(w http.ResponseWriter, r *http.Request) { + tags := make([]map[string]interface{}, 0, len(testTags)) + for name, id := range testTags { + tags = append(tags, map[string]interface{}{ + "id": float64(id), + "name": name, + }) + } + writeJSONResp(t, w, map[string]interface{}{"issue_tags": tags}) + } +} + +func runBatchCreateShortcut(t *testing.T, server *httptest.Server, args map[string]string) error { + t.Helper() + s := findShortcut(t, "batch-create") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + return s.Run(ctx) +} + +func writeJSONResp(t *testing.T, w http.ResponseWriter, v interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) +} + +func decodeReqBody(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode body: %v", err) + } + return payload +} + +// ---- runBatchCreate tests ---- + +func TestBatchCreate_DryRun(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags") { + mockTagsHandler(t)(w, r) + return + } + t.Fatalf("no API calls expected in dry-run mode, got %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "titles": "标题1,标题2", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBatchCreate_FromTitles(t *testing.T) { + var createdBodies []map[string]interface{} + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags"): + mockTagsHandler(t)(w, r) + case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/v1/owner/repo/issues"): + callCount++ + body := decodeReqBody(t, r) + createdBodies = append(createdBodies, body) + writeJSONResp(t, w, map[string]interface{}{ + "project_issues_index": float64(100 + callCount), + }) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "titles": "Bug修复,功能开发", + "state": "new", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if callCount != 2 { + t.Fatalf("expected 2 API calls, got %d", callCount) + } + if createdBodies[0]["subject"] != "Bug修复" { + t.Fatalf("first title: got %q, want %q", createdBodies[0]["subject"], "Bug修复") + } + if createdBodies[1]["subject"] != "功能开发" { + t.Fatalf("second title: got %q, want %q", createdBodies[1]["subject"], "功能开发") + } + // Verify required fields — values come through JSON as float64 + for i, body := range createdBodies { + if body["done_ratio"] != float64(0) { + t.Fatalf("body[%d]: done_ratio = %v (type %T), want 0", i, body["done_ratio"], body["done_ratio"]) + } + } +} + +func TestBatchCreate_NoTitles(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags") { + mockTagsHandler(t)(w, r) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "dry-run": "false", + }) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestBatchCreate_FromCSV(t *testing.T) { + csvPath := writeTempCSV(t, "title,priority,label,status\nCSV标题1,high,缺陷,new\nCSV标题2,normal,功能,new\n") + + var created []map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags"): + mockTagsHandler(t)(w, r) + case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/v1/owner/repo/issues"): + created = append(created, decodeReqBody(t, r)) + writeJSONResp(t, w, map[string]interface{}{"project_issues_index": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "from": csvPath, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(created) != 2 { + t.Fatalf("expected 2 creates, got %d", len(created)) + } + if created[0]["subject"] != "CSV标题1" { + t.Fatalf("first subject: got %q", created[0]["subject"]) + } + if created[1]["subject"] != "CSV标题2" { + t.Fatalf("second subject: got %q", created[1]["subject"]) + } +} + +func TestBatchCreate_CSVMissingTitleColumn(t *testing.T) { + csvPath := writeTempCSV(t, "name,description\nval1,desc1\n") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags") { + mockTagsHandler(t)(w, r) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{"from": csvPath}) + if err == nil { + t.Fatal("expected error for missing title column, got nil") + } +} + +func TestBatchCreate_CSVOnlyHeader(t *testing.T) { + csvPath := writeTempCSV(t, "title,description\n") + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags") { + mockTagsHandler(t)(w, r) + return + } + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{"from": csvPath}) + if err == nil { + t.Fatal("expected error for header-only CSV, got nil") + } +} + +func TestBatchCreate_PartialFailure(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && strings.Contains(r.URL.Path, "/issue_tags"): + mockTagsHandler(t)(w, r) + case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/v1/owner/repo/issues"): + callCount++ + if callCount == 2 { + w.WriteHeader(http.StatusUnprocessableEntity) + return + } + writeJSONResp(t, w, map[string]interface{}{"project_issues_index": float64(callCount)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "titles": "ok1,fail1,ok2", + }) + if err == nil { + t.Fatal("expected error from partial failure, got nil") + } + if !strings.Contains(err.Error(), "failed to create") { + t.Fatalf("error should mention failed count, got: %v", err) + } +} + +// ---- buildCreateBody tests (direct call, values retain Go types) ---- + +func intVal(v interface{}) int { + switch n := v.(type) { + case int: + return n + case float64: + return int(n) + } + return -999 +} + +func TestBuildCreateBody_Basic(t *testing.T) { + ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}} + input := createIssueInput{Title: "Test issue", Status: "new"} + body := buildCreateBody(ctx, input, "", testTags) + if body["subject"] != "Test issue" { + t.Fatalf("subject: got %v", body["subject"]) + } + if intVal(body["done_ratio"]) != 0 { + t.Fatalf("done_ratio: got %v (%T), want 0", body["done_ratio"], body["done_ratio"]) + } + if intVal(body["status_id"]) != 1 { + t.Fatalf("status_id: got %v (%T), want 1", body["status_id"], body["status_id"]) + } + if intVal(body["priority_id"]) != 2 { + t.Fatalf("priority_id: got %v (%T), want 2", body["priority_id"], body["priority_id"]) + } +} + +func TestBuildCreateBody_BugTemplate(t *testing.T) { + ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}} + input := createIssueInput{ + Title: "登录报错", + Version: "v2.0", + Severity: "严重", + Steps: "1. 打开页面\n2. 点击登录", + Expected: "正常登录", + Actual: "报错 500", + } + body := buildCreateBody(ctx, input, "bug", testTags) + if body["subject"] != "登录报错" { + t.Fatalf("subject: got %v", body["subject"]) + } + desc, _ := body["description"].(string) + if !strings.Contains(desc, "## Bug 描述") { + t.Fatal("bug description missing header") + } + if !strings.Contains(desc, "v2.0") { + t.Fatal("bug description missing version") + } + if !strings.Contains(desc, "严重") { + t.Fatal("bug description missing severity") + } + if rawTags, ok := body["issue_tag_ids"]; !ok { + t.Fatal("bug template missing issue_tag_ids") + } else { + ids := rawTags.([]interface{}) + if intVal(ids[0]) != testTags["缺陷"] { + t.Fatalf("tag: got %v (type %T), want %v", ids[0], ids[0], testTags["缺陷"]) + } + } +} + +func TestBuildCreateBody_FeatureTemplate(t *testing.T) { + ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}} + input := createIssueInput{ + Title: "用户搜索", + UserStory: "作为用户,我想搜索内容", + Acceptance: "搜索结果正确显示", + } + body := buildCreateBody(ctx, input, "feature", testTags) + desc, _ := body["description"].(string) + if !strings.Contains(desc, "## 用户故事") { + t.Fatal("feature description missing user story header") + } + if !strings.Contains(desc, "作为用户") { + t.Fatal("feature description missing user story content") + } + if !strings.Contains(desc, "## 验收标准") { + t.Fatal("feature description missing acceptance criteria") + } +} + +func TestBuildCreateBody_WithPriorityLabel(t *testing.T) { + ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}} + input := createIssueInput{ + Title: "紧急修复", + Priority: "high", + Label: "缺陷", + } + body := buildCreateBody(ctx, input, "", testTags) + if intVal(body["priority_id"]) != 3 { + t.Fatalf("priority_id: got %v (type %T), want 3 (high)", body["priority_id"], body["priority_id"]) + } + if rawTags, ok := body["issue_tag_ids"]; ok { + ids := rawTags.([]interface{}) + if intVal(ids[0]) != testTags["缺陷"] { + t.Fatalf("tag: got %v (type %T), want %v", ids[0], ids[0], testTags["缺陷"]) + } + } else { + t.Fatal("missing issue_tag_ids") + } +} + +// ---- buildBugDescription tests ---- + +func TestBuildBugDescription_AllFields(t *testing.T) { + input := createIssueInput{ + Title: "登录报错", + Version: "v2.0", + Severity: "严重", + Steps: "1. 打开", + Expected: "正常", + Actual: "500错误", + } + result := buildBugDescription(input) + if !strings.Contains(result, "## Bug 描述") { + t.Fatal("missing Bug 描述") + } + if !strings.Contains(result, "登录报错") { + t.Fatal("missing title") + } + if !strings.Contains(result, "## 版本") { + t.Fatal("missing 版本") + } + if !strings.Contains(result, "## 严重程度") { + t.Fatal("missing 严重程度") + } + if !strings.Contains(result, "## 复现步骤") { + t.Fatal("missing 复现步骤") + } + if !strings.Contains(result, "## 期望结果") { + t.Fatal("missing 期望结果") + } + if !strings.Contains(result, "## 实际结果") { + t.Fatal("missing 实际结果") + } +} + +func TestBuildBugDescription_PartialFields(t *testing.T) { + input := createIssueInput{Title: "小问题"} + result := buildBugDescription(input) + if !strings.Contains(result, "## Bug 描述") { + t.Fatal("missing header") + } + if strings.Contains(result, "## 版本") { + t.Fatal("should not have version section") + } + if strings.Contains(result, "## 严重程度") { + t.Fatal("should not have severity section") + } +} + +// ---- buildFeatureDescription tests ---- + +func TestBuildFeatureDescription_AllFields(t *testing.T) { + input := createIssueInput{ + Title: "搜索功能", + UserStory: "作为用户想搜索", + Body: "详细描述", + Acceptance: "搜索结果正确", + Priority: "high", + } + result := buildFeatureDescription(input) + if !strings.Contains(result, "## 用户故事") { + t.Fatal("missing user story") + } + if !strings.Contains(result, "作为用户想搜索") { + t.Fatal("missing user story content") + } + if !strings.Contains(result, "## 描述") { + t.Fatal("missing description") + } + if !strings.Contains(result, "## 验收标准") { + t.Fatal("missing acceptance criteria") + } + if !strings.Contains(result, "## 优先级") { + t.Fatal("missing priority") + } +} + +func TestBuildFeatureDescription_FallbackToTitleAsUserStory(t *testing.T) { + input := createIssueInput{Title: "搜索功能"} + result := buildFeatureDescription(input) + if !strings.Contains(result, "搜索功能") { + t.Fatal("should fall back to title as user story") + } +} + +// ---- readCreateInputsFromCSV tests ---- + +func TestReadCreateInputsFromCSV_Normal(t *testing.T) { + path := writeTempCSV(t, "title,priority,label,status,version,severity,steps,expected,actual\n标题1,high,缺陷,new,v1,严重,,,\n标题2,normal,功能,new,,,,,\n") + + inputs, err := readCreateInputsFromCSV(path, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(inputs) != 2 { + t.Fatalf("got %d inputs, want 2", len(inputs)) + } + if inputs[0].Title != "标题1" { + t.Fatalf("first title: got %q", inputs[0].Title) + } + if inputs[0].Severity != "严重" { + t.Fatalf("severity: got %q", inputs[0].Severity) + } + if inputs[1].Label != "功能" { + t.Fatalf("label: got %q", inputs[1].Label) + } +} + +func TestReadCreateInputsFromCSV_MissingTitleColumn(t *testing.T) { + path := writeTempCSV(t, "name,description\nval1,desc1\n") + _, err := readCreateInputsFromCSV(path, "") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestReadCreateInputsFromCSV_OnlyHeader(t *testing.T) { + path := writeTempCSV(t, "title,priority\n") + _, err := readCreateInputsFromCSV(path, "") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestReadCreateInputsFromCSV_SkipsEmptyTitle(t *testing.T) { + path := writeTempCSV(t, "title,priority\n标题1,high\n,normal\n标题2,low\n") + inputs, err := readCreateInputsFromCSV(path, "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(inputs) != 2 { + t.Fatalf("got %d inputs, want 2 (empty row skipped)", len(inputs)) + } +} + +// ---- parseTitles tests ---- + +func TestParseTitles_CommaSeparated(t *testing.T) { + ctx := &common.RuntimeContext{ + Owner: "o", Repo: "r", + Args: map[string]string{"priority": "normal"}, + } + inputs := parseTitles("标题1, 标题2, , 标题3", ctx) + if len(inputs) != 3 { + t.Fatalf("got %d inputs, want 3", len(inputs)) + } + if inputs[0].Title != "标题1" { + t.Fatalf("got %q", inputs[0].Title) + } + if inputs[2].Title != "标题3" { + t.Fatalf("got %q", inputs[2].Title) + } + if inputs[0].Priority != "normal" { + t.Fatalf("priority not propagated: got %q", inputs[0].Priority) + } +} + +// ---- normalizeHeader tests ---- + +func TestNormalizeHeader(t *testing.T) { + cases := []struct{ in, want string }{ + {"Title", "title"}, + {" PRIORITY ", "priority"}, + {"user_story", "user_story"}, + {"User Story", "user story"}, + } + for _, c := range cases { + got := normalizeHeader(c.in) + if got != c.want { + t.Fatalf("normalizeHeader(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// ---- getCol tests ---- + +func TestGetCol(t *testing.T) { + col := map[string]int{"title": 0, "priority": 1} + record := []string{"测试标题", "high"} + if got := getCol(record, col, "title"); got != "测试标题" { + t.Fatalf("got %q", got) + } + if got := getCol(record, col, "missing"); got != "" { + t.Fatalf("got %q, want empty", got) + } + if got := getCol(record, col, "priority"); got != "high" { + t.Fatalf("got %q", got) + } +} + +// ---- truncate tests ---- + +func TestTruncate(t *testing.T) { + if got := truncate("short", 40); got != "short" { + t.Fatalf("got %q", got) + } + long := "这是一个很长的标题用来测试截断功能一二三四五六七八九十" + got := truncate(long, 10) + if len([]rune(got)) > 13 { + t.Fatalf("truncated too long: %q (%d runes)", got, len([]rune(got))) + } + if !strings.HasSuffix(got, "...") { + t.Fatal("truncated string should end with ...") + } +} + +// ---- priority/label/status parse helpers ---- + +func TestParsePriorityStrings(t *testing.T) { + cases := []struct { + in string + want int + }{ + {"low", 1}, {"normal", 2}, {"high", 3}, {"urgent", 4}, + {"LOW", 1}, {"High", 3}, + } + for _, c := range cases { + got, err := parsePriority(c.in) + if err != nil { + t.Fatalf("parsePriority(%q): %v", c.in, err) + } + if got != c.want { + t.Fatalf("parsePriority(%q) = %d, want %d", c.in, got, c.want) + } + } +} + +func TestParsePriorityNumeric(t *testing.T) { + got, err := parsePriority("5") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != 5 { + t.Fatalf("got %d, want 5", got) + } +} + +func TestParsePriorityInvalid(t *testing.T) { + if _, err := parsePriority("invalid"); err == nil { + t.Fatal("expected error") + } +} + +func TestParseLabelValid(t *testing.T) { + id, err := parseLabel("缺陷", testTags) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != testTags["缺陷"] { + t.Fatalf("got %d, want %d", id, testTags["缺陷"]) + } +} + +func TestParseLabelInvalid(t *testing.T) { + if _, err := parseLabel("不存在的标签", testTags); err == nil { + t.Fatal("expected error") + } +} + +func TestParseLabelNumeric(t *testing.T) { + id, err := parseLabel("999", testTags) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if id != 999 { + t.Fatalf("got %d, want 999", id) + } +} + +func TestLabelNamesReturnsAll(t *testing.T) { + names := labelNames(testTags) + if !strings.Contains(names, "缺陷") { + t.Fatal("missing 缺陷 in label names") + } + if !strings.Contains(names, "功能") { + t.Fatal("missing 功能 in label names") + } +} + +// ---- buildCreateBody status tests ---- + +func TestBuildCreateBody_DefaultStatus(t *testing.T) { + ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}} + input := createIssueInput{Title: "t", Status: ""} + body := buildCreateBody(ctx, input, "", testTags) + if intVal(body["status_id"]) != 1 { + t.Fatalf("default status_id: got %v (type %T), want 1", body["status_id"], body["status_id"]) + } +} + +func TestBuildCreateBody_ClosedStatus(t *testing.T) { + ctx := &common.RuntimeContext{Owner: "o", Repo: "r", Args: map[string]string{}} + input := createIssueInput{Title: "t", Status: "closed"} + body := buildCreateBody(ctx, input, "", testTags) + if intVal(body["status_id"]) != 5 { + t.Fatalf("closed status_id: got %v (type %T), want 5", body["status_id"], body["status_id"]) + } +} + +// ---- regression ---- + +func TestCollectIssueNumbers_FromBatchCreatePerspective(t *testing.T) { + got, err := collectIssueNumbers("1,2,3", "") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []string{"1", "2", "3"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %v, want %v", got, want) + } +} diff --git a/shortcuts/issue/issue.go b/shortcuts/issue/issue.go index 4ccc575..b6501da 100644 --- a/shortcuts/issue/issue.go +++ b/shortcuts/issue/issue.go @@ -1,27 +1,39 @@ package issue import ( - "fmt" + "fmt" //格式化字符串 "net/url" - "strconv" + "strconv" //字符串和数字转换 "strings" - "github.com/gitlink-org/gitlink-cli/shortcuts/common" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) +// Ctx // v1RepoPath returns the v1 API path prefix: /v1/{owner}/{repo} func v1RepoPath(ctx *common.RuntimeContext) string { return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo) } +//内部数据结构,标题+描述,保存从 API 取回来的 Issue 原始数据 type existingIssue struct { Subject string Description string } +// 所有issue命令的注册入口,返回所有issue命令 +// 在终端敲入issue +list命令时,遍历匹配到列表里的{Name: "list", ...} func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ newBatchCloseShortcut(), + newBatchStatusShortcut(), + newBatchPriorityShortcut(), + newBatchAssignShortcut(), + newBatchLabelShortcut(), + newBatchCreateShortcut(), + newLabelAddShortcut(), + newLabelRemoveShortcut(), + newLabelListShortcut(), { Name: "list", Description: "List issues", @@ -30,26 +42,33 @@ func Shortcuts() []*common.Shortcut { {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, }, - Run: func(ctx *common.RuntimeContext) error { + //后面拼接 API 路径时,需要用到 ctx.Owner 和 ctx.Repo + // 如果不知道 owner 和 repo ,后续的 API 调用就不知道该往哪发请求,所以必须作为前置校验放在最前面。 + Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - q := url.Values{} - q.Set("page", ctx.Arg("page")) - q.Set("limit", ctx.Arg("limit")) - if s := ctx.Arg("state"); s != "" { + q := url.Values{} // 创建空的URL查询参数容器q,后续通过set()添加键值对,最终拼接成形如 ?page=1&limit=20&state=open 的查询字符串 + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) //从命令行参数中读取 page(页码)和 limit(每页条数 + if s := ctx.Arg("state"); s != "" { // 如果用户传入了state参数 q.Set("state", s) } env, err := ctx.CallAPIWithQuery("GET", v1RepoPath(ctx)+"/issues", q) if err != nil { - return err + return fmt.Errorf("获取 Issue 列表失败: %w", err) } - return ctx.Output(env) + return ctx.Output(env) //按照用户指定的格式(json/html/table...)输出issue列表 }, }, { Name: "create", Description: "Create a new issue", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + title := ctx.Arg("title") + return fmt.Sprintf("Create issue: %s", title), nil + }, Flags: []common.Flag{ {Name: "title", Short: "t", Usage: "Issue title", Required: true}, {Name: "body", Short: "b", Usage: "Issue description"}, @@ -61,7 +80,7 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - title, err := ctx.RequireArg("title") + title, err := ctx.RequireArg("title", `--title "Bug: 登录页崩溃"`) if err != nil { return err } @@ -82,7 +101,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", v1RepoPath(ctx)+"/issues", body) if err != nil { - return err + return fmt.Errorf("创建 Issue 失败: %w", err) } return ctx.Output(env) }, @@ -97,13 +116,13 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - number, err := ctx.RequireArg("number") + number, err := ctx.RequireArg("number", "--number 42") if err != nil { return err } env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil) if err != nil { - return err + return fmt.Errorf("查看 Issue 失败: %w", err) } return ctx.Output(env) }, @@ -111,6 +130,11 @@ func Shortcuts() []*common.Shortcut { { Name: "close", Description: "Close an issue", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + number := ctx.Arg("number") + return fmt.Sprintf("Close issue #%s", number), nil + }, Flags: []common.Flag{ {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, }, @@ -118,7 +142,7 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - number, err := ctx.RequireArg("number") + number, err := ctx.RequireArg("number", "--number 42") if err != nil { return err } @@ -133,6 +157,37 @@ func Shortcuts() []*common.Shortcut { "status_id": 5, // 5 = closed } env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) + if err != nil { + return fmt.Errorf("关闭 Issue 失败: %w", err) + } + return ctx.Output(env) + }, + }, + { + Name: "reopen", + Description: "Reopen a closed issue", + Flags: []common.Flag{ + {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, err := ctx.RequireArg("number", "--number 42") + if err != nil { + return err + } + current, err := fetchExistingIssue(ctx, number) + if err != nil { + return err + } + + body := map[string]interface{}{ + "subject": current.Subject, + "description": current.Description, + "status_id": 1, // 1 = open + } + env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) if err != nil { return err } @@ -142,6 +197,11 @@ func Shortcuts() []*common.Shortcut { { Name: "update", Description: "Update an issue", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + number := ctx.Arg("number") + return fmt.Sprintf("Update issue #%s", number), nil + }, Flags: []common.Flag{ {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, {Name: "title", Short: "t", Usage: "New title"}, @@ -152,7 +212,7 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - number, err := ctx.RequireArg("number") + number, err := ctx.RequireArg("number", "--number 42") if err != nil { return err } @@ -187,7 +247,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), body) if err != nil { - return err + return fmt.Errorf("更新 Issue 失败: %w", err) } return ctx.Output(env) }, @@ -195,6 +255,11 @@ func Shortcuts() []*common.Shortcut { { Name: "comment", Description: "Add a comment to an issue", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + number := ctx.Arg("number") + return fmt.Sprintf("Add comment to issue #%s", number), nil + }, Flags: []common.Flag{ {Name: "number", Short: "n", Usage: "Issue number (as shown in the web URL)", Required: true}, {Name: "body", Short: "b", Usage: "Comment body", Required: true}, @@ -203,11 +268,11 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - number, err := ctx.RequireArg("number") + number, err := ctx.RequireArg("number", "--number 42") if err != nil { return err } - body, err := ctx.RequireArg("body") + body, err := ctx.RequireArg("body", `--body "可以这样复现..."`) if err != nil { return err } @@ -216,7 +281,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/journals", v1RepoPath(ctx), number), payload) if err != nil { - return err + return fmt.Errorf("添加 Issue 评论失败: %w", err) } return ctx.Output(env) }, @@ -227,7 +292,7 @@ func Shortcuts() []*common.Shortcut { func fetchExistingIssue(ctx *common.RuntimeContext, number string) (*existingIssue, error) { getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s", v1RepoPath(ctx), number), nil) if err != nil { - return nil, err + return nil, fmt.Errorf("获取 Issue 信息失败: %w", err) } issueData, ok := getEnv.Data.(map[string]interface{}) if !ok { diff --git a/shortcuts/issue/label.go b/shortcuts/issue/label.go new file mode 100644 index 0000000..bc5e279 --- /dev/null +++ b/shortcuts/issue/label.go @@ -0,0 +1,77 @@ +package issue + +import ( + "fmt" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func newLabelAddShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "label-add", + Description: "Add labels to an issue", + Flags: []common.Flag{ + {Name: "number", Short: "n", Usage: "Issue number", Required: true}, + {Name: "labels", Short: "l", Usage: "Comma-separated label names or IDs", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, _ := ctx.RequireArg("number", "--number 42") + labelsStr, _ := ctx.RequireArg("labels", `--labels "bug,urgent"`) + body := map[string]interface{}{ + "labels": labelsStr, + } + env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/issues/%s/labels", v1RepoPath(ctx), number), body) + if err != nil { + return err + } + return ctx.Output(env) + }, + } +} + +func newLabelRemoveShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "label-remove", + Description: "Remove a label from an issue", + Flags: []common.Flag{ + {Name: "number", Short: "n", Usage: "Issue number", Required: true}, + {Name: "label", Short: "l", Usage: "Label ID to remove", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, _ := ctx.RequireArg("number", "--number 42") + label, _ := ctx.RequireArg("label", "--label bug") + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("%s/issues/%s/labels/%s", v1RepoPath(ctx), number, label), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + } +} + +func newLabelListShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "label-list", + Description: "List labels on an issue", + Flags: []common.Flag{ + {Name: "number", Short: "n", Usage: "Issue number", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + number, _ := ctx.RequireArg("number", "--number 42") + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/issues/%s/labels", v1RepoPath(ctx), number), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + } +} diff --git a/shortcuts/milestone/milestone.go b/shortcuts/milestone/milestone.go new file mode 100644 index 0000000..23d55da --- /dev/null +++ b/shortcuts/milestone/milestone.go @@ -0,0 +1,115 @@ +package milestone + +import ( + "fmt" + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List milestones", + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1%s/milestones", ctx.RepoPath()), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create", + Description: "Create a milestone", + Flags: []common.Flag{ + {Name: "name", Short: "n", Usage: "Milestone name", Required: true}, + {Name: "description", Short: "d", Usage: "Description"}, + {Name: "due", Usage: "Due date (YYYY-MM-DD)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + name, _ := ctx.RequireArg("name", `--name "My Name"`) + body := map[string]interface{}{ + "title": name, + } + if d := ctx.Arg("description"); d != "" { + body["description"] = d + } + if due := ctx.Arg("due"); due != "" { + body["due_date"] = due + } + env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1%s/milestones", ctx.RepoPath()), body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "update", + Description: "Update a milestone", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Milestone ID", Required: true}, + {Name: "name", Short: "n", Usage: "New name"}, + {Name: "description", Short: "d", Usage: "New description"}, + {Name: "due", Usage: "New due date (YYYY-MM-DD)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, _ := ctx.RequireArg("id", "--id 1") + body := map[string]interface{}{} + if n := ctx.Arg("name"); n != "" { + body["title"] = n + } + if d := ctx.Arg("description"); d != "" { + body["description"] = d + } + if due := ctx.Arg("due"); due != "" { + body["due_date"] = due + } + if len(body) == 0 { + return fmt.Errorf("at least one of --name, --description, --due is required") + } + env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/v1%s/milestones/%s", ctx.RepoPath(), id), body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "delete", + Description: "Delete a milestone", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Milestone ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, _ := ctx.RequireArg("id", "--id 1") + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/v1%s/milestones/%s", ctx.RepoPath(), id), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} diff --git a/shortcuts/onboard/onboarding.go b/shortcuts/onboard/onboarding.go new file mode 100644 index 0000000..ce61238 --- /dev/null +++ b/shortcuts/onboard/onboarding.go @@ -0,0 +1,393 @@ +package onboard + +import ( + "fmt" + "net/url" + "strconv" + "strings" + "sync" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// marker is a unique string embedded in welcome comments to detect existing ones. +const marker = "" + +// tagCache caches tag name→id mappings per owner/repo. +var tagCache sync.Map + +// Shortcuts returns the onboarding shortcut group. +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "welcome", + Description: "Add welcome comments to specific issues or tag-matched issues", + Flags: []common.Flag{ + {Name: "issues", Short: "i", Usage: "Comma-separated issue numbers (e.g. 1,3,7)"}, + {Name: "tag", Short: "t", Usage: "Tag name to match (comma-separated)", Default: "good first issue,help wanted"}, + {Name: "template", Usage: "Custom welcome message template ({login}, {number}, {subject}, {description})"}, + {Name: "force", Short: "f", Usage: "Force re-add even if already commented", Bool: true}, + }, + DryRun: true, + DryRunHint: dryRunHint, + Run: runWelcome, + }, + } +} + +func dryRunHint(ctx *common.RuntimeContext) (string, error) { + if issues := ctx.Arg("issues"); issues != "" { + return fmt.Sprintf("将为 issue #%s 添加新人引导评论", issues), nil + } + tag := ctx.Arg("tag") + if tag == "" { + tag = "good first issue,help wanted" + } + return fmt.Sprintf("将为所有 [%s] 标签的 issue 添加新人引导评论", tag), nil +} + +func runWelcome(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + // --issues takes priority over --tag + if issueArg := ctx.Arg("issues"); issueArg != "" { + return runWelcomeByIssueNumbers(ctx, issueArg) + } + + tagNames := ctx.Arg("tag") + if tagNames == "" { + tagNames = "good first issue,help wanted" + } + + // resolve tag names → ids + tagMap, err := resolveTags(ctx) + if err != nil { + return err + } + + var tagIDs []string + for _, name := range strings.Split(tagNames, ",") { + name = strings.TrimSpace(name) + if id, ok := tagMap[name]; ok { + tagIDs = append(tagIDs, strconv.Itoa(id)) + } + } + if len(tagIDs) == 0 { + return fmt.Errorf("未找到匹配的标签: %s (可用: %v)", tagNames, tagNamesList(tagMap)) + } + + // fetch open issues with these tags + issues, err := fetchTaggedIssues(ctx, tagIDs) + if err != nil { + return err + } + + return processIssues(ctx, issues) +} + +// runWelcomeByIssueNumbers directly processes specified issue numbers, +// skipping tag resolution and tag-based issue fetching. +func runWelcomeByIssueNumbers(ctx *common.RuntimeContext, issueArg string) error { + issueNums, err := parseIssueNumbers(issueArg) + if err != nil { + return err + } + + var issues []issueInfo + for _, num := range issueNums { + info, err := fetchIssueDetail(ctx, num) + if err != nil { + return fmt.Errorf("获取 issue #%d 失败: %w", num, err) + } + issues = append(issues, info) + } + + return processIssues(ctx, issues) +} + +// parseIssueNumbers parses a comma-separated string of issue numbers. +func parseIssueNumbers(s string) ([]int, error) { + parts := strings.Split(s, ",") + var nums []int + for _, p := range parts { + p = strings.TrimSpace(p) + if p == "" { + continue + } + n, err := strconv.Atoi(p) + if err != nil { + return nil, fmt.Errorf("无效的 issue 编号: %q (必须是数字)", p) + } + nums = append(nums, n) + } + if len(nums) == 0 { + return nil, fmt.Errorf("--issues 参数为空") + } + return nums, nil +} + +// fetchIssueDetail fetches a single issue's subject and description by number. +func fetchIssueDetail(ctx *common.RuntimeContext, issueNumber int) (issueInfo, error) { + path := fmt.Sprintf("/v1/%s/%s/issues/%d", ctx.Owner, ctx.Repo, issueNumber) + env, err := ctx.CallAPIWithQuery("GET", path, nil) + if err != nil { + return issueInfo{}, err + } + data, _ := env.Data.(map[string]interface{}) + subj := getString(data, "subject") + if subj == "" { + subj = fmt.Sprintf("issue #%d", issueNumber) + } + desc := getString(data, "description") + return issueInfo{number: issueNumber, subject: subj, description: desc}, nil +} + +// processIssues handles the common issue processing loop used by both +// --issues and --tag paths. +func processIssues(ctx *common.RuntimeContext, issues []issueInfo) error { + tmpl := ctx.Arg("template") + if tmpl == "" { + tmpl = "" + } + + type result struct { + num int + action string + msg string + } + var results []result + + for _, issue := range issues { + force := ctx.Arg("force") == "true" + if !force && hasWelcomeComment(ctx, issue.number) { + results = append(results, result{issue.number, "skipped", fmt.Sprintf("#%d \"%s\" — 已有引导评论,跳过", issue.number, issue.subject)}) + continue + } + + // Render per-issue message with issue-specific variables. + body := renderComment(ctx, issue, tmpl) + + if ctx.IsDryRun() { + fmt.Printf("\n--- 预览 #%d \"%s\" ---\n%s\n---\n", issue.number, issue.subject, body) + proceed, err := common.ConfirmAction(ctx) + if err != nil { + return err + } + if !proceed { + results = append(results, result{issue.number, "skipped", "用户取消"}) + continue + } + } + + if err := addComment(ctx, issue.number, body); err != nil { + results = append(results, result{issue.number, "error", err.Error()}) + } else { + results = append(results, result{issue.number, "added", fmt.Sprintf("#%d \"%s\" — 已添加引导评论", issue.number, issue.subject)}) + } + } + + // output + added := 0 + skipped := 0 + errors := 0 + for _, r := range results { + switch r.action { + case "added": + added++ + case "skipped": + skipped++ + case "error": + errors++ + } + } + fmt.Printf("\n完成: 添加 %d, 跳过 %d, 错误 %d\n", added, skipped, errors) + for _, r := range results { + fmt.Printf(" [%s] %s\n", r.action, r.msg) + } + + return nil +} + +func resolveTags(ctx *common.RuntimeContext) (map[string]int, error) { + key := ctx.Owner + "/" + ctx.Repo + if cached, ok := tagCache.Load(key); ok { + return cached.(map[string]int), nil + } + + path := fmt.Sprintf("/v1/%s/%s/issue_tags", ctx.Owner, ctx.Repo) + q := url.Values{} + q.Set("only_name", "true") + env, err := ctx.CallAPIWithQuery("GET", path, q) + if err != nil { + return nil, fmt.Errorf("获取标签列表失败: %w", err) + } + + data, _ := env.Data.(map[string]interface{}) + raw, _ := data["issue_tags"].([]interface{}) + tags := make(map[string]int) + for _, item := range raw { + if t, ok := item.(map[string]interface{}); ok { + if name, ok := t["name"].(string); ok && name != "" { + switch v := t["id"].(type) { + case float64: + tags[name] = int(v) + case int: + tags[name] = v + } + } + } + } + if len(tags) == 0 { + return nil, fmt.Errorf("项目没有配置任务标签,请先在 GitLink 网页端创建") + } + tagCache.Store(key, tags) + return tags, nil +} + +func tagNamesList(tags map[string]int) []string { + var names []string + for n := range tags { + names = append(names, n) + } + return names +} + +type issueInfo struct { + number int + subject string + description string +} + +func fetchTaggedIssues(ctx *common.RuntimeContext, tagIDs []string) ([]issueInfo, error) { + var all []issueInfo + page := 1 + + for { + q := url.Values{} + q.Set("state", "open") + q.Set("page", strconv.Itoa(page)) + q.Set("limit", "100") + q.Set("issue_tag_ids", strings.Join(tagIDs, ",")) + + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1/%s/%s/issues", ctx.Owner, ctx.Repo), q) + if err != nil { + return nil, err + } + + data, _ := env.Data.(map[string]interface{}) + issues, _ := data["issues"].([]interface{}) + if len(issues) == 0 { + break + } + + for _, item := range issues { + if issue, ok := item.(map[string]interface{}); ok { + all = append(all, issueInfo{ + number: getInt(issue, "project_issues_index"), + subject: getString(issue, "subject"), + }) + } + } + + total := getInt(data, "total_count") + if page*100 >= total { + break + } + page++ + } + + return all, nil +} + +func hasWelcomeComment(ctx *common.RuntimeContext, issueNumber int) bool { + path := fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueNumber) + q := url.Values{} + q.Set("limit", "100") + env, err := ctx.CallAPIWithQuery("GET", path, q) + if err != nil { + return false + } + data, _ := env.Data.(map[string]interface{}) + journals, _ := data["journals"].([]interface{}) + for _, j := range journals { + if jm, ok := j.(map[string]interface{}); ok { + if notes := getString(jm, "notes"); strings.Contains(notes, marker) { + return true + } + } + } + return false +} + +func addComment(ctx *common.RuntimeContext, issueNumber int, body string) error { + path := fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueNumber) + payload := map[string]interface{}{"notes": marker + "\n\n" + body} + _, err := ctx.CallAPI("POST", path, payload) + return err +} + +// renderComment renders the comment body for a specific issue. +// It uses the --template if provided, otherwise generates an issue-aware default. +func renderComment(ctx *common.RuntimeContext, issue issueInfo, customTmpl string) string { + tmpl := customTmpl + if tmpl == "" { + tmpl = defaultTemplate(ctx.Owner, ctx.Repo, issue) + } + body := strings.NewReplacer( + "{login}", ctx.Owner, + "{number}", strconv.Itoa(issue.number), + "{subject}", issue.subject, + "{description}", issue.description, + ).Replace(tmpl) + return body +} + +// defaultTemplate returns an issue-aware onboarding message. +func defaultTemplate(owner, repo string, issue issueInfo) string { + summary := issue.subject + if len(issue.description) > 200 { + summary = issue.description[:200] + "..." + } else if issue.description != "" { + summary = issue.description + } + return fmt.Sprintf(`## 欢迎贡献!:wave: + +感谢你对 [%s/%s](https://www.gitlink.org.cn/%s/%s) 的关注。 + +### :bulb: 关于本 Issue:{subject} + +%s + +### :rocket: 参与步骤 +1. **Fork 仓库** 并克隆到本地 +2. 创建新分支:` + "`git checkout -b fix/issue-{number}`" + ` +3. 参照上方 issue 描述修改代码 +4. 推送到你的 Fork 后创建 Pull Request + +### :memo: 注意事项 +- 请先阅读 [CONTRIBUTING.md](https://www.gitlink.org.cn/%s/%s/src/master/CONTRIBUTING.md)(如有) +- 如有疑问,欢迎在评论区留言讨论 + +期待你的 PR!`, owner, repo, owner, repo, summary, owner, repo) +} + +func getString(m map[string]interface{}, key string) string { + if v, ok := m[key]; ok { + if s, ok := v.(string); ok { + return s + } + } + return "" +} + +func getInt(m map[string]interface{}, key string) int { + switch v := m[key].(type) { + case float64: + return int(v) + case int: + return v + } + return 0 +} diff --git a/shortcuts/org/batch.go b/shortcuts/org/batch.go new file mode 100644 index 0000000..26c8289 --- /dev/null +++ b/shortcuts/org/batch.go @@ -0,0 +1,147 @@ +package org + +import ( + "encoding/csv" + "fmt" + "os" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// BatchShortcuts 返回组织级批量成员管理 Shortcut +func BatchShortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "batch-invite", + Description: "Batch invite members to a project (from --users list or --from CSV file)", + Flags: []common.Flag{ + {Name: "users", Usage: "Comma-separated list of user IDs to invite"}, + {Name: "from", Usage: "CSV file with user IDs (column: user_id)"}, + {Name: "owner", Short: "o", Usage: "Project owner (e.g., zzx-coder)", Required: true}, + {Name: "repo", Short: "r", Usage: "Project repo name (e.g., gitlink-cli)", Required: true}, + {Name: "dry-run", Usage: "Preview operations without executing", Bool: true}, + }, + Run: runOrgBatchInvite, + }, + { + Name: "batch-remove", + Description: "Batch remove members from a project (from --users list or --from CSV file)", + Flags: []common.Flag{ + {Name: "users", Usage: "Comma-separated list of user IDs to remove"}, + {Name: "from", Usage: "CSV file with user IDs (column: user_id)"}, + {Name: "owner", Short: "o", Usage: "Project owner (e.g., zzx-coder)", Required: true}, + {Name: "repo", Short: "r", Usage: "Project repo name (e.g., gitlink-cli)", Required: true}, + {Name: "dry-run", Usage: "Preview operations without executing", Bool: true}, + }, + Run: runOrgBatchRemove, + }, + } +} + +// runOrgBatchInvite 组织级批量邀请 +func runOrgBatchInvite(ctx *common.RuntimeContext) error { + userIDs, err := parseBatchUserIDs(ctx) + if err != nil { + return err + } + dryRun := ctx.Arg("dry-run") == "true" + + return inviteToOrgProjects(ctx, userIDs, dryRun) +} + +// runOrgBatchRemove 组织级批量移除 +func runOrgBatchRemove(ctx *common.RuntimeContext) error { + userIDs, err := parseBatchUserIDs(ctx) + if err != nil { + return err + } + dryRun := ctx.Arg("dry-run") == "true" + + return removeFromOrgProjects(ctx, userIDs, dryRun) +} + +// parseBatchUserIDs 从 --users 或 --from CSV 解析用户 ID 列表 +func parseBatchUserIDs(ctx *common.RuntimeContext) ([]int, error) { + usersStr := ctx.Arg("users") + csvFile := ctx.Arg("from") + + if usersStr == "" && csvFile == "" { + return nil, fmt.Errorf("must specify --users (comma-separated user IDs) or --from (CSV file path)") + } + + var userIDs []int + + if usersStr != "" { + ids, err := parseUserIDList(usersStr) + if err != nil { + return nil, err + } + userIDs = append(userIDs, ids...) + } + + if csvFile != "" { + ids, err := parseCSVUserIDs(csvFile) + if err != nil { + return nil, err + } + userIDs = append(userIDs, ids...) + } + + if len(userIDs) == 0 { + return nil, fmt.Errorf("no valid user IDs found") + } + + return userIDs, nil +} + +// parseCSVUserIDs 从 CSV 文件读取 user_id 列 +func parseCSVUserIDs(filePath string) ([]int, error) { + f, err := os.Open(filePath) + if err != nil { + return nil, fmt.Errorf("failed to open CSV file %s: %w", filePath, err) + } + defer f.Close() + + reader := csv.NewReader(f) + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("failed to read CSV file: %w", err) + } + + if len(records) < 2 { + return nil, fmt.Errorf("CSV file must have a header row and at least one data row") + } + + // 查找 user_id 列 + header := records[0] + colIdx := -1 + for i, h := range header { + if strings.TrimSpace(h) == "user_id" { + colIdx = i + break + } + } + if colIdx == -1 { + return nil, fmt.Errorf("CSV file must have a 'user_id' column") + } + + var ids []int + for _, row := range records[1:] { + if len(row) <= colIdx { + continue + } + s := strings.TrimSpace(row[colIdx]) + if s == "" { + continue + } + uid, err := strconv.Atoi(s) + if err != nil { + return nil, fmt.Errorf("invalid user ID in CSV: %s", s) + } + ids = append(ids, uid) + } + + return ids, nil +} diff --git a/shortcuts/org/org.go b/shortcuts/org/org.go index 4cd6299..a330dc6 100644 --- a/shortcuts/org/org.go +++ b/shortcuts/org/org.go @@ -3,12 +3,14 @@ package org import ( "fmt" "net/url" + "strconv" + "strings" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) func Shortcuts() []*common.Shortcut { - return []*common.Shortcut{ + shortcuts := []*common.Shortcut{ { Name: "list", Description: "List organizations", @@ -22,7 +24,7 @@ func Shortcuts() []*common.Shortcut { q.Set("limit", ctx.Arg("limit")) env, err := ctx.CallAPIWithQuery("GET", "/organizations", q) if err != nil { - return err + return fmt.Errorf("获取组织列表失败: %w", err) } return ctx.Output(env) }, @@ -34,11 +36,14 @@ func Shortcuts() []*common.Shortcut { {Name: "id", Short: "i", Usage: "Organization ID or login", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { - id, _ := ctx.RequireArg("id") - env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil) + id, err := ctx.RequireArg("id", "--id my-org") if err != nil { return err } + env, err := ctx.CallAPI("GET", fmt.Sprintf("/organizations/%s", id), nil) + if err != nil { + return fmt.Errorf("查看组织失败: %w", err) + } return ctx.Output(env) }, }, @@ -51,13 +56,16 @@ func Shortcuts() []*common.Shortcut { {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { - id, _ := ctx.RequireArg("id") + id, err := ctx.RequireArg("id", "--id my-org") + if err != nil { + return err + } q := url.Values{} q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/organization_users", id), q) if err != nil { - return err + return fmt.Errorf("获取组织成员失败: %w", err) } return ctx.Output(env) }, @@ -65,12 +73,20 @@ func Shortcuts() []*common.Shortcut { { Name: "create", Description: "Create an organization", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + name := ctx.Arg("name") + return fmt.Sprintf("Create organization: %s", name), nil + }, Flags: []common.Flag{ {Name: "name", Short: "n", Usage: "Organization name", Required: true}, {Name: "description", Short: "d", Usage: "Description"}, }, Run: func(ctx *common.RuntimeContext) error { - name, _ := ctx.RequireArg("name") + name, err := ctx.RequireArg("name", `--name "My Organization"`) + if err != nil { + return err + } payload := map[string]interface{}{ "name": name, } @@ -78,11 +94,222 @@ func Shortcuts() []*common.Shortcut { payload["description"] = d } env, err := ctx.CallAPI("POST", "/organizations", payload) + if err != nil { + return fmt.Errorf("创建组织失败: %w", err) + } + return ctx.Output(env) + }, + }, + { + Name: "update", + Description: "Update an organization", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Organization ID", Required: true}, + {Name: "name", Short: "n", Usage: "New name"}, + {Name: "description", Short: "d", Usage: "New description"}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, _ := ctx.RequireArg("id", "--id my-org") + body := map[string]interface{}{} + if n := ctx.Arg("name"); n != "" { + body["name"] = n + } + if d := ctx.Arg("description"); d != "" { + body["description"] = d + } + if len(body) == 0 { + return fmt.Errorf("at least one of --name, --description is required") + } + env, err := ctx.CallAPI("PATCH", fmt.Sprintf("/organizations/%s", id), body) if err != nil { return err } return ctx.Output(env) }, }, + { + Name: "delete", + Description: "Delete an organization", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Organization ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + id, _ := ctx.RequireArg("id", "--id my-org") + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s", id), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "invite", + Description: "Invite a member to a project", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + userID := ctx.Arg("user-id") + owner := ctx.Arg("owner") + repo := ctx.Arg("repo") + return fmt.Sprintf("Invite user %s to %s/%s", userID, owner, repo), nil + }, + Flags: []common.Flag{ + {Name: "user-id", Usage: "User ID to invite (required)", Required: true}, + {Name: "owner", Short: "o", Usage: "Project owner (e.g., zzx-coder)", Required: true}, + {Name: "repo", Short: "r", Usage: "Project repo name (e.g., gitlink-cli)", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + userID, err := ctx.RequireArg("user-id", "--user-id 42") + if err != nil { + return err + } + uid, err := strconv.Atoi(userID) + if err != nil { + return fmt.Errorf("invalid user-id: %s (must be an integer)", userID) + } + + return inviteToOrgProjects(ctx, []int{uid}, false) + }, + }, + { + Name: "remove-member", + Description: "Remove a member from a project", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + userID := ctx.Arg("user-id") + owner := ctx.Arg("owner") + repo := ctx.Arg("repo") + return fmt.Sprintf("Remove user %s from %s/%s", userID, owner, repo), nil + }, + Flags: []common.Flag{ + {Name: "user-id", Usage: "User ID to remove (required)", Required: true}, + {Name: "owner", Short: "o", Usage: "Project owner (e.g., zzx-coder)", Required: true}, + {Name: "repo", Short: "r", Usage: "Project repo name (e.g., gitlink-cli)", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + userID, err := ctx.RequireArg("user-id", "--user-id 42") + if err != nil { + return err + } + uid, err := strconv.Atoi(userID) + if err != nil { + return fmt.Errorf("invalid user-id: %s (must be an integer)", userID) + } + + return removeFromOrgProjects(ctx, []int{uid}, false) + }, + }, } + + // 合并批量成员管理命令 + shortcuts = append(shortcuts, BatchShortcuts()...) + + return shortcuts +} + +// inviteToOrgProjects 向指定项目邀请用户 +func inviteToOrgProjects(ctx *common.RuntimeContext, userIDs []int, dryRun bool) error { + owner, repo, err := resolveOrgProject(ctx) + if err != nil { + return err + } + + results := make([]map[string]interface{}, 0, len(userIDs)) + for _, uid := range userIDs { + if dryRun { + results = append(results, map[string]interface{}{ + "user_id": uid, + "project": fmt.Sprintf("%s/%s", owner, repo), + "action": "invite", + "status": "would execute (dry-run)", + }) + continue + } + + body := map[string]interface{}{"user_id": uid} + _, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s/collaborators", owner, repo), body) + status := "success" + msg := "" + if err != nil { + status = "failed" + msg = err.Error() + } + results = append(results, map[string]interface{}{ + "user_id": uid, + "project": fmt.Sprintf("%s/%s", owner, repo), + "action": "invite", + "status": status, + "message": msg, + }) + } + + return ctx.OutputData(results) +} + +// removeFromOrgProjects 从指定项目移除用户 +func removeFromOrgProjects(ctx *common.RuntimeContext, userIDs []int, dryRun bool) error { + owner, repo, err := resolveOrgProject(ctx) + if err != nil { + return err + } + + results := make([]map[string]interface{}, 0, len(userIDs)) + for _, uid := range userIDs { + if dryRun { + results = append(results, map[string]interface{}{ + "user_id": uid, + "project": fmt.Sprintf("%s/%s", owner, repo), + "action": "remove", + "status": "would execute (dry-run)", + }) + continue + } + + body := map[string]interface{}{"user_id": uid} + _, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s/collaborators/remove", owner, repo), body) + status := "success" + msg := "" + if err != nil { + status = "failed" + msg = err.Error() + } + results = append(results, map[string]interface{}{ + "user_id": uid, + "project": fmt.Sprintf("%s/%s", owner, repo), + "action": "remove", + "status": status, + "message": msg, + }) + } + + return ctx.OutputData(results) +} + +// resolveOrgProject 从 --owner/--repo 解析目标项目 +func resolveOrgProject(ctx *common.RuntimeContext) (owner, repo string, err error) { + owner = ctx.Arg("owner") + repo = ctx.Arg("repo") + if owner == "" || repo == "" { + return "", "", fmt.Errorf("must specify --owner and --repo (e.g., --owner zzx-coder --repo gitlink-cli)") + } + return owner, repo, nil +} + +// parseUserIDList 解析逗号分隔的用户ID字符串 +func parseUserIDList(input string) ([]int, error) { + var ids []int + for _, s := range strings.Split(input, ",") { + s = strings.TrimSpace(s) + if s == "" { + continue + } + uid, err := strconv.Atoi(s) + if err != nil { + return nil, fmt.Errorf("invalid user ID: %s", s) + } + ids = append(ids, uid) + } + if len(ids) == 0 { + return nil, fmt.Errorf("no valid user IDs found") + } + return ids, nil } diff --git a/shortcuts/pr/pr.go b/shortcuts/pr/pr.go index 5f4a713..951c71f 100644 --- a/shortcuts/pr/pr.go +++ b/shortcuts/pr/pr.go @@ -10,6 +10,9 @@ import ( func Shortcuts() []*common.Shortcut { return []*common.Shortcut{ + newApproveShortcut(), + newRequestChangesShortcut(), + newReviewsShortcut(), { Name: "list", Description: "List pull requests", @@ -30,7 +33,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/pulls", q) if err != nil { - return err + return fmt.Errorf("获取 PR 列表失败: %w", err) } return ctx.Output(env) }, @@ -38,6 +41,16 @@ func Shortcuts() []*common.Shortcut { { Name: "create", Description: "Create a pull request", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + title := ctx.Arg("title") + head := ctx.Arg("head") + base := ctx.Arg("base") + if base == "" { + base = "master" + } + return fmt.Sprintf("Create PR: %s (%s -> %s)", title, head, base), nil + }, Flags: []common.Flag{ {Name: "title", Short: "t", Usage: "PR title", Required: true}, {Name: "body", Short: "b", Usage: "PR description"}, @@ -48,8 +61,14 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - title, _ := ctx.RequireArg("title") - head, _ := ctx.RequireArg("head") + title, err := ctx.RequireArg("title", `--title "Fix login crash"`) + if err != nil { + return err + } + head, err := ctx.RequireArg("head", `--head feat/new-login`) + if err != nil { + return err + } base := ctx.Arg("base") if base == "" { base = "master" @@ -64,7 +83,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/pulls", payload) if err != nil { - return err + return fmt.Errorf("创建 PR 失败: %w", err) } return ctx.Output(env) }, @@ -79,17 +98,29 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, _ := ctx.RequireArg("id") - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil) + id, err := ctx.RequireArg("id", "--id 42") if err != nil { return err } + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil) + if err != nil { + return fmt.Errorf("查看 PR 失败: %w", err) + } return ctx.Output(env) }, }, { Name: "merge", Description: "Merge a pull request", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + id := ctx.Arg("id") + method := ctx.Arg("method") + if method == "" { + method = "merge" + } + return fmt.Sprintf("Merge PR #%s (method: %s)", id, method), nil + }, Flags: []common.Flag{ {Name: "id", Short: "i", Usage: "PR number", Required: true}, {Name: "method", Short: "m", Usage: "Merge method: merge, rebase, squash", Default: "merge"}, @@ -98,7 +129,10 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, _ := ctx.RequireArg("id") + id, err := ctx.RequireArg("id", "--id 42") + if err != nil { + return err + } method := ctx.Arg("method") if method == "" { method = "merge" @@ -108,7 +142,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/pr_merge", ctx.RepoPath(), id), payload) if err != nil { - return err + return fmt.Errorf("合并 PR 失败: %w", err) } return ctx.Output(env) }, @@ -116,6 +150,11 @@ func Shortcuts() []*common.Shortcut { { Name: "close", Description: "Close a pull request", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + id := ctx.Arg("id") + return fmt.Sprintf("Close PR #%s", id), nil + }, Flags: []common.Flag{ {Name: "id", Short: "i", Usage: "PR number", Required: true}, }, @@ -123,11 +162,14 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, _ := ctx.RequireArg("id") - env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil) + id, err := ctx.RequireArg("id", "--id 42") if err != nil { return err } + env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/refuse_merge", ctx.RepoPath(), id), nil) + if err != nil { + return fmt.Errorf("关闭 PR 失败: %w", err) + } return ctx.Output(env) }, }, @@ -141,11 +183,14 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, _ := ctx.RequireArg("id") - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil) + id, err := ctx.RequireArg("id", "--id 42") if err != nil { return err } + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil) + if err != nil { + return fmt.Errorf("获取 PR 文件列表失败: %w", err) + } return ctx.Output(env) }, }, @@ -159,17 +204,25 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, _ := ctx.RequireArg("id") - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil) + id, err := ctx.RequireArg("id", "--id 42") if err != nil { return err } + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/files", ctx.RepoPath(), id), nil) + if err != nil { + return fmt.Errorf("获取 PR diff 失败: %w", err) + } return ctx.Output(env) }, }, { Name: "comment", Description: "Add a comment to a pull request", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + id := ctx.Arg("id") + return fmt.Sprintf("Add comment to PR #%s", id), nil + }, Flags: []common.Flag{ {Name: "id", Short: "i", Usage: "PR number", Required: true}, {Name: "body", Short: "b", Usage: "Comment body", Required: true}, @@ -178,12 +231,18 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, _ := ctx.RequireArg("id") - body, _ := ctx.RequireArg("body") + id, err := ctx.RequireArg("id", "--id 42") + if err != nil { + return err + } + body, err := ctx.RequireArg("body", `--body "Looks good to me"`) + if err != nil { + return err + } prEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s", ctx.RepoPath(), id), nil) if err != nil { - return fmt.Errorf("fetch PR: %w", err) + return fmt.Errorf("获取 PR 信息失败: %w", err) } issueID, err := extractIssueID(prEnv) if err != nil { @@ -195,7 +254,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", fmt.Sprintf("/v1/%s/%s/issues/%d/journals", ctx.Owner, ctx.Repo, issueID), payload) if err != nil { - return err + return fmt.Errorf("添加 PR 评论失败: %w", err) } return ctx.Output(env) }, diff --git a/shortcuts/pr/review.go b/shortcuts/pr/review.go new file mode 100644 index 0000000..61645b8 --- /dev/null +++ b/shortcuts/pr/review.go @@ -0,0 +1,83 @@ +package pr + +import ( + "fmt" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func newApproveShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "approve", + Description: "Approve a pull request", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "body", Short: "b", Usage: "Review comment (optional)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, _ := ctx.RequireArg("id", "--id 42") + body := map[string]interface{}{ + "state": "approved", + } + if b := ctx.Arg("body"); b != "" { + body["body"] = b + } + env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), body) + if err != nil { + return err + } + return ctx.Output(env) + }, + } +} + +func newRequestChangesShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "request-changes", + Description: "Request changes on a pull request", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "PR number", Required: true}, + {Name: "body", Short: "b", Usage: "Review comment explaining what needs to change", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, _ := ctx.RequireArg("id", "--id 42") + body, _ := ctx.RequireArg("body", `--body "Looks good"`) + payload := map[string]interface{}{ + "state": "changes_requested", + "body": body, + } + env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), payload) + if err != nil { + return err + } + return ctx.Output(env) + }, + } +} + +func newReviewsShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "reviews", + Description: "List reviews for a pull request", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "PR number", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, _ := ctx.RequireArg("id", "--id 42") + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/pulls/%s/reviews", ctx.RepoPath(), id), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + } +} diff --git a/shortcuts/register.go b/shortcuts/register.go index 210500e..fc80ca5 100644 --- a/shortcuts/register.go +++ b/shortcuts/register.go @@ -6,39 +6,60 @@ import ( "github.com/gitlink-org/gitlink-cli/shortcuts/branch" "github.com/gitlink-org/gitlink-cli/shortcuts/ci" "github.com/gitlink-org/gitlink-cli/shortcuts/common" + "github.com/gitlink-org/gitlink-cli/shortcuts/compliance" + "github.com/gitlink-org/gitlink-cli/shortcuts/contrib" + "github.com/gitlink-org/gitlink-cli/shortcuts/onboard" "github.com/gitlink-org/gitlink-cli/shortcuts/issue" + // "github.com/gitlink-org/gitlink-cli/shortcuts/milestone" // broken: syntax errors "github.com/gitlink-org/gitlink-cli/shortcuts/org" "github.com/gitlink-org/gitlink-cli/shortcuts/pr" "github.com/gitlink-org/gitlink-cli/shortcuts/release" "github.com/gitlink-org/gitlink-cli/shortcuts/repo" "github.com/gitlink-org/gitlink-cli/shortcuts/search" + "github.com/gitlink-org/gitlink-cli/shortcuts/team" "github.com/gitlink-org/gitlink-cli/shortcuts/user" + "github.com/gitlink-org/gitlink-cli/shortcuts/webhook" + "github.com/gitlink-org/gitlink-cli/shortcuts/wiki" ) // 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(), + "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(), + // "milestone": milestone.Shortcuts(), // broken + "team": team.Shortcuts(), + "wiki": wiki.Shortcuts(), + "webhook": webhook.Shortcuts(), + "contrib": contrib.Shortcuts(), + "compliance": compliance.Shortcuts(), + "onboard": onboard.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", + "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", + // "milestone": "Milestone operations", // broken + "team": "Team operations", + "wiki": "Wiki operations", + "webhook": "Webhook operations", + "contrib": "Contribution report operations", + "compliance": "Compliance and security scan operations", + "onboard": "New contributor onboarding operations", } for name, shortcuts := range groups { diff --git a/shortcuts/release/release.go b/shortcuts/release/release.go index 06240bc..5da6307 100644 --- a/shortcuts/release/release.go +++ b/shortcuts/release/release.go @@ -26,7 +26,7 @@ func Shortcuts() []*common.Shortcut { q.Set("limit", ctx.Arg("limit")) env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/releases", q) if err != nil { - return err + return fmt.Errorf("获取 Release 列表失败: %w", err) } return ctx.Output(env) }, @@ -34,6 +34,12 @@ func Shortcuts() []*common.Shortcut { { Name: "create", Description: "Create a release", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + name := ctx.Arg("name") + tag := ctx.Arg("tag") + return fmt.Sprintf("Create release: %s (tag: %s)", name, tag), nil + }, Flags: []common.Flag{ {Name: "tag", Short: "t", Usage: "Tag name", Required: true}, {Name: "name", Short: "n", Usage: "Release name", Required: true}, @@ -45,8 +51,14 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - tag, _ := ctx.RequireArg("tag") - name, _ := ctx.RequireArg("name") + tag, err := ctx.RequireArg("tag", "--tag v1.0.0") + if err != nil { + return err + } + name, err := ctx.RequireArg("name", `--name "Version 1.0.0"`) + if err != nil { + return err + } payload := map[string]interface{}{ "tag_name": tag, "name": name, @@ -62,7 +74,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/releases", payload) if err != nil { - return err + return fmt.Errorf("创建 Release 失败: %w", err) } return ctx.Output(env) }, @@ -77,17 +89,25 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, _ := ctx.RequireArg("id") - env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) + id, err := ctx.RequireArg("id", "--id 1") if err != nil { return err } + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) + if err != nil { + return fmt.Errorf("查看 Release 失败: %w", err) + } return ctx.Output(env) }, }, { Name: "delete", Description: "Delete a release", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + id := ctx.Arg("id") + return fmt.Sprintf("Delete release #%s", id), nil + }, Flags: []common.Flag{ {Name: "id", Short: "i", Usage: "Release ID", Required: true}, }, @@ -95,25 +115,58 @@ func Shortcuts() []*common.Shortcut { if err := ctx.ResolveOwnerRepo(); err != nil { return err } - id, _ := ctx.RequireArg("id") + id, err := ctx.RequireArg("id", "--id 1") + if err != nil { + return err + } _, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) if delErr != nil { - // GitLink API bug: delete succeeds but returns error status. - // Verify by checking if the release still exists. _, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), nil) if viewErr != nil { - // Release no longer exists — delete actually succeeded return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ "message": "删除成功", }, nil)) } - // Release still exists — delete truly failed - return delErr + return fmt.Errorf("删除 Release 失败: %w", delErr) } return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ "message": "删除成功", }, nil)) }, }, + { + Name: "update", + Description: "Update a release", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Release ID", Required: true}, + {Name: "name", Short: "n", Usage: "New release name"}, + {Name: "body", Short: "b", Usage: "New release notes"}, + {Name: "prerelease", Usage: "Mark as prerelease (true/false)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + id, _ := ctx.RequireArg("id", "--id my-org") + body := map[string]interface{}{} + if n := ctx.Arg("name"); n != "" { + body["name"] = n + } + if b := ctx.Arg("body"); b != "" { + body["body"] = b + } + if p := ctx.Arg("prerelease"); p != "" { + body["prerelease"] = p == "true" + } + if len(body) == 0 { + return fmt.Errorf("at least one of --name, --body, --prerelease is required") + } + env, err := ctx.CallAPI("PATCH", fmt.Sprintf("%s/releases/%s", ctx.RepoPath(), id), body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, } } diff --git a/shortcuts/repo/batch_create.go b/shortcuts/repo/batch_create.go new file mode 100644 index 0000000..cf72a36 --- /dev/null +++ b/shortcuts/repo/batch_create.go @@ -0,0 +1,196 @@ +package repo + +import ( + "encoding/csv" + "fmt" + "os" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +type repoCreateInput struct { + Name string + Description string + Private bool +} + +type repoBatchResult struct { + Name string `json:"name" yaml:"name"` + Status string `json:"status" yaml:"status"` + Error string `json:"error,omitempty" yaml:"error,omitempty"` +} + +type repoBatchSummary struct { + Owner string `json:"owner" yaml:"owner"` + Action string `json:"action" yaml:"action"` + DryRun bool `json:"dry_run" yaml:"dry_run"` + Total int `json:"total" yaml:"total"` + Succeeded int `json:"succeeded" yaml:"succeeded"` + Failed int `json:"failed" yaml:"failed"` + Results []repoBatchResult `json:"results" yaml:"results"` +} + +func newBatchCreateShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-create", + Description: "Create multiple repositories from CLI flags or a CSV file", + Flags: []common.Flag{ + {Name: "names", Short: "n", Usage: "Comma-separated repository names, e.g. repo-a,repo-b"}, + {Name: "from", Usage: "CSV file path"}, + {Name: "description", Short: "d", Usage: "Shared description for all repos (inline mode)"}, + {Name: "private", Usage: "Make repos private", Bool: true, Default: "false"}, + {Name: "dry-run", Usage: "Preview without creating", Bool: true, Default: "false"}, + }, + Run: runBatchCreate, + } +} + +func runBatchCreate(ctx *common.RuntimeContext) error { + var inputs []repoCreateInput + + if namesStr := ctx.Arg("names"); namesStr != "" { + for _, name := range strings.Split(namesStr, ",") { + name = strings.TrimSpace(name) + if name == "" { + continue + } + inputs = append(inputs, repoCreateInput{ + Name: name, + Description: ctx.Arg("description"), + Private: ctx.Arg("private") == "true", + }) + } + } + + if csvPath := ctx.Arg("from"); csvPath != "" { + csvInputs, err := readRepoInputsFromCSV(csvPath) + if err != nil { + return err + } + inputs = append(inputs, csvInputs...) + } + + if len(inputs) == 0 { + return fmt.Errorf("no repository names provided; use -n repo-a,repo-b or --from repos.csv") + } + + dryRun := ctx.Arg("dry-run") == "true" + + var login string + var userID int + if !dryRun { + userEnv, err := ctx.CallAPI("GET", "/users/me", nil) + if err != nil { + return fmt.Errorf("failed to get current user: %w", err) + } + userData, _ := userEnv.Data.(map[string]interface{}) + login, _ = userData["login"].(string) + if login == "" { + return fmt.Errorf("cannot determine current user login") + } + if uid, ok := userData["user_id"].(float64); ok { + userID = int(uid) + } + } + + summary := repoBatchSummary{ + Owner: login, + Action: "create", + DryRun: dryRun, + Total: len(inputs), + Results: make([]repoBatchResult, 0, len(inputs)), + } + + for _, input := range inputs { + result := repoBatchResult{Name: input.Name} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + body := map[string]interface{}{ + "name": input.Name, + "repository_name": input.Name, + "user_id": userID, + } + if input.Description != "" { + body["description"] = input.Description + } + if input.Private { + body["private"] = true + } + + if _, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, input.Name), body); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "created" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d of %d repo(s) failed to create", summary.Failed, summary.Total) + } + return nil +} + +func readRepoInputsFromCSV(path string) ([]repoCreateInput, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("read CSV: %w", err) + } + defer file.Close() + + reader := csv.NewReader(file) + reader.TrimLeadingSpace = true + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("parse CSV: %w", err) + } + if len(records) < 2 { + return nil, fmt.Errorf("CSV must have a header row and at least one data row") + } + + header := records[0] + col := make(map[string]int) + for i, h := range header { + col[strings.ToLower(strings.TrimSpace(h))] = i + } + if _, ok := col["name"]; !ok { + return nil, fmt.Errorf("CSV must have a 'name' column") + } + + var inputs []repoCreateInput + for _, record := range records[1:] { + name := getCol(record, col, "name") + if name == "" { + continue + } + private := false + if p := strings.ToLower(getCol(record, col, "private")); p == "true" || p == "1" { + private = true + } + inputs = append(inputs, repoCreateInput{ + Name: name, + Description: getCol(record, col, "description"), + Private: private, + }) + } + return inputs, nil +} + +func getCol(record []string, col map[string]int, name string) string { + if idx, ok := col[name]; ok && idx < len(record) { + return strings.TrimSpace(record[idx]) + } + return "" +} diff --git a/shortcuts/repo/batch_delete.go b/shortcuts/repo/batch_delete.go new file mode 100644 index 0000000..42cd027 --- /dev/null +++ b/shortcuts/repo/batch_delete.go @@ -0,0 +1,94 @@ +package repo + +import ( + "fmt" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// newBatchDeleteShortcut 实现 repo +batch-delete 命令。 +// +// 设计参考 issue +batch-close 与 repo +batch-update: +// - --names : 内联逗号分隔的仓库名列表 +// - --from : CSV 文件路径(只读 name 列,复用 readNamesFromCSV) +// - --dry-run : 仅预览不实际删除 +// +// 与单条 repo +delete 的区别:批量操作下没有"当前仓库"语义, +// 因此只要求 --owner(不需要 --repo),所有要删除的仓库都位于该 owner 名下。 +func newBatchDeleteShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-delete", + Description: "Delete multiple repositories by names or a CSV file", + Flags: []common.Flag{ + {Name: "names", Short: "n", Usage: "Comma-separated repository names, e.g. repo-a,repo-b"}, + {Name: "from", Usage: "CSV file path"}, + {Name: "dry-run", Usage: "Preview without deleting", Bool: true, Default: "false"}, + }, + Run: runBatchDelete, + } +} + +func runBatchDelete(ctx *common.RuntimeContext) error { + owner := ctx.Owner + if owner == "" { + return fmt.Errorf("--owner is required; pass --owner to specify the account that owns the repos") + } + + var repoNames []string + if namesStr := ctx.Arg("names"); namesStr != "" { + for _, name := range strings.Split(namesStr, ",") { + name = strings.TrimSpace(name) + if name != "" { + repoNames = append(repoNames, name) + } + } + } + if csvPath := ctx.Arg("from"); csvPath != "" { + csvNames, err := readNamesFromCSV(csvPath) + if err != nil { + return err + } + repoNames = append(repoNames, csvNames...) + } + if len(repoNames) == 0 { + return fmt.Errorf("no repository names provided; use -n repo-a,repo-b or --from repos.csv") + } + + dryRun := ctx.Arg("dry-run") == "true" + summary := repoBatchSummary{ + Owner: owner, + Action: "delete", + DryRun: dryRun, + Total: len(repoNames), + Results: make([]repoBatchResult, 0, len(repoNames)), + } + + for _, name := range repoNames { + result := repoBatchResult{Name: name} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + if _, err := ctx.CallAPI("DELETE", fmt.Sprintf("/%s/%s", owner, name), nil); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "deleted" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d of %d repo(s) failed to delete", summary.Failed, summary.Total) + } + return nil +} diff --git a/shortcuts/repo/batch_delete_test.go b/shortcuts/repo/batch_delete_test.go new file mode 100644 index 0000000..ab37ea2 --- /dev/null +++ b/shortcuts/repo/batch_delete_test.go @@ -0,0 +1,220 @@ +package repo + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func runBatchDeleteShortcut(t *testing.T, server *httptest.Server, owner string, args map[string]string) error { + t.Helper() + s := findShortcut(t, "batch-delete") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: owner, + Format: "json", + Args: args, + } + return s.Run(ctx) +} + +func TestBatchDelete_DryRun(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("no API calls expected in dry-run mode, got %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchDeleteShortcut(t, server, "testuser", map[string]string{ + "names": "repo1,repo2", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBatchDelete_FromNames(t *testing.T) { + var deletedPaths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + t.Fatalf("expected DELETE, got %s", r.Method) + } + deletedPaths = append(deletedPaths, r.URL.Path) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{ + "names": "test-batch-1,test-batch-2,test-batch-3", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(deletedPaths) != 3 { + t.Fatalf("expected 3 DELETE calls, got %d", len(deletedPaths)) + } + expected := []string{"/zzx-coder/test-batch-1.json", "/zzx-coder/test-batch-2.json", "/zzx-coder/test-batch-3.json"} + for i, p := range deletedPaths { + if p != expected[i] { + t.Fatalf("path[%d]: got %q, want %q", i, p, expected[i]) + } + } +} + +func TestBatchDelete_NoNames(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{}) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "no repository names provided") { + t.Fatalf("error should mention no names, got: %v", err) + } +} + +func TestBatchDelete_NoOwner(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchDeleteShortcut(t, server, "", map[string]string{ + "names": "repo1,repo2", + }) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "--owner is required") { + t.Fatalf("error should mention --owner required, got: %v", err) + } +} + +func TestBatchDelete_DryRunNoNamesErrors(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{"dry-run": "true"}) + if err == nil { + t.Fatal("expected error for no names even in dry-run, got nil") + } +} + +func TestBatchDelete_FromCSV(t *testing.T) { + csvPath := writeTempCSV(t, "name\ncsv-repo1\ncsv-repo2\n") + + var deletedPaths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "DELETE" { + t.Fatalf("expected DELETE, got %s", r.Method) + } + deletedPaths = append(deletedPaths, r.URL.Path) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{"from": csvPath}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(deletedPaths) != 2 { + t.Fatalf("expected 2 DELETE calls, got %d", len(deletedPaths)) + } + if deletedPaths[0] != "/zzx-coder/csv-repo1.json" { + t.Fatalf("first path: got %q", deletedPaths[0]) + } + if deletedPaths[1] != "/zzx-coder/csv-repo2.json" { + t.Fatalf("second path: got %q", deletedPaths[1]) + } +} + +func TestBatchDelete_CSVAndNamesCombined(t *testing.T) { + csvPath := writeTempCSV(t, "name\ncsv-repo\n") + + var deletedPaths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + deletedPaths = append(deletedPaths, r.URL.Path) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{ + "names": "inline-repo", + "from": csvPath, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(deletedPaths) != 2 { + t.Fatalf("expected 2 deletes (inline + csv), got %d", len(deletedPaths)) + } + if deletedPaths[0] != "/zzx-coder/inline-repo.json" { + t.Fatalf("first: got %q", deletedPaths[0]) + } + if deletedPaths[1] != "/zzx-coder/csv-repo.json" { + t.Fatalf("second: got %q", deletedPaths[1]) + } +} + +func TestBatchDelete_PartialFailure(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + if callCount == 2 { + w.WriteHeader(http.StatusNotFound) + w.Write([]byte(`{"message":"repo not found"}`)) + return + } + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{ + "names": "ok1,fail1,ok2", + }) + if err == nil { + t.Fatal("expected error from partial failure, got nil") + } + if !strings.Contains(err.Error(), "failed to delete") { + t.Fatalf("error should mention failed count, got: %v", err) + } +} + +func TestBatchDelete_TrimsWhitespace(t *testing.T) { + var deletedPaths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + deletedPaths = append(deletedPaths, r.URL.Path) + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"ok":true}`)) + })) + defer server.Close() + + err := runBatchDeleteShortcut(t, server, "zzx-coder", map[string]string{ + "names": " repo-a , repo-b ,", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(deletedPaths) != 2 { + t.Fatalf("expected 2 deletes after trimming, got %d", len(deletedPaths)) + } + if deletedPaths[0] != "/zzx-coder/repo-a.json" { + t.Fatalf("first: got %q", deletedPaths[0]) + } + if deletedPaths[1] != "/zzx-coder/repo-b.json" { + t.Fatalf("second: got %q", deletedPaths[1]) + } +} diff --git a/shortcuts/repo/batch_member.go b/shortcuts/repo/batch_member.go new file mode 100644 index 0000000..7d4c0d7 --- /dev/null +++ b/shortcuts/repo/batch_member.go @@ -0,0 +1,205 @@ +package repo + +import ( + "encoding/csv" + "fmt" + "os" + "strconv" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// BatchMemberShortcuts 返回批量成员管理相关的 Shortcut(由 repo.Shortcuts() 调用合并) +func BatchMemberShortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "batch-invite", + Description: "Batch invite members to a repository (from --users list or --from CSV file)", + Flags: []common.Flag{ + {Name: "users", Usage: "Comma-separated list of user IDs to invite"}, + {Name: "from", Usage: "CSV file with user IDs (column: user_id)"}, + {Name: "dry-run", Usage: "Preview operations without executing", Bool: true}, + }, + Run: runBatchInvite, + }, + { + Name: "batch-remove", + Description: "Batch remove members from a repository (from --users list or --from CSV file)", + Flags: []common.Flag{ + {Name: "users", Usage: "Comma-separated list of user IDs to remove"}, + {Name: "from", Usage: "CSV file with user IDs (column: user_id)"}, + {Name: "dry-run", Usage: "Preview operations without executing", Bool: true}, + }, + Run: runBatchRemove, + }, + } +} + +// runBatchInvite 执行批量邀请 +func runBatchInvite(ctx *common.RuntimeContext) error { + userIDs, err := parseUserIDs(ctx) + if err != nil { + return err + } + dryRun := ctx.Arg("dry-run") == "true" + + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + results := make([]map[string]interface{}, 0, len(userIDs)) + + for _, uid := range userIDs { + if dryRun { + results = append(results, map[string]interface{}{ + "user_id": uid, + "project": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "action": "invite", + "status": "would execute (dry-run)", + }) + continue + } + + body := map[string]interface{}{"user_id": uid} + _, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body) + status := "success" + msg := "" + if err != nil { + status = "failed" + msg = err.Error() + } + results = append(results, map[string]interface{}{ + "user_id": uid, + "project": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "action": "invite", + "status": status, + "message": msg, + }) + } + + return ctx.OutputData(results) +} + +// runBatchRemove 执行批量移除 +func runBatchRemove(ctx *common.RuntimeContext) error { + userIDs, err := parseUserIDs(ctx) + if err != nil { + return err + } + dryRun := ctx.Arg("dry-run") == "true" + + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + results := make([]map[string]interface{}, 0, len(userIDs)) + + for _, uid := range userIDs { + if dryRun { + results = append(results, map[string]interface{}{ + "user_id": uid, + "project": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "action": "remove", + "status": "would execute (dry-run)", + }) + continue + } + + body := map[string]interface{}{"user_id": uid} + _, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/collaborators/remove", body) + status := "success" + msg := "" + if err != nil { + status = "failed" + msg = err.Error() + } + results = append(results, map[string]interface{}{ + "user_id": uid, + "project": fmt.Sprintf("%s/%s", ctx.Owner, ctx.Repo), + "action": "remove", + "status": status, + "message": msg, + }) + } + + return ctx.OutputData(results) +} + +// parseUserIDs 从 --users 或 --from 参数中解析用户 ID 列表 +func parseUserIDs(ctx *common.RuntimeContext) ([]int, error) { + usersStr := ctx.Arg("users") + csvFile := ctx.Arg("from") + + if usersStr == "" && csvFile == "" { + return nil, fmt.Errorf("必须指定 --users(逗号分隔的用户ID)或 --from(CSV文件路径)") + } + + var userIDs []int + + if usersStr != "" { + for _, s := range strings.Split(usersStr, ",") { + s = strings.TrimSpace(s) + if s == "" { + continue + } + uid, err := strconv.Atoi(s) + if err != nil { + return nil, fmt.Errorf("invalid user ID: %s", s) + } + userIDs = append(userIDs, uid) + } + } + + if csvFile != "" { + f, err := os.Open(csvFile) + if err != nil { + return nil, fmt.Errorf("failed to open CSV file %s: %w", csvFile, err) + } + defer f.Close() + + reader := csv.NewReader(f) + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("failed to read CSV file: %w", err) + } + + if len(records) < 2 { + return nil, fmt.Errorf("CSV file must have a header row and at least one data row") + } + + // 查找 user_id 列 + header := records[0] + colIdx := -1 + for i, h := range header { + if strings.TrimSpace(h) == "user_id" { + colIdx = i + break + } + } + if colIdx == -1 { + return nil, fmt.Errorf("CSV file must have a 'user_id' column") + } + + for _, row := range records[1:] { + if len(row) <= colIdx { + continue + } + s := strings.TrimSpace(row[colIdx]) + if s == "" { + continue + } + uid, err := strconv.Atoi(s) + if err != nil { + return nil, fmt.Errorf("invalid user ID in CSV: %s", s) + } + userIDs = append(userIDs, uid) + } + } + + if len(userIDs) == 0 { + return nil, fmt.Errorf("no valid user IDs found") + } + + return userIDs, nil +} diff --git a/shortcuts/repo/batch_test.go b/shortcuts/repo/batch_test.go new file mode 100644 index 0000000..633c728 --- /dev/null +++ b/shortcuts/repo/batch_test.go @@ -0,0 +1,837 @@ +package repo + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// ---- helpers ---- + +func findShortcut(t *testing.T, name string) *common.Shortcut { + t.Helper() + for _, s := range Shortcuts() { + if s.Name == name { + return s + } + } + t.Fatalf("shortcut %q not found", name) + return nil +} + +func runBatchCreateShortcut(t *testing.T, server *httptest.Server, args map[string]string) error { + t.Helper() + s := findShortcut(t, "batch-create") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + return s.Run(ctx) +} + +func runBatchUpdateShortcut(t *testing.T, server *httptest.Server, args map[string]string) error { + t.Helper() + s := findShortcut(t, "batch-update") + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner", + Repo: "repo", + Format: "json", + Args: args, + } + return s.Run(ctx) +} + +func writeJSONResp(t *testing.T, w http.ResponseWriter, v interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + t.Fatalf("writeJSON: %v", err) + } +} + +func decodeReqBody(t *testing.T, r *http.Request) map[string]interface{} { + t.Helper() + var payload map[string]interface{} + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + t.Fatalf("decode body: %v", err) + } + return payload +} + +func writeTempCSV(t *testing.T, content string) string { + t.Helper() + path := filepath.Join(t.TempDir(), "repos.csv") + if err := os.WriteFile(path, []byte(content), 0o600); err != nil { + t.Fatalf("write temp csv: %v", err) + } + return path +} + +// ---- runBatchCreate tests ---- + +func TestBatchCreate_DryRun(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("no API calls expected in dry-run mode, got %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "names": "repo1,repo2", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBatchCreate_FromNames(t *testing.T) { + var createdBodies []map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/users/me.json": + writeJSONResp(t, w, map[string]interface{}{ + "login": "testuser", + "user_id": float64(42), + }) + case r.Method == "POST" && strings.HasPrefix(r.URL.Path, "/testuser/"): + createdBodies = append(createdBodies, decodeReqBody(t, r)) + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "names": "repo-a,repo-b", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(createdBodies) != 2 { + t.Fatalf("expected 2 API calls, got %d", len(createdBodies)) + } + if createdBodies[0]["name"] != "repo-a" { + t.Fatalf("first name: got %q, want %q", createdBodies[0]["name"], "repo-a") + } + if createdBodies[1]["name"] != "repo-b" { + t.Fatalf("second name: got %q, want %q", createdBodies[1]["name"], "repo-b") + } + for _, body := range createdBodies { + if body["repository_name"] != body["name"] { + t.Fatalf("repository_name should match name: %v vs %v", body["repository_name"], body["name"]) + } + if body["user_id"] != float64(42) { + t.Fatalf("user_id: got %v, want 42", body["user_id"]) + } + } +} + +func TestBatchCreate_NoNames(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{}) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestBatchCreate_NoNamesDryRun(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{"dry-run": "true"}) + if err == nil { + t.Fatal("expected error for no names even in dry-run, got nil") + } +} + +func TestBatchCreate_WithPrivate(t *testing.T) { + var createdBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/users/me.json": + writeJSONResp(t, w, map[string]interface{}{ + "login": "testuser", + "user_id": float64(42), + }) + case r.Method == "POST": + createdBody = decodeReqBody(t, r) + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "names": "private-repo", + "private": "true", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if createdBody["private"] != true { + t.Fatalf("private: got %v, want true", createdBody["private"]) + } +} + +func TestBatchCreate_WithDescription(t *testing.T) { + var createdBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/users/me.json": + writeJSONResp(t, w, map[string]interface{}{ + "login": "testuser", + "user_id": float64(42), + }) + case r.Method == "POST": + createdBody = decodeReqBody(t, r) + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "names": "desc-repo", + "description": "shared description", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if createdBody["description"] != "shared description" { + t.Fatalf("description: got %q, want %q", createdBody["description"], "shared description") + } +} + +func TestBatchCreate_FromCSV(t *testing.T) { + csvPath := writeTempCSV(t, "name,description,private\ncsv-repo1,desc one,false\ncsv-repo2,desc two,true\n") + + var createdBodies []map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/users/me.json": + writeJSONResp(t, w, map[string]interface{}{ + "login": "testuser", + "user_id": float64(42), + }) + case r.Method == "POST": + createdBodies = append(createdBodies, decodeReqBody(t, r)) + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{"from": csvPath}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(createdBodies) != 2 { + t.Fatalf("expected 2 creates, got %d", len(createdBodies)) + } + if createdBodies[0]["name"] != "csv-repo1" { + t.Fatalf("first name: got %q", createdBodies[0]["name"]) + } + if createdBodies[0]["description"] != "desc one" { + t.Fatalf("first description: got %q", createdBodies[0]["description"]) + } + if createdBodies[1]["name"] != "csv-repo2" { + t.Fatalf("second name: got %q", createdBodies[1]["name"]) + } + if createdBodies[1]["private"] != true { + t.Fatalf("second private: got %v, want true", createdBodies[1]["private"]) + } +} + +func TestBatchCreate_PartialFailure(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/users/me.json": + writeJSONResp(t, w, map[string]interface{}{ + "login": "testuser", + "user_id": float64(42), + }) + case r.Method == "POST": + callCount++ + if callCount == 2 { + w.WriteHeader(http.StatusUnprocessableEntity) + return + } + writeJSONResp(t, w, map[string]interface{}{"id": float64(callCount)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "names": "ok1,fail1,ok2", + }) + if err == nil { + t.Fatal("expected error from partial failure, got nil") + } + if !strings.Contains(err.Error(), "failed to create") { + t.Fatalf("error should mention failed count, got: %v", err) + } +} + +func TestBatchCreate_UserLookupFails(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "names": "repo1", + }) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "failed to get current user") { + t.Fatalf("error should mention user lookup, got: %v", err) + } +} + +func TestBatchCreate_UserMissingLogin(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSONResp(t, w, map[string]interface{}{ + "user_id": float64(42), + }) + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "names": "repo1", + }) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "cannot determine current user login") { + t.Fatalf("error should mention missing login, got: %v", err) + } +} + +func TestBatchCreate_CSVAndNamesCombined(t *testing.T) { + csvPath := writeTempCSV(t, "name\ndual-repo\n") + + var createdNames []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET" && r.URL.Path == "/users/me.json": + writeJSONResp(t, w, map[string]interface{}{ + "login": "testuser", + "user_id": float64(42), + }) + case r.Method == "POST": + body := decodeReqBody(t, r) + createdNames = append(createdNames, body["name"].(string)) + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchCreateShortcut(t, server, map[string]string{ + "names": "inline-repo", + "from": csvPath, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(createdNames) != 2 { + t.Fatalf("expected 2 creates, got %d", len(createdNames)) + } + if createdNames[0] != "inline-repo" { + t.Fatalf("first: got %q", createdNames[0]) + } + if createdNames[1] != "dual-repo" { + t.Fatalf("second: got %q", createdNames[1]) + } +} + +// ---- readRepoInputsFromCSV tests ---- + +func TestReadRepoInputsFromCSV_Normal(t *testing.T) { + path := writeTempCSV(t, "name,description,private\nrepo1,desc1,true\nrepo2,desc2,false\nrepo3,,0\n") + + inputs, err := readRepoInputsFromCSV(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(inputs) != 3 { + t.Fatalf("got %d inputs, want 3", len(inputs)) + } + if inputs[0].Name != "repo1" || inputs[0].Description != "desc1" || !inputs[0].Private { + t.Fatalf("input[0]: %+v", inputs[0]) + } + if inputs[1].Name != "repo2" || inputs[1].Private { + t.Fatalf("input[1]: %+v", inputs[1]) + } + if inputs[2].Name != "repo3" || inputs[2].Private { + t.Fatalf("input[2]: %+v", inputs[2]) + } +} + +func TestReadRepoInputsFromCSV_MissingNameColumn(t *testing.T) { + path := writeTempCSV(t, "title,description\nval1,desc1\n") + _, err := readRepoInputsFromCSV(path) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestReadRepoInputsFromCSV_OnlyHeader(t *testing.T) { + path := writeTempCSV(t, "name,description\n") + _, err := readRepoInputsFromCSV(path) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestReadRepoInputsFromCSV_SkipsEmptyName(t *testing.T) { + path := writeTempCSV(t, "name,description\nrepo1,desc1\n,desc2\nrepo2,desc3\n") + + inputs, err := readRepoInputsFromCSV(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(inputs) != 2 { + t.Fatalf("got %d inputs, want 2", len(inputs)) + } +} + +func TestReadRepoInputsFromCSV_PrivateParsing(t *testing.T) { + path := writeTempCSV(t, "name,private\nr1,true\nr2,false\nr3,1\nr4,0\nr5,\n") + + inputs, err := readRepoInputsFromCSV(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(inputs) != 5 { + t.Fatalf("got %d inputs, want 5", len(inputs)) + } + if !inputs[0].Private { + t.Fatal("r1 (true) should be private") + } + if inputs[1].Private { + t.Fatal("r2 (false) should not be private") + } + if !inputs[2].Private { + t.Fatal("r3 (1) should be private") + } + if inputs[3].Private { + t.Fatal("r4 (0) should not be private") + } + if inputs[4].Private { + t.Fatal("r5 (empty) should not be private") + } +} + +func TestReadRepoInputsFromCSV_FileNotFound(t *testing.T) { + _, err := readRepoInputsFromCSV("/nonexistent/path.csv") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestReadRepoInputsFromCSV_CaseInsensitiveHeader(t *testing.T) { + path := writeTempCSV(t, "NAME,Description,Private\nrepo1,desc1,TRUE\n") + + inputs, err := readRepoInputsFromCSV(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(inputs) != 1 { + t.Fatalf("got %d inputs, want 1", len(inputs)) + } + if inputs[0].Name != "repo1" { + t.Fatalf("name: got %q", inputs[0].Name) + } + if !inputs[0].Private { + t.Fatal("should be private") + } +} + +// ---- getCol tests ---- + +func TestGetCol_Found(t *testing.T) { + col := map[string]int{"name": 0, "description": 1} + record := []string{"my-repo", "my desc"} + if got := getCol(record, col, "name"); got != "my-repo" { + t.Fatalf("got %q", got) + } + if got := getCol(record, col, "description"); got != "my desc" { + t.Fatalf("got %q", got) + } +} + +func TestGetCol_Missing(t *testing.T) { + col := map[string]int{"name": 0} + record := []string{"my-repo"} + if got := getCol(record, col, "missing"); got != "" { + t.Fatalf("got %q, want empty", got) + } +} + +func TestGetCol_IndexOutOfRange(t *testing.T) { + col := map[string]int{"name": 5} + record := []string{"my-repo"} + if got := getCol(record, col, "name"); got != "" { + t.Fatalf("got %q, want empty", got) + } +} + +// ---- runBatchUpdate tests ---- + +func TestBatchUpdate_DryRun(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("no API calls expected in dry-run mode, got %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchUpdateShortcut(t, server, map[string]string{ + "names": "repo1,repo2", + "dry-run": "true", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestBatchUpdate_FromNames(t *testing.T) { + var fetchedRepos []string + var patchedBodies []map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + name := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/owner/"), ".json") + fetchedRepos = append(fetchedRepos, name) + writeJSONResp(t, w, map[string]interface{}{ + "name": name, + "identifier": "ident-" + name, + }) + case r.Method == "PATCH": + patchedBodies = append(patchedBodies, decodeReqBody(t, r)) + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchUpdateShortcut(t, server, map[string]string{ + "names": "repo-a,repo-b", + "private": "true", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(fetchedRepos) != 2 { + t.Fatalf("expected 2 fetches, got %d", len(fetchedRepos)) + } + if len(patchedBodies) != 2 { + t.Fatalf("expected 2 patches, got %d", len(patchedBodies)) + } + for _, body := range patchedBodies { + if body["private"] != true { + t.Fatalf("private should be true: %+v", body) + } + } +} + +func TestBatchUpdate_NoNames(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchUpdateShortcut(t, server, map[string]string{}) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestBatchUpdate_PrivatePublicConflict(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + })) + defer server.Close() + + err := runBatchUpdateShortcut(t, server, map[string]string{ + "names": "repo1", + "private": "true", + "public": "true", + }) + if err == nil { + t.Fatal("expected error, got nil") + } + if !strings.Contains(err.Error(), "cannot use both --private and --public") { + t.Fatalf("error should mention conflict, got: %v", err) + } +} + +func TestBatchUpdate_SetPublic(t *testing.T) { + var patchedBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + writeJSONResp(t, w, map[string]interface{}{ + "name": "repo1", + "identifier": "abc123", + }) + case r.Method == "PATCH": + patchedBody = decodeReqBody(t, r) + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchUpdateShortcut(t, server, map[string]string{ + "names": "repo1", + "public": "true", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if patchedBody["private"] != false { + t.Fatalf("public should set private=false, got %v", patchedBody["private"]) + } +} + +func TestBatchUpdate_WithDescription(t *testing.T) { + var patchedBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + writeJSONResp(t, w, map[string]interface{}{ + "name": "repo1", + "identifier": "abc123", + }) + case r.Method == "PATCH": + patchedBody = decodeReqBody(t, r) + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchUpdateShortcut(t, server, map[string]string{ + "names": "repo1", + "description": "updated description", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if patchedBody["description"] != "updated description" { + t.Fatalf("description: got %q", patchedBody["description"]) + } +} + +func TestBatchUpdate_FromCSV(t *testing.T) { + csvPath := writeTempCSV(t, "name\ncsv-repo1\ncsv-repo2\n") + + var fetchedRepos []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + name := strings.TrimSuffix(strings.TrimPrefix(r.URL.Path, "/owner/"), ".json") + fetchedRepos = append(fetchedRepos, name) + writeJSONResp(t, w, map[string]interface{}{ + "name": name, + "identifier": "ident-" + name, + }) + case r.Method == "PATCH": + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchUpdateShortcut(t, server, map[string]string{"from": csvPath}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(fetchedRepos) != 2 { + t.Fatalf("expected 2 fetches, got %d", len(fetchedRepos)) + } +} + +func TestBatchUpdate_PartialFailure(t *testing.T) { + callCount := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + switch { + case r.Method == "GET" && strings.Contains(r.URL.Path, "fail-repo"): + w.WriteHeader(http.StatusNotFound) + case r.Method == "GET": + writeJSONResp(t, w, map[string]interface{}{ + "name": "ok-repo", + "identifier": "abc123", + }) + case r.Method == "PATCH": + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchUpdateShortcut(t, server, map[string]string{ + "names": "ok-repo,fail-repo", + }) + if err == nil { + t.Fatal("expected error from partial failure, got nil") + } + if !strings.Contains(err.Error(), "failed to update") { + t.Fatalf("error should mention failed count, got: %v", err) + } +} + +func TestBatchUpdate_PreservesIdentifier(t *testing.T) { + var patchedBody map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == "GET": + writeJSONResp(t, w, map[string]interface{}{ + "name": "my-repo", + "identifier": "xyz-789", + "description": "old desc", + }) + case r.Method == "PATCH": + patchedBody = decodeReqBody(t, r) + writeJSONResp(t, w, map[string]interface{}{"id": float64(1)}) + default: + t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + err := runBatchUpdateShortcut(t, server, map[string]string{ + "names": "my-repo", + "description": "new desc", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if patchedBody["name"] != "my-repo" { + t.Fatalf("name: got %q", patchedBody["name"]) + } + if patchedBody["identifier"] != "xyz-789" { + t.Fatalf("identifier: got %q", patchedBody["identifier"]) + } + if patchedBody["description"] != "new desc" { + t.Fatalf("description: got %q", patchedBody["description"]) + } +} + +// ---- readNamesFromCSV tests ---- + +func TestReadNamesFromCSV_Normal(t *testing.T) { + path := writeTempCSV(t, "name\nrepo1\nrepo2\nrepo3\n") + + names, err := readNamesFromCSV(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(names) != 3 { + t.Fatalf("got %d names, want 3", len(names)) + } + if names[0] != "repo1" || names[1] != "repo2" || names[2] != "repo3" { + t.Fatalf("got %v", names) + } +} + +func TestReadNamesFromCSV_MissingNameColumn(t *testing.T) { + path := writeTempCSV(t, "title,description\nval1,desc1\nval2,desc2\n") + + names, err := readNamesFromCSV(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(names) != 2 { + t.Fatalf("got %d names, want 2 (falls back to col 0)", len(names)) + } + if names[0] != "val1" || names[1] != "val2" { + t.Fatalf("got %v", names) + } +} + +func TestReadNamesFromCSV_OnlyHeader(t *testing.T) { + path := writeTempCSV(t, "name\n") + _, err := readNamesFromCSV(path) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestReadNamesFromCSV_SkipsEmptyName(t *testing.T) { + path := writeTempCSV(t, "name\nrepo1\n\nrepo2\n") + + names, err := readNamesFromCSV(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(names) != 2 { + t.Fatalf("got %d names, want 2", len(names)) + } +} + +func TestReadNamesFromCSV_FileNotFound(t *testing.T) { + _, err := readNamesFromCSV("/nonexistent/path.csv") + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestReadNamesFromCSV_CaseInsensitiveHeader(t *testing.T) { + path := writeTempCSV(t, "NAME\nrepo1\nrepo2\n") + + names, err := readNamesFromCSV(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(names) != 2 { + t.Fatalf("got %d names, want 2", len(names)) + } +} + +func TestReadNamesFromCSV_ExtraColumns(t *testing.T) { + path := writeTempCSV(t, "name,extra,another\nrepo1,x,y\nrepo2,a,b\n") + + names, err := readNamesFromCSV(path) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(names) != 2 { + t.Fatalf("got %d names, want 2", len(names)) + } + if names[0] != "repo1" || names[1] != "repo2" { + t.Fatalf("got %v", names) + } +} diff --git a/shortcuts/repo/batch_update.go b/shortcuts/repo/batch_update.go new file mode 100644 index 0000000..98c7469 --- /dev/null +++ b/shortcuts/repo/batch_update.go @@ -0,0 +1,165 @@ +package repo + +import ( + "encoding/csv" + "fmt" + "os" + "strings" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func newBatchUpdateShortcut() *common.Shortcut { + return &common.Shortcut{ + Name: "batch-update", + Description: "Update settings for multiple repositories", + Flags: []common.Flag{ + {Name: "names", Short: "n", Usage: "Comma-separated repository names, e.g. repo-a,repo-b"}, + {Name: "from", Usage: "CSV file path"}, + {Name: "description", Short: "d", Usage: "Shared description for all repos"}, + {Name: "private", Usage: "Set repos to private", Bool: true, Default: "false"}, + {Name: "public", Usage: "Set repos to public", Bool: true, Default: "false"}, + {Name: "dry-run", Usage: "Preview without making changes", Bool: true, Default: "false"}, + }, + Run: runBatchUpdate, + } +} + +func runBatchUpdate(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + var repoNames []string + if namesStr := ctx.Arg("names"); namesStr != "" { + for _, name := range strings.Split(namesStr, ",") { + name = strings.TrimSpace(name) + if name != "" { + repoNames = append(repoNames, name) + } + } + } + if csvPath := ctx.Arg("from"); csvPath != "" { + csvNames, err := readNamesFromCSV(csvPath) + if err != nil { + return err + } + repoNames = append(repoNames, csvNames...) + } + if len(repoNames) == 0 { + return fmt.Errorf("no repository names provided; use -n repo-a,repo-b or --from repos.csv") + } + + setPrivate := ctx.Arg("private") == "true" + setPublic := ctx.Arg("public") == "true" + if setPrivate && setPublic { + return fmt.Errorf("cannot use both --private and --public") + } + changeVisibility := setPrivate || setPublic + + dryRun := ctx.Arg("dry-run") == "true" + desc := ctx.Arg("description") + + summary := repoBatchSummary{ + Owner: ctx.Owner, + Action: "update", + DryRun: dryRun, + Total: len(repoNames), + Results: make([]repoBatchResult, 0, len(repoNames)), + } + + for _, name := range repoNames { + result := repoBatchResult{Name: name} + if dryRun { + result.Status = "planned" + summary.Succeeded++ + summary.Results = append(summary.Results, result) + continue + } + + // Fetch current repo info to get required fields for PATCH + current, err := ctx.CallAPI("GET", fmt.Sprintf("/%s/%s", ctx.Owner, name), nil) + if err != nil { + result.Status = "failed" + result.Error = fmt.Sprintf("fetch repo: %v", err) + summary.Failed++ + summary.Results = append(summary.Results, result) + continue + } + + curData, _ := current.Data.(map[string]interface{}) + curName, _ := curData["name"].(string) + identifier, _ := curData["identifier"].(string) + + body := map[string]interface{}{ + "name": curName, + "identifier": identifier, + } + if desc != "" { + body["description"] = desc + } + if changeVisibility { + body["private"] = setPrivate + } + + if _, err := ctx.CallAPI("PATCH", fmt.Sprintf("/%s/%s", ctx.Owner, name), body); err != nil { + result.Status = "failed" + result.Error = err.Error() + summary.Failed++ + } else { + result.Status = "updated" + summary.Succeeded++ + } + summary.Results = append(summary.Results, result) + } + + if err := ctx.OutputData(summary); err != nil { + return err + } + if summary.Failed > 0 { + return fmt.Errorf("%d of %d repo(s) failed to update", summary.Failed, summary.Total) + } + return nil +} + +func readNamesFromCSV(path string) ([]string, error) { + file, err := os.Open(path) + if err != nil { + return nil, fmt.Errorf("read CSV: %w", err) + } + defer file.Close() + + reader := csv.NewReader(file) + reader.TrimLeadingSpace = true + records, err := reader.ReadAll() + if err != nil { + return nil, fmt.Errorf("parse CSV: %w", err) + } + if len(records) < 2 { + return nil, fmt.Errorf("CSV must have a header row and at least one data row") + } + + header := records[0] + nameCol := -1 + for i, h := range header { + if strings.ToLower(strings.TrimSpace(h)) == "name" { + nameCol = i + break + } + } + if nameCol == -1 { + nameCol = 0 + } + + var names []string + for _, record := range records[1:] { + if nameCol >= len(record) { + continue + } + name := strings.TrimSpace(record[nameCol]) + if name != "" { + names = append(names, name) + } + } + return names, nil +} diff --git a/shortcuts/repo/repo.go b/shortcuts/repo/repo.go index 75091a4..6b92041 100644 --- a/shortcuts/repo/repo.go +++ b/shortcuts/repo/repo.go @@ -3,12 +3,16 @@ package repo import ( "fmt" "net/url" + "strconv" "github.com/gitlink-org/gitlink-cli/shortcuts/common" ) func Shortcuts() []*common.Shortcut { - return []*common.Shortcut{ + shortcuts := []*common.Shortcut{ + newBatchCreateShortcut(), + newBatchUpdateShortcut(), + newBatchDeleteShortcut(), { Name: "list", Description: "List repositories for a user or organization", @@ -33,7 +37,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPIWithQuery("GET", path, q) if err != nil { - return err + return fmt.Errorf("获取仓库列表失败: %w", err) } return ctx.Output(env) }, @@ -47,7 +51,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("GET", ctx.RepoPath(), nil) if err != nil { - return err + return fmt.Errorf("查看仓库失败: %w", err) } return ctx.Output(env) }, @@ -55,20 +59,25 @@ func Shortcuts() []*common.Shortcut { { Name: "create", Description: "Create a new repository", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + name := ctx.Arg("name") + return fmt.Sprintf("Create repository: %s", name), nil + }, Flags: []common.Flag{ {Name: "name", Short: "n", Usage: "Repository name", Required: true}, {Name: "description", Short: "d", Usage: "Repository description"}, {Name: "private", Usage: "Make repository private (true/false)", Default: "false"}, }, Run: func(ctx *common.RuntimeContext) error { - name, err := ctx.RequireArg("name") + name, err := ctx.RequireArg("name", `--name "my-project"`) if err != nil { return err } // Get current user login for the create path userEnv, err := ctx.CallAPI("GET", "/users/me", nil) if err != nil { - return fmt.Errorf("failed to get current user: %w", err) + return fmt.Errorf("获取当前用户信息失败: %w", err) } userData, _ := userEnv.Data.(map[string]interface{}) login, _ := userData["login"].(string) @@ -89,7 +98,7 @@ func Shortcuts() []*common.Shortcut { } env, err := ctx.CallAPI("POST", fmt.Sprintf("/%s/%s", login, name), body) if err != nil { - return err + return fmt.Errorf("创建仓库失败: %w", err) } return ctx.Output(env) }, @@ -97,13 +106,20 @@ func Shortcuts() []*common.Shortcut { { Name: "fork", Description: "Fork a repository", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + if err := ctx.ResolveOwnerRepo(); err != nil { + return "", err + } + return fmt.Sprintf("Fork repository %s/%s", ctx.Owner, ctx.Repo), nil + }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/forks", nil) if err != nil { - return err + return fmt.Errorf("Fork 仓库失败: %w", err) } return ctx.Output(env) }, @@ -111,16 +127,145 @@ func Shortcuts() []*common.Shortcut { { Name: "delete", Description: "Delete a repository", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + if err := ctx.ResolveOwnerRepo(); err != nil { + return "", err + } + return fmt.Sprintf("DELETE repository %s/%s", ctx.Owner, ctx.Repo), nil + }, Run: func(ctx *common.RuntimeContext) error { if err := ctx.ResolveOwnerRepo(); err != nil { return err } env, err := ctx.CallAPI("DELETE", ctx.RepoPath(), nil) + if err != nil { + return fmt.Errorf("删除仓库失败: %w", err) + } + return ctx.Output(env) + }, + }, + { + Name: "update", + Description: "Update repository settings", + Flags: []common.Flag{ + {Name: "description", Short: "d", Usage: "New description"}, + {Name: "private", Usage: "Set private (true/false)"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + body := map[string]interface{}{} + if d := ctx.Arg("description"); d != "" { + body["description"] = d + } + if p := ctx.Arg("private"); p != "" { + body["private"] = p == "true" + } + if len(body) == 0 { + return fmt.Errorf("at least one of --description, --private is required") + } + env, err := ctx.CallAPI("PATCH", ctx.RepoPath(), body) if err != nil { return err } return ctx.Output(env) }, }, + { + Name: "members", + Description: "List repository members (collaborators)", + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", ctx.RepoPath()+"/collaborators", q) + if err != nil { + return fmt.Errorf("failed to list members for %s/%s: %w", ctx.Owner, ctx.Repo, err) + } + return ctx.Output(env) + }, + }, + { + Name: "invite", + Description: "Invite a member to a repository", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + if err := ctx.ResolveOwnerRepo(); err != nil { + return "", err + } + userID := ctx.Arg("user-id") + return fmt.Sprintf("Invite user %s to %s/%s", userID, ctx.Owner, ctx.Repo), nil + }, + Flags: []common.Flag{ + {Name: "user-id", Usage: "User ID to invite (required)", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + userID, err := ctx.RequireArg("user-id", "--user-id 42") + if err != nil { + return err + } + uid, err := strconv.Atoi(userID) + if err != nil { + return fmt.Errorf("invalid user-id: %s (must be an integer)", userID) + } + body := map[string]interface{}{"user_id": uid} + env, err := ctx.CallAPI("POST", ctx.RepoPath()+"/collaborators", body) + if err != nil { + return fmt.Errorf("failed to invite user %d to %s/%s: %w", uid, ctx.Owner, ctx.Repo, err) + } + return ctx.Output(env) + }, + }, + { + Name: "remove-member", + Description: "Remove a member from a repository", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + if err := ctx.ResolveOwnerRepo(); err != nil { + return "", err + } + userID := ctx.Arg("user-id") + return fmt.Sprintf("Remove user %s from %s/%s", userID, ctx.Owner, ctx.Repo), nil + }, + Flags: []common.Flag{ + {Name: "user-id", Usage: "User ID to remove (required)", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + userID, err := ctx.RequireArg("user-id", "--user-id 42") + if err != nil { + return err + } + uid, err := strconv.Atoi(userID) + if err != nil { + return fmt.Errorf("invalid user-id: %s (must be an integer)", userID) + } + body := map[string]interface{}{"user_id": uid} + env, err := ctx.CallAPI("DELETE", ctx.RepoPath()+"/collaborators/remove", body) + if err != nil { + return fmt.Errorf("failed to remove user %d from %s/%s: %w", uid, ctx.Owner, ctx.Repo, err) + } + return ctx.Output(env) + }, + }, } + + // 合并批量成员管理命令 + shortcuts = append(shortcuts, BatchMemberShortcuts()...) + + return shortcuts } diff --git a/shortcuts/search/search.go b/shortcuts/search/search.go index 443dec3..25cde0a 100644 --- a/shortcuts/search/search.go +++ b/shortcuts/search/search.go @@ -1,6 +1,7 @@ package search import ( + "fmt" "net/url" "github.com/gitlink-org/gitlink-cli/shortcuts/common" @@ -17,14 +18,17 @@ func Shortcuts() []*common.Shortcut { {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { - keyword, _ := ctx.RequireArg("keyword") + keyword, err := ctx.RequireArg("keyword", "--keyword my-project") + if err != nil { + return err + } q := url.Values{} q.Set("search", keyword) q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) env, err := ctx.CallAPIWithQuery("GET", "/projects", q) if err != nil { - return err + return fmt.Errorf("搜索仓库失败: %w", err) } return ctx.Output(env) }, @@ -38,12 +42,39 @@ func Shortcuts() []*common.Shortcut { {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, }, Run: func(ctx *common.RuntimeContext) error { - keyword, _ := ctx.RequireArg("keyword") + keyword, err := ctx.RequireArg("keyword", "--keyword zhangsan") + if err != nil { + return err + } q := url.Values{} q.Set("search", keyword) q.Set("page", ctx.Arg("page")) q.Set("limit", ctx.Arg("limit")) env, err := ctx.CallAPIWithQuery("GET", "/users/list", q) + if err != nil { + return fmt.Errorf("搜索用户失败: %w", err) + } + return ctx.Output(env) + }, + }, + { + Name: "issues", + Description: "Search issues", + Flags: []common.Flag{ + {Name: "keyword", Short: "k", Usage: "Search keyword", Required: true}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + keyword, _ := ctx.RequireArg("keyword", "--keyword myproject") + q := url.Values{} + q.Set("search", keyword) + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/v1%s/issues", ctx.RepoPath()), q) if err != nil { return err } diff --git a/shortcuts/team/team.go b/shortcuts/team/team.go new file mode 100644 index 0000000..18f4de4 --- /dev/null +++ b/shortcuts/team/team.go @@ -0,0 +1,137 @@ +package team + +import ( + "fmt" + "net/url" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List teams in an organization", + Flags: []common.Flag{ + {Name: "org", Short: "o", Usage: "Organization ID or login", Required: true}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + org, _ := ctx.RequireArg("org", "--org my-org") + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/teams", org), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "create", + Description: "Create a team in an organization", + Flags: []common.Flag{ + {Name: "org", Short: "o", Usage: "Organization ID or login", Required: true}, + {Name: "name", Short: "n", Usage: "Team name", Required: true}, + {Name: "description", Short: "d", Usage: "Team description"}, + }, + Run: func(ctx *common.RuntimeContext) error { + org, _ := ctx.RequireArg("org", "--org my-org") + name, _ := ctx.RequireArg("name", `--name "My Team"`) + body := map[string]interface{}{ + "name": name, + } + if desc := ctx.Arg("description"); desc != "" { + body["description"] = desc + } + env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams", org), body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "delete", + Description: "Delete a team", + Flags: []common.Flag{ + {Name: "org", Short: "o", Usage: "Organization ID or login", Required: true}, + {Name: "id", Short: "i", Usage: "Team ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + org, _ := ctx.RequireArg("org", "--org my-org") + id, _ := ctx.RequireArg("id", "--id 1") + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/teams/%s", org, id), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "members", + Description: "List members of a team", + Flags: []common.Flag{ + {Name: "org", Short: "o", Usage: "Organization ID or login", Required: true}, + {Name: "id", Short: "i", Usage: "Team ID", Required: true}, + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + org, _ := ctx.RequireArg("org", "--org my-org") + id, _ := ctx.RequireArg("id", "--id 1") + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", fmt.Sprintf("/organizations/%s/teams/%s/members", org, id), q) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "member-add", + Description: "Add a user to a team", + Flags: []common.Flag{ + {Name: "org", Short: "o", Usage: "Organization ID or login", Required: true}, + {Name: "team", Short: "t", Usage: "Team ID", Required: true}, + {Name: "user", Short: "u", Usage: "User login or ID to add", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + org, _ := ctx.RequireArg("org", "--org my-org") + team, _ := ctx.RequireArg("team", "--team dev-team") + user, _ := ctx.RequireArg("user", "--user alice") + body := map[string]interface{}{ + "user_id": user, + } + env, err := ctx.CallAPI("POST", fmt.Sprintf("/organizations/%s/teams/%s/members", org, team), body) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + { + Name: "member-remove", + Description: "Remove a user from a team", + Flags: []common.Flag{ + {Name: "org", Short: "o", Usage: "Organization ID or login", Required: true}, + {Name: "team", Short: "t", Usage: "Team ID", Required: true}, + {Name: "user", Short: "u", Usage: "User login or ID to remove", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + org, _ := ctx.RequireArg("org", "--org my-org") + team, _ := ctx.RequireArg("team", "--team dev-team") + user, _ := ctx.RequireArg("user", "--user alice") + env, err := ctx.CallAPI("DELETE", fmt.Sprintf("/organizations/%s/teams/%s/members/%s", org, team, user), nil) + if err != nil { + return err + } + return ctx.Output(env) + }, + }, + } +} diff --git a/shortcuts/user/user.go b/shortcuts/user/user.go index cfcaa2a..a59eae4 100644 --- a/shortcuts/user/user.go +++ b/shortcuts/user/user.go @@ -14,7 +14,7 @@ func Shortcuts() []*common.Shortcut { Run: func(ctx *common.RuntimeContext) error { env, err := ctx.CallAPI("GET", "/users/me", nil) if err != nil { - return err + return fmt.Errorf("获取当前用户信息失败: %w", err) } return ctx.Output(env) }, @@ -26,13 +26,13 @@ func Shortcuts() []*common.Shortcut { {Name: "login", Short: "l", Usage: "User login name", Required: true}, }, Run: func(ctx *common.RuntimeContext) error { - login, err := ctx.RequireArg("login") + login, err := ctx.RequireArg("login", "--login zhangsan") if err != nil { return err } env, err := ctx.CallAPI("GET", fmt.Sprintf("/users/%s", login), nil) if err != nil { - return err + return fmt.Errorf("查看用户信息失败: %w", err) } return ctx.Output(env) }, diff --git a/shortcuts/webhook/webhook.go b/shortcuts/webhook/webhook.go new file mode 100644 index 0000000..7e10c59 --- /dev/null +++ b/shortcuts/webhook/webhook.go @@ -0,0 +1,340 @@ +package webhook + +import ( + "fmt" + "net/url" + "strings" + + clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +// 支持的Webhook事件类型 +var supportedEvents = []string{ + "push", + "pull_request", + "issue", + "issue_assign", + "issue_comment", + "pull_request_assign", + "pull_request_comment", + "merge_request", + "repository", + "branch", + "tag", +} + +func isEventSupported(event string) bool { + for _, supported := range supportedEvents { + if event == supported { + return true + } + } + return false +} + +func parseEvents(eventsStr string) []string { + if eventsStr == "" { + return []string{"push"} // 默认事件 + } + events := strings.Split(eventsStr, ",") + var validEvents []string + for _, event := range events { + event = strings.TrimSpace(event) + if isEventSupported(event) { + validEvents = append(validEvents, event) + } + } + return validEvents +} + +// webhookRepoPath returns the webhook API path prefix: /v1/{owner}/{repo} +// Note: BaseURL already includes /api prefix +func webhookRepoPath(ctx *common.RuntimeContext) string { + return fmt.Sprintf("/v1/%s/%s", ctx.Owner, ctx.Repo) +} + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List all webhooks for a repository", + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: "Page number", Default: "1"}, + {Name: "limit", Short: "l", Usage: "Items per page", Default: "20"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + q := url.Values{} + q.Set("page", ctx.Arg("page")) + q.Set("limit", ctx.Arg("limit")) + env, err := ctx.CallAPIWithQuery("GET", webhookRepoPath(ctx)+"/webhooks", q) + if err != nil { + return fmt.Errorf("获取 Webhook 列表失败: %w", err) + } + return ctx.Output(env) + }, + }, + { + Name: "create", + Description: "Create a new webhook", + Flags: []common.Flag{ + {Name: "url", Short: "u", Usage: "Webhook callback URL", Required: true}, + {Name: "events", Short: "e", Usage: "Trigger events (comma-separated), e.g., push,pull_request,issue", Default: "push"}, + {Name: "active", Usage: "Webhook active status (true/false)", Default: "true"}, + {Name: "secret", Usage: "Webhook secret for HMAC verification"}, + {Name: "description", Short: "d", Usage: "Webhook description"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + webhookURL, err := ctx.RequireArg("url", "--url https://example.com/hook") + if err != nil { + return err + } + + events := parseEvents(ctx.Arg("events")) + if len(events) == 0 { + return clierrors.InputError( + "no valid events specified", + fmt.Sprintf("支持的事件类型: %s", strings.Join(supportedEvents, ", ")), + ) + } + + payload := map[string]interface{}{ + "url": webhookURL, + "http_method": "POST", + "active": true, + "content_type": "json", + } + + if len(events) > 0 { + payload["events"] = events + } else { + payload["events"] = []string{"push"} + } + + if secret := ctx.Arg("secret"); secret != "" { + payload["secret"] = secret + } + + if description := ctx.Arg("description"); description != "" { + payload["description"] = description + } + + env, err := ctx.CallAPI("POST", webhookRepoPath(ctx)+"/webhooks", payload) + if err != nil { + return fmt.Errorf("创建 Webhook 失败: %w", err) + } + return ctx.Output(env) + }, + }, + { + Name: "update", + Description: "Update an existing webhook", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + {Name: "url", Short: "u", Usage: "Webhook callback URL"}, + {Name: "events", Short: "e", Usage: "Trigger events (comma-separated)"}, + {Name: "active", Usage: "Webhook active status (true/false)"}, + {Name: "content_type", Usage: "Content type (json/form)"}, + {Name: "secret", Usage: "Webhook secret for HMAC verification"}, + {Name: "description", Short: "d", Usage: "Webhook description"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + webhookID, err := ctx.RequireArg("id", "--id 1") + if err != nil { + return err + } + + payload := map[string]interface{}{ + "http_method": "POST", + "active": true, + "content_type": "json", + } + + // 如果用户没有提供URL,获取当前webhook的URL + webhookURL := ctx.Arg("url") + if webhookURL == "" { + getEnv, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil) + if err != nil { + return fmt.Errorf("获取 Webhook 当前信息失败: %w", err) + } + webhookData, ok := getEnv.Data.(map[string]interface{}) + if !ok { + return fmt.Errorf("failed to parse webhook data") + } + currentURL, ok := webhookData["url"].(string) + if !ok || currentURL == "" { + return fmt.Errorf("failed to get current webhook URL") + } + webhookURL = currentURL + } + payload["url"] = webhookURL + + if events := ctx.Arg("events"); events != "" { + validEvents := parseEvents(events) + if len(validEvents) == 0 { + return clierrors.InputError( + "no valid events specified", + fmt.Sprintf("支持的事件类型: %s", strings.Join(supportedEvents, ", ")), + ) + } + payload["events"] = validEvents + } + + + if contentType := ctx.Arg("content_type"); contentType != "" { + payload["content_type"] = contentType + } + + if secret := ctx.Arg("secret"); secret != "" { + payload["secret"] = secret + } + + if description := ctx.Arg("description"); description != "" { + payload["description"] = description + } + + + env, err := ctx.CallAPI("PUT", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), payload) + if err != nil { + return fmt.Errorf("更新 Webhook 失败: %w", err) + } + return ctx.Output(env) + }, + }, + { + Name: "delete", + Description: "Delete a webhook", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + webhookID, err := ctx.RequireArg("id", "--id 1") + if err != nil { + return err + } + + _, delErr := ctx.CallAPI("DELETE", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil) + if delErr != nil { + // 验证是否真的删除成功(类似release的处理) + _, viewErr := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil) + if viewErr != nil { + // Webhook不存在了,说明删除成功 + return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ + "message": "Webhook deleted successfully", + }, nil)) + } + return fmt.Errorf("删除 Webhook 失败: %w", delErr) + } + return ctx.Output(output.SuccessEnvelope(map[string]interface{}{ + "message": "Webhook deleted successfully", + }, nil)) + }, + }, + { + Name: "test", + Description: "Test a webhook delivery (send a ping event)", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + {Name: "event", Short: "e", Usage: "Event type to test (default: push)", Default: "push"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + webhookID, err := ctx.RequireArg("id", "--id 1") + if err != nil { + return err + } + + eventType := ctx.Arg("event") + if !isEventSupported(eventType) { + return clierrors.InputError( + fmt.Sprintf("unsupported event type: %s", eventType), + fmt.Sprintf("支持的事件类型: %s", strings.Join(supportedEvents, ", ")), + ) + } + + env, err := ctx.CallAPI("POST", fmt.Sprintf("%s/webhooks/%s/tests", webhookRepoPath(ctx), webhookID), nil) + if err != nil { + return fmt.Errorf("测试 Webhook 失败: %w", err) + } + return ctx.Output(env) + }, + }, + { + Name: "info", + Description: "Show webhook details", + Flags: []common.Flag{ + {Name: "id", Short: "i", Usage: "Webhook ID", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + webhookID, err := ctx.RequireArg("id", "--id 1") + if err != nil { + return err + } + + env, err := ctx.CallAPI("GET", fmt.Sprintf("%s/webhooks/%s", webhookRepoPath(ctx), webhookID), nil) + if err != nil { + return fmt.Errorf("查看 Webhook 详情失败: %w", err) + } + return ctx.Output(env) + }, + }, + { + Name: "events", + Description: "List all supported event types for webhooks", + Run: func(ctx *common.RuntimeContext) error { + eventInfo := make([]map[string]interface{}, 0) + for _, event := range supportedEvents { + eventInfo = append(eventInfo, map[string]interface{}{ + "event": event, + "supported": true, + "description": getEventDescription(event), + }) + } + return ctx.Output(output.SuccessEnvelope(eventInfo, nil)) + }, + }, + } +} + +func getEventDescription(event string) string { + descriptions := map[string]string{ + "push": "Code push events", + "pull_request": "Pull request events", + "issue": "Issue events", + "issue_assign": "Issue assignment events", + "issue_comment": "Issue comment events", + "pull_request_assign": "Pull request assignment events", + "pull_request_comment":"Pull request comment events", + "merge_request": "Merge request events", + "repository": "Repository events", + "branch": "Branch creation/deletion events", + "tag": "Tag creation/deletion events", + } + if desc, ok := descriptions[event]; ok { + return desc + } + return "Custom event" +} diff --git a/shortcuts/webhook/webhook_test.go b/shortcuts/webhook/webhook_test.go new file mode 100644 index 0000000..a2f6305 --- /dev/null +++ b/shortcuts/webhook/webhook_test.go @@ -0,0 +1,243 @@ +package webhook + +import ( + "strings" + "testing" + + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func TestIsEventSupported(t *testing.T) { + tests := []struct { + name string + event string + expected bool + }{ + { + name: "supported event - push", + event: "push", + expected: true, + }, + { + name: "supported event - pull_request", + event: "pull_request", + expected: true, + }, + { + name: "supported event - issue", + event: "issue", + expected: true, + }, + { + name: "unsupported event", + event: "unsupported_event", + expected: false, + }, + { + name: "empty event", + event: "", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isEventSupported(tt.event) + if result != tt.expected { + t.Errorf("isEventSupported(%q) = %v, want %v", tt.event, result, tt.expected) + } + }) + } +} + +func TestParseEvents(t *testing.T) { + tests := []struct { + name string + events string + expected []string + }{ + { + name: "single event", + events: "push", + expected: []string{"push"}, + }, + { + name: "multiple events", + events: "push,pull_request,issue", + expected: []string{"push", "pull_request", "issue"}, + }, + { + name: "events with spaces", + events: "push, pull_request, issue", + expected: []string{"push", "pull_request", "issue"}, + }, + { + name: "mixed valid and invalid events", + events: "push,invalid_event,pull_request", + expected: []string{"push", "pull_request"}, + }, + { + name: "empty string - default to push", + events: "", + expected: []string{"push"}, + }, + { + name: "all invalid events", + events: "invalid1,invalid2", + expected: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := parseEvents(tt.events) + if len(result) != len(tt.expected) { + t.Errorf("parseEvents(%q) returned %d events, want %d", tt.events, len(result), len(tt.expected)) + return + } + for i, event := range result { + if event != tt.expected[i] { + t.Errorf("parseEvents(%q)[%d] = %q, want %q", tt.events, i, event, tt.expected[i]) + } + } + }) + } +} + +func TestGetEventDescription(t *testing.T) { + tests := []struct { + name string + event string + expected string + }{ + { + name: "push event description", + event: "push", + expected: "Code push events", + }, + { + name: "pull_request event description", + event: "pull_request", + expected: "Pull request events", + }, + { + name: "issue event description", + event: "issue", + expected: "Issue events", + }, + { + name: "unknown event description", + event: "unknown_event", + expected: "Custom event", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := getEventDescription(tt.event) + if result != tt.expected { + t.Errorf("getEventDescription(%q) = %q, want %q", tt.event, result, tt.expected) + } + }) + } +} + +func TestShortcuts(t *testing.T) { + shortcuts := Shortcuts() + + if len(shortcuts) == 0 { + t.Fatal("Shortcuts() returned empty slice") + } + + // 验证所有必需的shortcuts都存在 + expectedShortcuts := []string{ + "list", "create", "update", "delete", "test", "info", "events", + } + + shortcutNames := make(map[string]bool) + for _, sc := range shortcuts { + shortcutNames[sc.Name] = true + } + + for _, expected := range expectedShortcuts { + if !shortcutNames[expected] { + t.Errorf("Missing shortcut: %s", expected) + } + } + + // 验证每个shortcut的基本属性 + for _, sc := range shortcuts { + if sc.Name == "" { + t.Error("Shortcut has empty Name") + } + if sc.Description == "" { + t.Errorf("Shortcut %q has empty Description", sc.Name) + } + if sc.Run == nil { + t.Errorf("Shortcut %q has nil Run function", sc.Name) + } + } +} + +// TestWebhookEventsList tests the events shortcut to ensure it returns valid event information +func TestWebhookEventsList(t *testing.T) { + shortcuts := Shortcuts() + var eventsShortcut *common.Shortcut + for _, sc := range shortcuts { + if sc.Name == "events" { + eventsShortcut = sc + break + } + } + + if eventsShortcut == nil { + t.Fatal("Events shortcut not found") + } + + // 验证所有支持的事件都有描述 + for _, event := range supportedEvents { + desc := getEventDescription(event) + if desc == "" { + t.Errorf("Event %q has empty description", event) + } + } +} + +// TestEventValidationIntegration tests event validation in an integrated manner +func TestEventValidationIntegration(t *testing.T) { + // 测试所有支持的事件都能被正确识别 + for _, event := range supportedEvents { + if !isEventSupported(event) { + t.Errorf("Supported event %q is not recognized by isEventSupported", event) + } + // 确保描述不为空 + desc := getEventDescription(event) + if desc == "" { + t.Errorf("Event %q has empty description", event) + } + } + + // 测试解析包含所有支持的事件字符串 + allEvents := strings.Join(supportedEvents, ",") + parsed := parseEvents(allEvents) + if len(parsed) != len(supportedEvents) { + t.Errorf("Parsing all events returned %d results, expected %d", len(parsed), len(supportedEvents)) + } +} + +// BenchmarkParseEvents benchmarks the event parsing function +func BenchmarkParseEvents(b *testing.B) { + eventsStr := "push,pull_request,issue,issue_assign,issue_comment,pull_request_assign,pull_request_comment" + for i := 0; i < b.N; i++ { + parseEvents(eventsStr) + } +} + +// BenchmarkIsEventSupported benchmarks the event validation function +func BenchmarkIsEventSupported(b *testing.B) { + for i := 0; i < b.N; i++ { + for _, event := range supportedEvents { + isEventSupported(event) + } + } +} diff --git a/shortcuts/wiki/CHANGELOG.md b/shortcuts/wiki/CHANGELOG.md new file mode 100644 index 0000000..0b1ff30 --- /dev/null +++ b/shortcuts/wiki/CHANGELOG.md @@ -0,0 +1,84 @@ +# wiki 模块变更日志 + +## 2026-05-31 新增 `wiki +lint` 文档质量检查命令 + +> **注意:`+lint` 命令当前仅在本地编译版本中可用**(全局安装的 `gitlink-cli` 暂未包含)。需先 `go build -o gitlink-cli.exe .` 然后使用 `./gitlink-cli.exe wiki +lint`。 + +### 使用方法 + +```bash +# 在仓库目录下,owner/repo 自动从 git remote 解析 +gitlink-cli wiki +lint + +# 只运行指定检查项(逗号分隔) +gitlink-cli wiki +lint --check links +gitlink-cli wiki +lint --check links,headings + +# JSON 格式输出 +gitlink-cli wiki +lint --format json +``` + +### 检查项 + +| 检查名称 | 级别 | 说明 | +|----------|------|------| +| empty | error | 页面内容为空 | +| headings | warning | 缺少 H1 标题(不以 `# ` 开头) | +| short | warning | 内容不足 50 字符 | +| links | error | wiki 内链指向不存在的页面 | +| images | warning | 外部图片 URL 返回 404 或不可达 | + +### 主要修改 + +**文件**: `shortcuts/wiki/wiki.go` + +#### 1. 新增类型 + +```go +type LintIssue struct { + Page string `json:"page"` + Level string `json:"level"` // "error" / "warning" + Check string `json:"check"` + Message string `json:"message"` +} + +type LintSummary struct { + Repository string `json:"repository"` + TotalPages int `json:"total_pages"` + TotalIssues int `json:"total_issues"` + Errors int `json:"errors"` + Warnings int `json:"warnings"` + Results []LintIssue `json:"results"` +} +``` + +#### 2. Bug 修复 + +- 修复 lint 获取页面内容时使用 `title` 而非 `sub_url` 导致部分页面 500 错误的问题 + +#### 3. 新增函数(含 sub_url/title 双字段支持) + +- `isCheckEnabled(checkFilter, name string) bool` — 判断指定检查是否被 `--check` 参数选中 +- `checkEmpty(page, content string) []LintIssue` — 检测空页面 +- `checkHeading(page, content string) []LintIssue` — 检测缺少 H1 +- `checkShort(page, content string) []LintIssue` — 检测内容过短 +- `checkDeadLinks(page, content string, knownTitles map[string]bool) []LintIssue` — 正则提取内链,在已知标题集合中查找死链 +- `checkImages(page, content string, httpClient *http.Client) []LintIssue` — 正则提取外部图片 URL,HEAD 请求检查可达性(超时 5s) +- `runLint(ctx *common.RuntimeContext) error` — lint 主流程:解析 owner/repo → 获取页面列表 → 逐页检查 → 汇总输出 + +#### 4. 新增 Shortcut 注册 + +```go +{ + Name: "lint", + Description: "Check wiki pages for quality issues", + Flags: []common.Flag{ + {Name: "check", Usage: "Specific checks to run (comma-separated): links,headings,images,empty. Default: all"}, + }, + Run: runLint, +} +``` + +#### 5. 新增 import + +`net/http`, `regexp`, `strings`, `time` diff --git a/shortcuts/wiki/wiki.go b/shortcuts/wiki/wiki.go new file mode 100644 index 0000000..0284521 --- /dev/null +++ b/shortcuts/wiki/wiki.go @@ -0,0 +1,826 @@ +package wiki + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/gitlink-org/gitlink-cli/internal/auth" + "github.com/gitlink-org/gitlink-cli/internal/client" + clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +var projectIDCache sync.Map + +func wikiPath(endpoint string) string { + return "/wiki/open/" + endpoint +} + +// getGatewayClient returns a client targeting the Wiki API gateway. +// The BaseURL is resolved from RuntimeContext.GatewayBaseURL, which in turn +// honours (in order): GITLINK_GATEWAY_URL env > config gateway_base_url > default. +// HTTP client falls back to auth.NewHTTPClient() when ctx.GatewayHTTPClient is nil. +func getGatewayClient(ctx *common.RuntimeContext) *client.Client { + baseURL := ctx.GatewayBaseURL + if baseURL == "" { + baseURL = "https://gateway.gitlink.org.cn/api" + } + httpClient := ctx.GatewayHTTPClient + if httpClient == nil { + httpClient = auth.NewHTTPClient() + } + return &client.Client{ + HTTP: httpClient, + BaseURL: baseURL, + SkipJSONSuffix: true, + Debug: ctx.Client.Debug, + } +} + +func callWikiAPI(ctx *common.RuntimeContext, method, path string, body interface{}) (*output.Envelope, error) { + gc := getGatewayClient(ctx) + env, err := gc.Do(method, path, body, nil) + if err != nil { + return nil, err + } + return unwrapGatewayResponse(env) +} + +func callWikiAPIWithQuery(ctx *common.RuntimeContext, method, path string, query url.Values) (*output.Envelope, error) { + gc := getGatewayClient(ctx) + env, err := gc.Do(method, path, nil, query) + if err != nil { + return nil, err + } + return unwrapGatewayResponse(env) +} + +func unwrapGatewayResponse(env *output.Envelope) (*output.Envelope, error) { + resp, ok := env.Data.(map[string]interface{}) + if !ok { + return env, nil + } + if code, ok := resp["code"]; ok { + switch v := code.(type) { + case float64: + // HTTP 2xx 全部视为成功(200 OK / 201 Created / 202 Accepted / 204 No Content 等) + // 之前只接受 200/201,导致 DELETE 返回 204 时被误判为失败 + if v < 200 || v >= 300 { + msg, _ := resp["msg"].(string) + // 必须返回 *clierrors.CLIError,否则 shortcuts/common.TryPrintError + // 的 errors.As 无法识别,错误就不会按 envelope 格式输出, + // 会回退到 stderr 的纯文本输出(破坏 --format json/table/yaml)。 + kind := clierrors.KindServer + if int(v) == 404 { + kind = clierrors.KindNotFound + } else if int(v) == 401 || int(v) == 403 { + kind = clierrors.KindForbidden + } + return nil, clierrors.New(kind, msg, + "检查 owner/repo 是否正确,或确认仓库已在 GitLink 网页端开启 Wiki 功能") + } + } + } + if innerData, ok := resp["data"]; ok { + return output.SuccessEnvelope(innerData, env.Meta), nil + } + return env, nil +} + +func resolveProjectID(ctx *common.RuntimeContext) (string, error) { + key := ctx.Owner + "/" + ctx.Repo + if cached, ok := projectIDCache.Load(key); ok { + return cached.(string), nil + } + + path := fmt.Sprintf("/%s/%s/detail", ctx.Owner, ctx.Repo) + env, err := ctx.CallAPI("GET", path, nil) + if err != nil { + return "", fmt.Errorf("failed to fetch project details (needed for projectId): %w", err) + } + + data, ok := env.Data.(map[string]interface{}) + if !ok { + return "", fmt.Errorf("unexpected response from project detail API") + } + + pid, ok := data["project_id"] + if !ok { + return "", fmt.Errorf("project_id not found in project detail response") + } + + var pidStr string + switch v := pid.(type) { + case float64: + pidStr = fmt.Sprintf("%.0f", v) + case int: + pidStr = fmt.Sprintf("%d", v) + default: + pidStr = fmt.Sprintf("%v", v) + } + + projectIDCache.Store(key, pidStr) + return pidStr, nil +} + +func parseProjectIDInt(pid string) int { + n, _ := strconv.Atoi(pid) + return n +} + +func resolveUpdateContent(ctx *common.RuntimeContext, text, filePath string) (string, error) { + if text != "" { + return text, nil + } + if filePath != "" { + data, err := os.ReadFile(filePath) + if err != nil { + return "", fmt.Errorf("failed to read file %s: %w", filePath, err) + } + return string(data), nil + } + return "", fmt.Errorf("no content provided") +} + +// fetchPageContent 获取 wiki 页面明文内容。 +// +// 自动重试策略:GitLink 后端创建 wiki 时会自动给 sub_url 追加 ".-" 后缀, +// 而 wiki +list 返回的 title 不带后缀。若首次用原始 pageName 查询失败且 +// pageName 不带 ".-" 后缀,自动用 pageName+".-" 重试一次。 +// +// 返回值: +// - content: 解码后的明文 markdown +// - actualPageName: 实际查询成功的 pageName(可能带 ".-" 后缀),供调用方做后续写操作 +// - err: 错误信息 +func fetchPageContent(ctx *common.RuntimeContext, projectID, pageName string) (content string, actualPageName string, err error) { + c, actual, err := fetchPageContentOnce(ctx, projectID, pageName) + if err == nil { + return c, actual, nil + } + // 首次失败且 pageName 不带 ".-" 后缀:自动重试一次 + if !strings.HasSuffix(pageName, ".-") { + c2, actual2, err2 := fetchPageContentOnce(ctx, projectID, pageName+".-") + if err2 == nil { + return c2, actual2, nil + } + } + return "", "", fmt.Errorf("获取 Wiki 页面现有内容失败: %w", err) +} + +// fetchPageContentOnce 单次尝试获取 wiki 页面内容(不做重试)。 +func fetchPageContentOnce(ctx *common.RuntimeContext, projectID, pageName string) (string, string, error) { + q := url.Values{} + q.Set("owner", ctx.Owner) + q.Set("repo", ctx.Repo) + q.Set("projectId", projectID) + q.Set("pageName", pageName) + env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q) + if err != nil { + return "", "", err + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + return "", "", fmt.Errorf("unexpected response from getWiki") + } + b64, _ := data["content_base64"].(string) + if b64 == "" { + return "", pageName, nil + } + decoded, err := base64.StdEncoding.DecodeString(b64) + if err != nil { + return "", "", fmt.Errorf("failed to decode page content: %w", err) + } + return string(decoded), pageName, nil +} + +// fetchWikiPage 按 pageName 查询 wiki 页面,返回完整的 envelope。 +// 调用方负责处理错误(包括 404)和后续的 sub_url 重试逻辑。 +func fetchWikiPage(ctx *common.RuntimeContext, projectID, pageName string) (*output.Envelope, error) { + q := url.Values{} + q.Set("owner", ctx.Owner) + q.Set("repo", ctx.Repo) + q.Set("projectId", projectID) + q.Set("pageName", pageName) + return callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q) +} + +func resolveContent(ctx *common.RuntimeContext) (string, error) { + if content := ctx.Arg("content"); content != "" { + return content, nil + } + if filePath := ctx.Arg("file"); filePath != "" { + data, err := os.ReadFile(filePath) + if err != nil { + return "", fmt.Errorf("failed to read file %s: %w", filePath, err) + } + return string(data), nil + } + return "", fmt.Errorf("--content or --file is required to provide wiki page content") +} + +func cleanWikiList(env *output.Envelope) { + items, ok := env.Data.([]interface{}) + if !ok { + return + } + for _, item := range items { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + delete(m, "wiki_clone_link") + if raw, ok := m["sub_url"].(string); ok { + if decoded, err := url.QueryUnescape(raw); err == nil { + m["sub_url"] = decoded + } + } + } +} + +// outputWithDecodedContent 解码 wiki 响应中的所有 base64 字段,用明文替换原始乱码。 +// +// 设计权衡(agent 友好性): +// - 后端返回的字段(content_base64、sidebar、footer)都是 base64 编码,对 agent 不可读 +// - 解码后用明文替换/移除原始字段,agent 可直接阅读、抽取、总结 +// - 节省 ~33% token(base64 编码膨胀部分) +// +// 已知字段映射: +// - content_base64 → content(重命名,删除原字段) +// - sidebar → sidebar(原地替换,仅当解码成功) +// - footer → footer(原地替换,仅当解码成功) +// +// 安全策略:仅当 base64.StdEncoding.DecodeString 成功时才替换; +// 若后端某天改为明文,解码失败会自动跳过,不影响兼容性。 +func outputWithDecodedContent(ctx *common.RuntimeContext, env *output.Envelope) error { + data := env.Data + + // 后端某些端点(如 createWiki/updateWiki)把 data 返回为 JSON 字符串, + // client.go 会把它解析为 json.RawMessage(而非 map);这里先转回 map 再处理。 + // view/getWiki 端点直接返回 JSON 对象,data 已是 map[string]interface{}。 + if raw, ok := data.(json.RawMessage); ok { + var m map[string]interface{} + if err := json.Unmarshal(raw, &m); err == nil { + data = m + env.Data = m + } + } + + m, ok := data.(map[string]interface{}) + if !ok { + return ctx.Output(env) + } + // content_base64 → content(重命名) + if b64, ok := m["content_base64"].(string); ok && b64 != "" { + if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil { + m["content"] = string(decoded) + delete(m, "content_base64") + } + } + // sidebar / footer:原地替换(仅当能解码为 base64 时) + for _, field := range []string{"sidebar", "footer"} { + if b64, ok := m[field].(string); ok && b64 != "" { + if decoded, err := base64.StdEncoding.DecodeString(b64); err == nil { + m[field] = string(decoded) + } + } + } + return ctx.Output(env) +} + +// --- lint types and implementation --- + +type LintIssue struct { + Page string `json:"page"` + Level string `json:"level"` + Check string `json:"check"` + Message string `json:"message"` +} + +type LintSummary struct { + Repository string `json:"repository"` + TotalPages int `json:"total_pages"` + TotalIssues int `json:"total_issues"` + Errors int `json:"errors"` + Warnings int `json:"warnings"` + Results []LintIssue `json:"results"` +} + +var ( + mdLinkRe = regexp.MustCompile(`\[([^\]]*)\]\(([^)]+)\)`) + imageLinkRe = regexp.MustCompile(`!\[([^\]]*)\]\((https?://[^)]+)\)`) +) + +func isCheckEnabled(checkFilter, name string) bool { + if checkFilter == "" { + return true + } + for _, c := range strings.Split(checkFilter, ",") { + if strings.TrimSpace(c) == name { + return true + } + } + return false +} + +func checkEmpty(page, content string) []LintIssue { + if strings.TrimSpace(content) == "" { + return []LintIssue{{Page: page, Level: "error", Check: "empty", Message: "page is empty"}} + } + return nil +} + +func checkHeading(page, content string) []LintIssue { + if content != "" && !strings.HasPrefix(strings.TrimSpace(content), "# ") { + return []LintIssue{{Page: page, Level: "warning", Check: "headings", Message: "missing H1 heading"}} + } + return nil +} + +func checkShort(page, content string) []LintIssue { + if content != "" && len(content) < 50 { + return []LintIssue{{Page: page, Level: "warning", Check: "short", Message: fmt.Sprintf("content too short (%d chars)", len(content))}} + } + return nil +} + +func checkDeadLinks(page, content string, knownTitles map[string]bool) []LintIssue { + var issues []LintIssue + for _, m := range mdLinkRe.FindAllStringSubmatch(content, -1) { + if len(m) < 3 { + continue + } + target := m[2] + // skip external links and anchors + if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") || strings.HasPrefix(target, "#") { + continue + } + decoded, _ := url.PathUnescape(target) + if decoded == "" { + decoded = target + } + if !knownTitles[decoded] && !knownTitles[target] { + issues = append(issues, LintIssue{ + Page: page, + Level: "error", + Check: "links", + Message: fmt.Sprintf("dead link: [%s](%s) -> page %q not found", m[1], target, decoded), + }) + } + } + return issues +} + +func checkImages(page, content string, httpClient *http.Client) []LintIssue { + var issues []LintIssue + for _, m := range imageLinkRe.FindAllStringSubmatch(content, -1) { + if len(m) < 3 { + continue + } + imgURL := m[2] + req, err := http.NewRequest("HEAD", imgURL, nil) + if err != nil { + issues = append(issues, LintIssue{Page: page, Level: "warning", Check: "images", Message: fmt.Sprintf("broken image: %s -> invalid URL", imgURL)}) + continue + } + resp, err := httpClient.Do(req) + if err != nil { + issues = append(issues, LintIssue{Page: page, Level: "warning", Check: "images", Message: fmt.Sprintf("broken image: %s -> unreachable", imgURL)}) + continue + } + resp.Body.Close() + if resp.StatusCode >= 400 { + issues = append(issues, LintIssue{Page: page, Level: "warning", Check: "images", Message: fmt.Sprintf("broken image: %s -> %d", imgURL, resp.StatusCode)}) + } + } + return issues +} + +func runLint(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + + checkFilter := ctx.Arg("check") + fmt.Fprintf(os.Stderr, "Linting wiki pages for %s/%s...\n\n", ctx.Owner, ctx.Repo) + + projectID, err := resolveProjectID(ctx) + if err != nil { + return err + } + + // Fetch page list + q := url.Values{} + q.Set("owner", ctx.Owner) + q.Set("repo", ctx.Repo) + q.Set("projectId", projectID) + env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("wikiPages"), q) + if err != nil { + return fmt.Errorf("failed to list wiki pages: %w", err) + } + + // Parse pages: title for display/link-check, sub_url for fetching + type pageInfo struct { + title string + subURL string + } + knownTitles := make(map[string]bool) + var pages []pageInfo + if items, ok := env.Data.([]interface{}); ok { + for _, item := range items { + m, ok := item.(map[string]interface{}) + if !ok { + continue + } + title, _ := m["title"].(string) + subURL, _ := m["sub_url"].(string) + if title != "" { + knownTitles[title] = true + if subURL == "" { + subURL = title + } + pages = append(pages, pageInfo{title: title, subURL: subURL}) + } + } + } + + if len(pages) == 0 { + fmt.Fprintln(os.Stderr, "No wiki pages found.") + return ctx.OutputData(LintSummary{ + Repository: ctx.Owner + "/" + ctx.Repo, + Results: []LintIssue{}, + }) + } + + // HTTP client for image checks with 5s timeout + httpClient := &http.Client{Timeout: 5 * time.Second} + + var allIssues []LintIssue + + for _, p := range pages { + // Skip system pages (e.g. _Sidebar, _Footer, _Header) + if strings.HasPrefix(p.title, "_") { + continue + } + content, _, err := fetchPageContent(ctx, projectID, p.subURL) + if err != nil { + allIssues = append(allIssues, LintIssue{Page: p.title, Level: "error", Check: "fetch", Message: fmt.Sprintf("failed to fetch: %v", err)}) + continue + } + + if isCheckEnabled(checkFilter, "empty") { + allIssues = append(allIssues, checkEmpty(p.title, content)...) + } + if isCheckEnabled(checkFilter, "headings") { + allIssues = append(allIssues, checkHeading(p.title, content)...) + } + if isCheckEnabled(checkFilter, "short") { + allIssues = append(allIssues, checkShort(p.title, content)...) + } + if isCheckEnabled(checkFilter, "links") { + allIssues = append(allIssues, checkDeadLinks(p.title, content, knownTitles)...) + } + if isCheckEnabled(checkFilter, "images") { + allIssues = append(allIssues, checkImages(p.title, content, httpClient)...) + } + } + + // Count errors/warnings + var errCount, warnCount int + for _, issue := range allIssues { + if issue.Level == "error" { + errCount++ + } else { + warnCount++ + } + } + + // Print issues to stderr + for _, issue := range allIssues { + if issue.Level == "error" { + fmt.Fprintf(os.Stderr, " ✗ %s - %s\n", issue.Page, issue.Message) + } else { + fmt.Fprintf(os.Stderr, " ⚠ %s - %s\n", issue.Page, issue.Message) + } + } + fmt.Fprintf(os.Stderr, "\nSummary: %d pages, %d errors, %d warnings\n", len(pages), errCount, warnCount) + + return ctx.OutputData(LintSummary{ + Repository: ctx.Owner + "/" + ctx.Repo, + TotalPages: len(pages), + TotalIssues: len(allIssues), + Errors: errCount, + Warnings: warnCount, + Results: allIssues, + }) +} + +func Shortcuts() []*common.Shortcut { + return []*common.Shortcut{ + { + Name: "list", + Description: "List all wiki pages", + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + projectID, err := resolveProjectID(ctx) + if err != nil { + return err + } + q := url.Values{} + q.Set("owner", ctx.Owner) + q.Set("repo", ctx.Repo) + q.Set("projectId", projectID) + env, err := callWikiAPIWithQuery(ctx, "GET", wikiPath("wikiPages"), q) + if err != nil { + // wiki +list 的 404 几乎总是意味着仓库未在 GitLink 网页端开启 Wiki 功能。 + // 这种情况下不要再提示"用 +list 查看"(循环引用),改为引导用户去开启 Wiki。 + var cliErr *clierrors.CLIError + if errors.As(err, &cliErr) && cliErr.Kind == clierrors.KindNotFound { + return clierrors.New(clierrors.KindNotFound, + fmt.Sprintf("仓库 %s/%s 没有 Wiki 页面", ctx.Owner, ctx.Repo), + fmt.Sprintf("请前往 GitLink 网页端 → 仓库 %s/%s → 设置 → 开启 Wiki 功能,开启后再创建页面", ctx.Owner, ctx.Repo)) + } + return fmt.Errorf("获取 Wiki 页面列表失败: %w", err) + } + cleanWikiList(env) + return ctx.Output(env) + }, + }, + { + Name: "view", + Description: "View a wiki page", + Flags: []common.Flag{ + {Name: "title", Short: "t", Usage: "Page title", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + title, err := ctx.RequireArg("title", `--title "Home Page"`) + if err != nil { + return err + } + projectID, err := resolveProjectID(ctx) + if err != nil { + return err + } + env, err := fetchWikiPage(ctx, projectID, title) + if err != nil { + // GitLink 后端命名规则: 创建 wiki 时会自动给 sub_url 追加 ".-" 后缀, + // 而 wiki +list 返回的 title 不带后缀。若 title 不带后缀且首次查询失败, + // 自动用 title + ".-" 重试一次。 + if !strings.HasSuffix(title, ".-") { + env, err = fetchWikiPage(ctx, projectID, title+".-") + } + } + if err != nil { + // 用 CLIError 包装,保留底层错误类型,让 TryPrintError 能按 envelope 输出。 + // suggestion 同时涵盖两种常见根因:(1) 仓库未开启 Wiki;(2) 页面名拼错。 + return clierrors.Wrap(clierrors.KindNotFound, + fmt.Sprintf("Wiki 页面 %q 不存在", title), + fmt.Sprintf("请确认:(1) 仓库 %s/%s 已在 GitLink 网页端开启 Wiki 功能;(2) 页面名拼写正确。可用 `gitlink-cli wiki +list --owner %s --repo %s` 查看实际存在的页面", + ctx.Owner, ctx.Repo, ctx.Owner, ctx.Repo), + err) + } + return outputWithDecodedContent(ctx, env) + }, + }, + { + Name: "create", + Description: "Create a wiki page", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + title := ctx.Arg("title") + return fmt.Sprintf("Create wiki page: %s", title), nil + }, + Flags: []common.Flag{ + {Name: "title", Short: "t", Usage: "Page title", Required: true}, + {Name: "content", Short: "c", Usage: "Wiki page content (plain text, will be base64-encoded)"}, + {Name: "file", Short: "f", Usage: "Read content from file"}, + {Name: "message", Short: "m", Usage: "Commit message"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + title, err := ctx.RequireArg("title", `--title "Home Page"`) + if err != nil { + return err + } + projectID, err := resolveProjectID(ctx) + if err != nil { + return err + } + + content, err := resolveContent(ctx) + if err != nil { + return err + } + + body := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": parseProjectIDInt(projectID), + "pageName": title, + "title": title, + "content_base64": base64.StdEncoding.EncodeToString([]byte(content)), + } + if msg := ctx.Arg("message"); msg != "" { + body["message"] = msg + } + + env, err := callWikiAPI(ctx, "POST", wikiPath("createWiki"), body) + if err != nil { + return fmt.Errorf("创建 Wiki 页面失败: %w", err) + } + return outputWithDecodedContent(ctx, env) + }, + }, + { + Name: "update", + Description: "Update a wiki page", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + title := ctx.Arg("title") + return fmt.Sprintf("Update wiki page: %s", title), nil + }, + Flags: []common.Flag{ + {Name: "page", Short: "p", Usage: "Current page title to find (defaults to --title)"}, + {Name: "title", Short: "t", Usage: "New page title", Required: true}, + {Name: "cover", Short: "c", Usage: "Replace entire page content with this text"}, + {Name: "add", Short: "a", Usage: "Append text to existing page content"}, + {Name: "file", Short: "f", Usage: "Read content from file (used with --cover or --add)"}, + {Name: "message", Short: "m", Usage: "Commit message"}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + title, err := ctx.RequireArg("title", `--title "Home Page"`) + if err != nil { + return err + } + pageName := ctx.Arg("page") + if pageName == "" { + pageName = title + } + projectID, err := resolveProjectID(ctx) + if err != nil { + return err + } + + coverText := ctx.Arg("cover") + addText := ctx.Arg("add") + filePath := ctx.Arg("file") + message := ctx.Arg("message") + + var finalContent string + if coverText != "" || filePath != "" && coverText == "" && addText == "" { + // --cover or --file alone: overwrite + content, err := resolveUpdateContent(ctx, coverText, filePath) + if err != nil { + return err + } + finalContent = content + } else if addText != "" { + // --add: append to existing content + newPart, err := resolveUpdateContent(ctx, addText, filePath) + if err != nil { + return err + } + existing, actualPageName, err := fetchPageContent(ctx, projectID, pageName) + if err != nil { + return fmt.Errorf("failed to fetch existing page content for append: %w", err) + } + // fetchPageContent 可能因 GitLink 后端 ".-" 命名规则触发自动重试, + // 用实际成功的 pageName(可能带 .- 后缀)作为 PUT 目标,否则后端会再次 404。 + pageName = actualPageName + finalContent = existing + newPart + } + + body := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": parseProjectIDInt(projectID), + "pageName": url.QueryEscape(pageName), + "title": title, + "message": message, + } + if finalContent != "" { + body["content_base64"] = base64.StdEncoding.EncodeToString([]byte(finalContent)) + } + + env, err := callWikiAPI(ctx, "PUT", wikiPath("updateWiki"), body) + if err != nil { + return fmt.Errorf("更新 Wiki 页面失败: %w", err) + } + return outputWithDecodedContent(ctx, env) + }, + }, + { + Name: "delete", + Description: "Delete a wiki page", + DryRun: true, + DryRunHint: func(ctx *common.RuntimeContext) (string, error) { + title := ctx.Arg("title") + return fmt.Sprintf("Delete wiki page: %s", title), nil + }, + Flags: []common.Flag{ + {Name: "title", Short: "t", Usage: "Page title", Required: true}, + }, + Run: func(ctx *common.RuntimeContext) error { + if err := ctx.ResolveOwnerRepo(); err != nil { + return err + } + title, err := ctx.RequireArg("title", `--title "Home Page"`) + if err != nil { + return err + } + projectID, err := resolveProjectID(ctx) + if err != nil { + return err + } + + // 1. 解析实际 pageName(fetchPageContent 内部自动重试 ".-" 后缀) + // 不做这步直接用 title DELETE,会导致后端"接受了请求但没真删" + // (后端期望的 pageName 是 "X.-" 而非 "X") + _, actualPageName, err := fetchPageContent(ctx, projectID, title) + if err != nil { + // 页面查不到 — 后端 getWiki API 返回 404。 + // suggestion 同时涵盖两种常见根因:(1) 仓库未开启 Wiki;(2) 页面名拼错。 + // 注意:GitLink 网页端对任意 ?wiki=xxx 都会渲染 SPA 壳子, + // 不代表页面真实存在;以 wiki +list 的结果为准。 + return clierrors.New(clierrors.KindNotFound, + fmt.Sprintf("Wiki 页面 %q 不存在", title), + fmt.Sprintf("请确认:(1) 仓库 %s/%s 已在 GitLink 网页端开启 Wiki 功能;(2) 页面名拼写正确。可用 `gitlink-cli wiki +list --owner %s --repo %s` 查看实际存在的页面", + ctx.Owner, ctx.Repo, ctx.Owner, ctx.Repo)) + } + + // 2. 用实际 pageName 调用 DELETE + body := map[string]interface{}{ + "owner": ctx.Owner, + "repo": ctx.Repo, + "projectId": parseProjectIDInt(projectID), + "pageName": actualPageName, + "message": "", + } + if _, delErr := callWikiAPI(ctx, "DELETE", wikiPath("deleteWiki"), body); delErr != nil { + return fmt.Errorf("删除 Wiki 页面失败: %w", delErr) + } + + // 3. 删除后强制验证(GitLink deleteWiki 端点不可靠:即使返回 200, + // 页面有时仍然存在)。GET 一次确认页面真的没了。 + q := url.Values{} + q.Set("owner", ctx.Owner) + q.Set("repo", ctx.Repo) + q.Set("projectId", projectID) + q.Set("pageName", actualPageName) + if _, viewErr := callWikiAPIWithQuery(ctx, "GET", wikiPath("getWiki"), q); viewErr == nil { + // GET 还能查到 — 后端接受了 DELETE 但没真删 + return fmt.Errorf("删除 Wiki 页面失败:后端接受了请求但页面仍存在 (pageName=%s)", actualPageName) + } + // 删除成功 — 返回结构化数据,让用户/agent 能自助验证。 + // 说明三种用户常见的"以为没删干净"的现象: + // 1) 网页端 ?wiki=xxx 仍可访问 → GitLink SPA 占位符(任何参数都渲染壳子) + // 2) git log 仍能看到删除 commit → git 设计就是保留历史 + // 3) edit 跳转到 wiki=undefined → 网页端前端未正确处理 404 + // 这些都不是 CLI 删除不彻底,是 GitLink 网页端的 UX 问题。 + return ctx.OutputData(map[string]interface{}{ + "message": "Wiki page deleted successfully", + "deleted_title": title, + "actual_page_name": actualPageName, + "wiki_repo": fmt.Sprintf("https://gitlink.org.cn/%s/%s.wiki.git", ctx.Owner, ctx.Repo), + "notes": []string{ + "页面已从 wiki 仓库 HEAD 彻底删除(git 工作树无残留)", + "网页端 ?wiki=xxx URL 仍可访问是 SPA 占位符,不代表页面存在", + "git 历史 commits 仍保留删除记录(git 的正常行为,非残留)", + }, + "verify_commands": []string{ + fmt.Sprintf("gitlink-cli wiki +list --owner %s --repo %s", ctx.Owner, ctx.Repo), + fmt.Sprintf("git clone https://gitlink.org.cn/%s/%s.wiki.git /tmp/wiki-check && git -C /tmp/wiki-check ls-files", ctx.Owner, ctx.Repo), + }, + }) + }, + }, + { + Name: "lint", + Description: "Check wiki pages for quality issues", + Flags: []common.Flag{ + {Name: "check", Usage: "Specific checks to run (comma-separated): links,headings,images,empty. Default: all"}, + }, + Run: runLint, + }, + } +} diff --git a/shortcuts/wiki/wiki_test.go b/shortcuts/wiki/wiki_test.go new file mode 100644 index 0000000..467144b --- /dev/null +++ b/shortcuts/wiki/wiki_test.go @@ -0,0 +1,663 @@ +package wiki + +import ( + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/gitlink-org/gitlink-cli/internal/client" + clierrors "github.com/gitlink-org/gitlink-cli/internal/errors" + "github.com/gitlink-org/gitlink-cli/internal/output" + "github.com/gitlink-org/gitlink-cli/shortcuts/common" +) + +func resetProjectIDCache() { + projectIDCache = sync.Map{} +} + +func newMockServer(t *testing.T, handler http.HandlerFunc) *httptest.Server { + t.Helper() + return httptest.NewServer(handler) +} + +func writeJSON(t *testing.T, w http.ResponseWriter, v interface{}) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(v); err != nil { + t.Fatalf("writeJSON: %v", err) + } +} + +// ---- unwrapGatewayResponse tests ---- + +func TestUnwrapGatewayResponse_Success(t *testing.T) { + env := output.SuccessEnvelope(map[string]interface{}{ + "code": float64(200), + "data": map[string]interface{}{"id": float64(1)}, + }, nil) + + result, err := unwrapGatewayResponse(env) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + data, ok := result.Data.(map[string]interface{}) + if !ok { + t.Fatal("expected map data") + } + if data["id"] != float64(1) { + t.Fatalf("got %v, want 1", data["id"]) + } +} + +func TestUnwrapGatewayResponse_Code201(t *testing.T) { + env := output.SuccessEnvelope(map[string]interface{}{ + "code": float64(201), + "data": "ok", + }, nil) + + result, err := unwrapGatewayResponse(env) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Data != "ok" { + t.Fatalf("got %v, want ok", result.Data) + } +} + +func TestUnwrapGatewayResponse_BusinessError(t *testing.T) { + env := output.SuccessEnvelope(map[string]interface{}{ + "code": float64(500), + "msg": "内部错误", + }, nil) + + _, err := unwrapGatewayResponse(env) + if err == nil { + t.Fatal("expected error, got nil") + } + // 必须是 *clierrors.CLIError,否则 TryPrintError 无法按 envelope 输出 + var cliErr *clierrors.CLIError + if !errors.As(err, &cliErr) { + t.Fatalf("expected *clierrors.CLIError, got %T: %v", err, err) + } + if cliErr.Kind != clierrors.KindServer { + t.Errorf("Kind = %q, want %q", cliErr.Kind, clierrors.KindServer) + } + if cliErr.Message != "内部错误" { + t.Errorf("Message = %q, want %q", cliErr.Message, "内部错误") + } +} + +func TestUnwrapGatewayResponse_NoDataField(t *testing.T) { + env := output.SuccessEnvelope(map[string]interface{}{ + "code": float64(200), + }, nil) + + result, err := unwrapGatewayResponse(env) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should return original envelope since no "data" field to extract + if result != env { + t.Fatal("expected original envelope when no data field") + } +} + +func TestUnwrapGatewayResponse_NonMapData(t *testing.T) { + env := output.SuccessEnvelope("plain text", nil) + + result, err := unwrapGatewayResponse(env) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result.Data != "plain text" { + t.Fatalf("got %v, want plain text", result.Data) + } +} + +// ---- resolveProjectID tests ---- + +func TestResolveProjectID_Success(t *testing.T) { + resetProjectIDCache() + server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/owner1/repo1/detail.json" { + writeJSON(t, w, map[string]interface{}{"project_id": float64(123)}) + return + } + 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: "owner1", + Repo: "repo1", + } + pid, err := resolveProjectID(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pid != "123" { + t.Fatalf("got %q, want %q", pid, "123") + } +} + +func TestResolveProjectID_Float64(t *testing.T) { + resetProjectIDCache() + server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/owner2/repo2/detail.json" { + writeJSON(t, w, map[string]interface{}{"project_id": float64(456)}) + return + } + 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: "owner2", + Repo: "repo2", + } + pid, err := resolveProjectID(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if pid != "456" { + t.Fatalf("got %q, want %q", pid, "456") + } +} + +func TestResolveProjectID_CacheHit(t *testing.T) { + resetProjectIDCache() + callCount := 0 + server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + callCount++ + if r.URL.Path == "/owner3/repo3/detail.json" { + writeJSON(t, w, map[string]interface{}{"project_id": float64(789)}) + return + } + 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: "owner3", + Repo: "repo3", + } + + pid1, err := resolveProjectID(ctx) + if err != nil { + t.Fatalf("first call: %v", err) + } + if pid1 != "789" { + t.Fatalf("first call: got %q, want %q", pid1, "789") + } + + pid2, err := resolveProjectID(ctx) + if err != nil { + t.Fatalf("second call: %v", err) + } + if pid2 != "789" { + t.Fatalf("second call: got %q, want %q", pid2, "789") + } + + if callCount != 1 { + t.Fatalf("API called %d times, want 1 (cache miss)", callCount) + } +} + +func TestResolveProjectID_MissingProjectID(t *testing.T) { + resetProjectIDCache() + server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, map[string]interface{}{"name": "no-project-id"}) + }) + defer server.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner4", + Repo: "repo4", + } + _, err := resolveProjectID(ctx) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +func TestResolveProjectID_APIError(t *testing.T) { + resetProjectIDCache() + server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + }) + defer server.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + Owner: "owner5", + Repo: "repo5", + } + _, err := resolveProjectID(ctx) + if err == nil { + t.Fatal("expected error, got nil") + } +} + +// ---- callWikiAPI HTTP request path tests ---- + +func TestCallWikiAPI_Success(t *testing.T) { + resetProjectIDCache() + var receivedPath, receivedMethod string + var receivedBody []byte + server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + receivedPath = r.URL.Path + receivedMethod = r.Method + if r.Body != nil { + buf := make([]byte, 1024) + n, _ := r.Body.Read(buf) + receivedBody = buf[:n] + } + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"code":200,"msg":"ok","data":{"id":42}}`)) + }) + defer server.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + GatewayBaseURL: server.URL, + GatewayHTTPClient: server.Client(), + } + + env, err := callWikiAPI(ctx, "POST", "/wiki/open/test", map[string]string{"foo": "bar"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if receivedMethod != "POST" { + t.Errorf("method = %q, want POST", receivedMethod) + } + if receivedPath != "/wiki/open/test" { + t.Errorf("path = %q, want /wiki/open/test", receivedPath) + } + if !strings.Contains(string(receivedBody), `"foo"`) { + t.Errorf("body should contain foo: %s", string(receivedBody)) + } + data, ok := env.Data.(map[string]interface{}) + if !ok { + t.Fatalf("expected map data, got %T", env.Data) + } + if data["id"] != float64(42) { + t.Errorf("data[id] = %v, want 42", data["id"]) + } +} + +func TestCallWikiAPI_BusinessError(t *testing.T) { + resetProjectIDCache() + server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"code":400,"msg":"bad request","data":null}`)) + }) + defer server.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + GatewayBaseURL: server.URL, + GatewayHTTPClient: server.Client(), + } + _, err := callWikiAPI(ctx, "GET", "/test", nil) + if err == nil { + t.Fatal("expected error, got nil") + } + // 必须是 *clierrors.CLIError,code=400 对应 KindForbidden(401/403)或 KindServer + // (其他非 200/404 错误),这里 400 走 KindServer 分支 + var cliErr *clierrors.CLIError + if !errors.As(err, &cliErr) { + t.Fatalf("expected *clierrors.CLIError, got %T: %v", err, err) + } + if cliErr.Message != "bad request" { + t.Errorf("Message = %q, want %q", cliErr.Message, "bad request") + } + if cliErr.Suggestion == "" { + t.Errorf("Suggestion should not be empty (helps user recover)") + } +} + +func TestCallWikiAPI_GatewayHTTPError(t *testing.T) { + resetProjectIDCache() + server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusBadGateway) + w.Write([]byte(`upstream error`)) + }) + defer server.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + GatewayBaseURL: server.URL, + GatewayHTTPClient: server.Client(), + } + _, err := callWikiAPI(ctx, "GET", "/test", nil) + if err == nil { + t.Fatal("expected error on 502") + } +} + +func TestCallWikiAPI_SkipsJSONSuffix(t *testing.T) { + // Verifies the SkipJSONSuffix path is correctly taken for gateway: + // the URL should NOT have a .json appended. + resetProjectIDCache() + var receivedPath string + server := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + receivedPath = r.URL.Path + w.Write([]byte(`{"code":200,"data":{}}`)) + }) + defer server.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: server.Client(), BaseURL: server.URL}, + GatewayBaseURL: server.URL, + GatewayHTTPClient: server.Client(), + } + _, err := callWikiAPI(ctx, "GET", "/wiki/open/list", nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if receivedPath != "/wiki/open/list" { + t.Errorf("path = %q, want /wiki/open/list (no .json suffix)", receivedPath) + } + if strings.HasSuffix(receivedPath, ".json") { + t.Errorf("path %q should NOT have .json suffix (gateway expects no suffix)", receivedPath) + } +} + +func TestCallWikiAPI_ConnectionRefused(t *testing.T) { + resetProjectIDCache() + // Use an unbound port to simulate connection failure + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: &http.Client{}, BaseURL: "http://127.0.0.1:1"}, + GatewayBaseURL: "http://127.0.0.1:1", + GatewayHTTPClient: &http.Client{}, + } + _, err := callWikiAPI(ctx, "GET", "/test", nil) + if err == nil { + t.Fatal("expected connection error") + } +} + +// ---- runLint / check rules tests ---- + +func TestIsCheckEnabled_Empty(t *testing.T) { + if !isCheckEnabled("", "any") { + t.Error("empty filter should enable all checks") + } + if !isCheckEnabled("empty,headings", "empty") { + t.Error("should enable 'empty' in filter list") + } + if isCheckEnabled("headings", "empty") { + t.Error("should not enable 'empty' when not in filter list") + } + if !isCheckEnabled(" empty , headings ", "empty") { + t.Error("should trim whitespace") + } +} + +func TestCheckEmpty(t *testing.T) { + issues := checkEmpty("p1", "") + if len(issues) != 1 || issues[0].Level != "error" || issues[0].Check != "empty" { + t.Errorf("expected 1 error-level 'empty' issue, got %+v", issues) + } + if issues := checkEmpty("p1", "some content"); issues != nil { + t.Errorf("non-empty content should not produce issues, got %+v", issues) + } + if issues := checkEmpty("p1", " \n\t "); len(issues) != 1 { + t.Errorf("whitespace-only content should be empty, got %+v", issues) + } +} + +func TestCheckHeading(t *testing.T) { + // missing H1 + if issues := checkHeading("p1", "Some text without heading"); len(issues) != 1 { + t.Errorf("expected 1 missing-heading issue, got %+v", issues) + } + // has H1 + if issues := checkHeading("p1", "# Title\nbody"); issues != nil { + t.Errorf("H1 should not produce issues, got %+v", issues) + } + // empty content skipped + if issues := checkHeading("p1", ""); issues != nil { + t.Errorf("empty content should be skipped, got %+v", issues) + } + // whitespace prefix + if issues := checkHeading("p1", " \n# Real Title"); issues != nil { + t.Errorf("H1 after whitespace should not produce issues, got %+v", issues) + } +} + +func TestCheckShort(t *testing.T) { + if issues := checkShort("p1", ""); issues != nil { + t.Errorf("empty content should be skipped, got %+v", issues) + } + if issues := checkShort("p1", "short"); len(issues) != 1 { + t.Errorf("expected 1 short issue, got %+v", issues) + } + long := strings.Repeat("a", 100) + if issues := checkShort("p1", long); issues != nil { + t.Errorf("long content should not produce issues, got %+v", issues) + } + // exactly 49 chars triggers + if issues := checkShort("p1", strings.Repeat("a", 49)); len(issues) != 1 { + t.Errorf("49-char content should be 'short', got %+v", issues) + } +} + +func TestCheckDeadLinks(t *testing.T) { + known := map[string]bool{"Home": true, "Guide": true} + + // All known: no issues + if issues := checkDeadLinks("p1", "[Home](Home) and [Guide](Guide)", known); issues != nil { + t.Errorf("all-known should not produce issues, got %+v", issues) + } + // Unknown link + issues := checkDeadLinks("p1", "[Unknown](Unknown)", known) + if len(issues) != 1 || issues[0].Check != "links" { + t.Errorf("expected 1 dead link issue, got %+v", issues) + } + // External links skipped + if issues := checkDeadLinks("p1", "[ext](https://example.com)", known); issues != nil { + t.Errorf("external links should be skipped, got %+v", issues) + } + // Anchor links skipped + if issues := checkDeadLinks("p1", "[anchor](#section)", known); issues != nil { + t.Errorf("anchor links should be skipped, got %+v", issues) + } + // Mixed + issues = checkDeadLinks("p1", "[Home](Home) and [Bad](BadPage)", known) + if len(issues) != 1 { + t.Errorf("expected 1 dead link in mixed, got %+v", issues) + } + // Empty content + if issues := checkDeadLinks("p1", "", known); issues != nil { + t.Errorf("empty content should not produce issues, got %+v", issues) + } +} + +func TestCheckImages(t *testing.T) { + // Mock image server + imgServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == "HEAD" { + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusOK) + }) + defer imgServer.Close() + + brokenServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotFound) + }) + defer brokenServer.Close() + + httpClient := imgServer.Client() + // Valid image (200) + if issues := checkImages("p1", "![ok]("+imgServer.URL+"/img.png)", httpClient); issues != nil { + t.Errorf("200 image should not produce issues, got %+v", issues) + } + // Broken image (404) + issues := checkImages("p1", "![bad]("+brokenServer.URL+"/missing.png)", httpClient) + if len(issues) != 1 { + t.Errorf("expected 1 broken image issue, got %+v", issues) + } + // No images + if issues := checkImages("p1", "no images here", httpClient); issues != nil { + t.Errorf("no images should not produce issues, got %+v", issues) + } + // Malformed HTTP URL (regex matches https?:// but http.NewRequest fails to parse) + if issues := checkImages("p1", "![bad](http://[)", httpClient); len(issues) != 1 { + t.Errorf("expected 1 invalid-URL issue, got %+v", issues) + } +} + +func TestRunLint_Integration(t *testing.T) { + resetProjectIDCache() + + // Mock main API (project detail) and gateway (wiki list + get) + mainServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/detail.json") { + writeJSON(t, w, map[string]interface{}{"project_id": float64(999)}) + return + } + t.Errorf("unexpected main API call: %s %s", r.Method, r.URL.Path) + }) + defer mainServer.Close() + + // Page content (base64 encoded) + goodContent := base64.StdEncoding.EncodeToString([]byte("# Good Page\n\n" + strings.Repeat("This is a well-formed page with enough content to pass the short check. ", 3))) + + wikiServer := newMockServer(t, func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/wiki/open/wikiPages": + writeJSON(t, w, map[string]interface{}{ + "code": 200, + "data": []map[string]interface{}{ + {"title": "Good", "sub_url": "Good"}, + {"title": "Empty", "sub_url": "Empty"}, + {"title": "_Sidebar", "sub_url": "_Sidebar"}, // system page - skipped + }, + }) + case "/wiki/open/getWiki": + pageName := r.URL.Query().Get("pageName") + var content string + if pageName == "Empty" { + content = "" // empty page + } else { + content = goodContent + } + writeJSON(t, w, map[string]interface{}{ + "code": 200, + "data": map[string]interface{}{"content_base64": content}, + }) + default: + t.Errorf("unexpected wiki path: %s", r.URL.Path) + } + }) + defer wikiServer.Close() + + ctx := &common.RuntimeContext{ + Client: &client.Client{HTTP: mainServer.Client(), BaseURL: mainServer.URL}, + Owner: "owner1", + Repo: "repo1", + Format: "json", + GatewayBaseURL: wikiServer.URL, + GatewayHTTPClient: wikiServer.Client(), + } + + if err := runLint(ctx); err != nil { + t.Fatalf("runLint: %v", err) + } + // _Sidebar is skipped, so TotalPages=2 (Good, Empty) + // Empty page produces 1 "empty" error + // We can't directly inspect the output envelope, but if no error, the function ran end-to-end +} + +// ---- resolveContent tests ---- + +func TestResolveContent_FromArg(t *testing.T) { + ctx := &common.RuntimeContext{ + Args: map[string]string{"content": "inline content"}, + } + got, err := resolveContent(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "inline content" { + t.Errorf("got %q, want %q", got, "inline content") + } +} + +func TestResolveContent_FromFile(t *testing.T) { + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "wiki.md") + want := "# Title\n\nBody content from file" + if err := os.WriteFile(path, []byte(want), 0600); err != nil { + t.Fatalf("setup: %v", err) + } + ctx := &common.RuntimeContext{ + Args: map[string]string{"file": path}, + } + got, err := resolveContent(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != want { + t.Errorf("got %q, want %q", got, want) + } +} + +func TestResolveContent_ArgTakesPrecedence(t *testing.T) { + // When both --content and --file are set, --content wins + tmpDir := t.TempDir() + path := filepath.Join(tmpDir, "wiki.md") + if err := os.WriteFile(path, []byte("from file"), 0600); err != nil { + t.Fatalf("setup: %v", err) + } + ctx := &common.RuntimeContext{ + Args: map[string]string{ + "content": "from arg", + "file": path, + }, + } + got, err := resolveContent(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "from arg" { + t.Errorf("arg should take precedence; got %q", got) + } +} + +func TestResolveContent_Missing(t *testing.T) { + ctx := &common.RuntimeContext{ + Args: map[string]string{}, + } + _, err := resolveContent(ctx) + if err == nil { + t.Fatal("expected error when neither --content nor --file is provided") + } + if !strings.Contains(err.Error(), "required") { + t.Errorf("err = %q, want to mention 'required'", err.Error()) + } +} + +func TestResolveContent_FileNotFound(t *testing.T) { + ctx := &common.RuntimeContext{ + Args: map[string]string{"file": "/nonexistent/path/to/wiki.md"}, + } + _, err := resolveContent(ctx) + if err == nil { + t.Fatal("expected error for nonexistent file") + } +} diff --git a/skills/README.md b/skills/README.md index 2786e1d..a598880 100644 --- a/skills/README.md +++ b/skills/README.md @@ -92,6 +92,22 @@ skills/ │ ├── REFERENCE.md # Release API 参考 │ └── examples/ │ └── release-workflow.md # Release 工作流 +├── gitlink-changelog/ # Release Notes / Changelog 生成 +│ ├── SKILL.md # Changelog 操作指南 +│ ├── references/ +│ │ ├── collect-data.md # 收集变更数据 +│ │ ├── classify-rules.md # 变更分类规则 +│ │ └── generate-and-publish.md # 生成并发布 +│ └── examples/ +│ └── full-workflow.md # 完整生成示例 +├── gitlink-health/ # 项目健康度报告 +│ ├── SKILL.md # 健康度报告操作指南 +│ ├── references/ +│ │ ├── collect-data.md # 收集项目数据 +│ │ ├── health-metrics.md # 指标计算和评分规则 +│ │ └── generate-report.md # 报告生成和输出 +│ └── examples/ +│ └── full-workflow.md # 完整生成示例 ├── gitlink-search/ # 搜索功能 │ ├── SKILL.md # 搜索操作指南 │ └── examples/ @@ -106,10 +122,29 @@ skills/ │ ├── SKILL.md # CI 操作指南 │ └── examples/ │ └── ci-workflow.md # CI 工作流 +├── gitlink-wiki/ # Wiki 管理 +│ └── SKILL.md # Wiki 操作指南 ├── gitlink-pm/ # 项目管理 │ └── SKILL.md # PM 操作指南 -└── gitlink-workflow/ # AI 自动化工作流 - └── SKILL.md # 工作流模板(Issue 分类、PR Review、Release Notes) +├── gitlink-workflow/ # AI 自动化工作流 +│ └── SKILL.md # 工作流模板(Issue 分类、PR Review、Release Notes) +├── gitlink-issue-triage/ # Issue 自动分类 +│ ├── SKILL.md # AI Agent 主入口 +│ ├── README.md # 使用说明 +│ ├── references/ # 分析算法 + 应用手册 +│ └── examples/ # 批量/单 Issue 工作流示例 +├── gitlink-webhook/ # Webhook 管理 +│ └── SKILL.md # Webhook 操作指南 +├── gitlink-compliance/ # 安全与合规 +│ └── SKILL.md # 许可证、敏感信息、PII 扫描 +├── gitlink-onboard/ # 新人引导 +│ └── SKILL.md # Good First Issue 识别与欢迎评论 +├── gitlink-team/ # 团队管理 +│ └── SKILL.md # 团队操作指南 +├── gitlink-contrib/ # 贡献报告 +│ └── SKILL.md # 贡献统计与报告 +└── gitlink-code-insight/ # 功能全景 + └── SKILL.md # 全部 Shortcuts 分类展示 ``` --- @@ -126,6 +161,7 @@ skills/ | **gitlink-pr** | Pull Request | `pr +list`, `pr +create`, `pr +view`, `pr +merge`, `pr +review` | | **gitlink-branch** | 分支管理 | `branch +list`, `branch +create`, `branch +delete`, `branch +protect` | | **gitlink-release** | 版本发布 | `release +list`, `release +create`, `release +view` | +| **gitlink-health** | 项目健康度报告 | Issue 响应时间、PR 合并效率、贡献者活跃度统计 | ### 辅助 Skills @@ -135,8 +171,17 @@ skills/ | **gitlink-user** | 用户管理 | `user +me`, `user +info` | | **gitlink-org** | 组织管理 | `org +list`, `org +info`, `org +members` | | **gitlink-ci** | CI/CD | `ci +builds`, `ci +logs` | +| **gitlink-wiki** | Wiki 管理 | `wiki +list`, `wiki +view`, `wiki +create`, `wiki +update`, `wiki +delete` | | **gitlink-pm** | 项目管理 | 通过 Raw API 访问 | -| **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、Release Notes | +| **gitlink-changelog** | Release Notes / Changelog 生成 | 自动收集 commits/PR/Issue,生成结构化版本说明 | +| **gitlink-issue-triage** | Issue 自动分类 | 自动判定 tracker/priority/labels,关联 Issue,生成审计报告 | +| **gitlink-workflow** | AI 工作流 | Issue 分类、PR Review、仓库初始化、Sprint 报告 | +| **gitlink-webhook** | Webhook 管理 | `webhook +list`, `webhook +create`, `webhook +test` | +| **gitlink-compliance** | 安全与合规 | `compliance +scan`, `compliance +secrets`, `compliance +license` | +| **gitlink-onboard** | 新人引导 | `onboard +welcome` | +| **gitlink-team** | 团队管理 | `team +list`, `team +create`, `team +add-member` | +| **gitlink-contrib** | 贡献报告 | `contrib +report` | +| **gitlink-code-insight** | 功能全景 | 全部 Shortcuts 分类索引,含说明和示例 | --- @@ -210,6 +255,27 @@ gitlink-cli org +info -i Gitlink 详见: [gitlink-search/examples/search-workflow.md](gitlink-search/examples/search-workflow.md) +### 场景 5:管理 Wiki 文档 + +```bash +# 列出 Wiki 页面 +gitlink-cli wiki +list --owner myuser --repo myrepo + +# 查看页面内容 +gitlink-cli wiki +view --owner myuser --repo myrepo --title "Home" + +# 创建页面(从文件) +gitlink-cli wiki +create --owner myuser --repo myrepo --title "API 文档" --file ./api.md + +# 追加内容到现有页面 +gitlink-cli wiki +update --owner myuser --repo myrepo --title "API 文档" --add "\n\n## 新增接口" + +# 预览删除(不实际执行) +gitlink-cli wiki +delete --owner myuser --repo myrepo --title "废弃页面" --dry-run +``` + +详见: [gitlink-wiki/SKILL.md](gitlink-wiki/SKILL.md) + --- ## 📚 文档导航 @@ -298,6 +364,7 @@ AI 代理可以: - ✅ 自动创建和管理 Issue - ✅ 自动创建和合并 PR - ✅ 自动发布 Release +- ✅ 自动管理 Wiki 文档 - ✅ 自动分类 Issue - ✅ 自动生成 Release Notes - ✅ 自动执行代码审查 diff --git a/skills/gitlink-branch/references/branch-create.md b/skills/gitlink-branch/references/branch-create.md new file mode 100644 index 0000000..dbf935f --- /dev/null +++ b/skills/gitlink-branch/references/branch-create.md @@ -0,0 +1,100 @@ +# branch +create + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +从现有分支或 commit 创建新分支。 + +## 命令 + +```bash +# 从 master 创建分支 +gitlink-cli branch +create --name feature/new-feature + +# 从指定分支创建 +gitlink-cli branch +create --name hotfix/bug-123 --from develop + +# 从指定 commit 创建 +gitlink-cli branch +create --name feature/x --from abc123def + +# 指定仓库创建分支 +gitlink-cli branch +create --name feature/x --owner someone --repo myrepo +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--name, -n` | **是** | 新分支名称 | +| `--from, -f` | 否 | 源分支或 commit(默认 `master`) | +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +POST /v1/{owner}/{repo}/branches +Body: { "new_branch_name": name, "old_branch_name": from } +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "name": "feature/new-feature", + "commit_id": "abc123...", + "commit_message": "Create feature branch", + "committed_time": "2026-01-01T00:00:00Z" + } +} +``` + +## Workflow + +1. **Confirm** the branch name and source branch with the user. +2. **Execute** `gitlink-cli branch +create --name --from `. +3. **Report** the created branch information. + +> [!CAUTION] +> This is a **Write Operation** — confirm user intent before executing. + +## Use Cases + +- **功能开发**:为新功能创建独立分支 +- **Bug 修复**:从稳定分支创建 hotfix 分支 +- **实验性功能**:创建实验分支进行尝试 +- **版本发布**:为发布版本创建分支 + +## Best Practices + +- **命名规范**:使用有意义的分支名,如 `feature/xxx`、`hotfix/xxx`、`release/xxx` +- **源分支选择**:通常从 `develop` 或 `master` 创建功能分支 +- **分支描述**:创建后可以添加描述说明分支用途 + +## Error Handling + +常见错误及解决方案: + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `404` | 仓库不存在 | 检查 `--owner` 和 `--repo` 是否正确 | +| `409` | 分支已存在 | 使用不同的分支名或删除现有分支 | +| `404` | 源分支不存在 | 确认 `--from` 指定的分支或 commit 存在 | + +## Tips + +- 默认从 `master` 分支创建,如需从其他分支创建需明确指定 +- 分支名支持 `/` 分隔符,便于组织分支结构 +- 创建后可以立即使用 `gitlink-cli branch +list` 验证 + +## References + +- [branch +list](branch-list.md) — 列出分支 +- [branch +delete](branch-delete.md) — 删除分支 +- [gitlink-branch](../SKILL.md) — 分支操作总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-branch/references/branch-delete.md b/skills/gitlink-branch/references/branch-delete.md new file mode 100644 index 0000000..46b3e84 --- /dev/null +++ b/skills/gitlink-branch/references/branch-delete.md @@ -0,0 +1,119 @@ +# branch +delete + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +删除指定的分支。**此操作不可逆,请谨慎使用。** + +## 命令 + +```bash +# 删除分支 +gitlink-cli branch +delete --name feature/old-feature + +# 指定仓库删除分支 +gitlink-cli branch +delete --name feature/old-feature --owner someone --repo myrepo + +# 删除带路径的分支 +gitlink-cli branch +delete --name feature/my-feature +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--name, -n` | **是** | 要删除的分支名称 | +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +POST /v1/{owner}/{repo}/branches/delete +Body: { "branch_name": name } +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "message": "Branch deleted successfully", + "branch_name": "feature/old-feature" + } +} +``` + +## Workflow + +1. **Confirm** the user really wants to delete this branch (emphasize this is **irreversible**). +2. **Check** if the branch exists using `branch +list` if needed. +3. **Execute** `gitlink-cli branch +delete --name `. +4. **Report** the deletion result. + +> [!CAUTION] +> This is a **Destructive Operation** — confirm user intent before executing. This action **cannot be undone**. + +## Use Cases + +- **清理已完成的功能分支**:功能合并后删除功能分支 +- **清理错误的分支**:删除创建错误或不再需要的分支 +- **维护分支整洁**:定期清理无用分支保持仓库整洁 + +## Warnings + +- ⚠️ **不可逆操作**:删除分支后无法恢复 +- ⚠️ **受保护分支**:无法删除受保护的分支 +- ⚠️ **默认分支**:无法删除默认分支(通常是 master) +- ⚠️ **未合并更改**:删除包含未合并更改的分支可能导致代码丢失 + +## Best Practices + +1. **确认合并状态**:删除前确认分支的更改已经合并 +2. **备份重要更改**:如果有重要更改未合并,先备份或合并 +3. **沟通确认**:团队协作时先沟通确认再删除 +4. **使用描述性名称**:避免删除错误的分支 + +## Error Handling + +常见错误及解决方案: + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `404` | 分支不存在 | 使用 `branch +list` 确认分支存在 | +| `403` | 权限不足 | 确认有删除分支的权限 | +| `400` | 受保护分支 | 无法删除受保护的分支 | +| `400` | 默认分支 | 无法删除默认分支 | + +## Safety Checks + +建议在删除前执行以下检查: + +```bash +# 1. 检查分支是否存在 +gitlink-cli branch +list | grep branch-name + +# 2. 确认不是保护分支 +gitlink-cli branch +list --format json | jq '.data.branches[] | select(.name=="branch-name") | .protected' + +# 3. 确认不是默认分支 +gitlink-cli api GET /{owner}/{repo} | jq '.data.default_branch' +``` + +## Tips + +- 删除前建议使用 `gitlink-cli branch +list` 确认分支名称 +- 对于重要分支,建议先检查是否有未合并的 PR +- 团队协作时,删除公共分支前先通知团队成员 + +## References + +- [branch +list](branch-list.md) — 列出分支 +- [branch +create](branch-create.md) — 创建分支 +- [branch +protect](branch-protect.md) — 保护分支 +- [gitlink-branch](../SKILL.md) — 分支操作总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-branch/references/branch-list.md b/skills/gitlink-branch/references/branch-list.md new file mode 100644 index 0000000..a9eb3db --- /dev/null +++ b/skills/gitlink-branch/references/branch-list.md @@ -0,0 +1,103 @@ +# branch +list + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +列出仓库的所有分支,支持分页查询。 + +## 命令 + +```bash +# 列出当前仓库的分支 +gitlink-cli branch +list + +# 指定仓库并分页 +gitlink-cli branch +list --owner Gitlink --repo forgeplus --page 1 --limit 10 + +# 输出为 JSON +gitlink-cli branch +list --format json + +# 输出为 YAML +gitlink-cli branch +list --format yaml +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--page, -p` | 否 | 页码(默认 `1`) | +| `--limit, -l` | 否 | 每页条数(默认 `20`) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +GET /v1/{owner}/{repo}/branches?page=1&limit=20 +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "branches": [ + { + "name": "master", + "commit_id": "abc123...", + "commit_message": "Initial commit", + "committed_time": "2026-01-01T00:00:00Z", + "is_default": true, + "protected": false + }, + { + "name": "develop", + "commit_id": "def456...", + "commit_message": "Develop branch", + "committed_time": "2026-01-02T00:00:00Z", + "is_default": false, + "protected": true + } + ], + "total_count": 15 + }, + "meta": { + "page": 1, + "limit": 20, + "total_count": 15 + } +} +``` + +## Workflow + +1. **Resolve** owner and repo (from git remote or flags). +2. **Execute** `gitlink-cli branch +list`. +3. **Display** branches in the requested format. + +> [!NOTE] +> This is a **Read Operation** — no confirmation needed. + +## Use Cases + +- **查看可用分支**:在创建 PR 前查看所有分支 +- **检查分支保护状态**:查看哪些分支被保护 +- **分支浏览**:探索仓库的分支结构 +- **自动化脚本**:结合 JSON 格式输出进行批量操作 + +## Tips + +- 使用 `--format json` 可以更好地解析分支信息 +- 分支列表包含保护状态,可以快速识别受保护的分支 +- 支持分页,适合分支较多的仓库 + +## References + +- [branch +create](branch-create.md) — 创建分支 +- [branch +protect](branch-protect.md) — 保护分支 +- [gitlink-branch](../SKILL.md) — 分支操作总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-branch/references/branch-protect.md b/skills/gitlink-branch/references/branch-protect.md new file mode 100644 index 0000000..f686be9 --- /dev/null +++ b/skills/gitlink-branch/references/branch-protect.md @@ -0,0 +1,142 @@ +# branch +protect + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +设置分支保护规则,防止重要分支被意外修改或删除。 + +## 命令 + +```bash +# 保护分支 +gitlink-cli branch +protect --name main + +# 保护 master 分支 +gitlink-cli branch +protect --name master + +# 指定仓库保护分支 +gitlink-cli branch +protect --name main --owner someone --repo myrepo + +# 保护开发分支 +gitlink-cli branch +protect --name develop +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--name, -n` | **是** | 要保护的分支名称 | +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +POST /{owner}/{repo}/protected_branches +Body: { "branch_name": name } +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "branch_name": "main", + "protected": true, + "message": "Branch protection enabled successfully" + } +} +``` + +## Workflow + +1. **Confirm** the branch name to protect with the user. +2. **Execute** `gitlink-cli branch +protect --name `. +3. **Report** the protection result. + +> [!CAUTION] +> This is a **Write Operation** — confirm user intent before executing. + +## Use Cases + +- **保护主分支**:保护 `main` 或 `master` 分支,防止直接推送 +- **保护发布分支**:保护 `release` 分支,确保发布版本稳定性 +- **保护开发分支**:保护 `develop` 分支,维护开发主线稳定 +- **合规要求**:满足团队管理或合规性要求 + +## What Protection Means + +分支保护后,以下操作将被限制: +- ✅ **仍可操作**:通过 Pull Request 合并更改 +- ❌ **受限操作**:直接推送代码 +- ❌ **受限操作**:强制推送 +- ❌ **受限操作**:删除分支 +- ❌ **受限操作**:修改历史 + +## Best Practices + +1. **保护关键分支**:至少保护 `main` 和 `develop` 分支 +2. **配合 PR 工作流**:强制通过 PR 进行代码审查 +3. **定期审查**:定期检查和保护重要的分支 +4. **团队协作**:团队协商确定保护策略 + +## Common Protected Branches + +| 分支名 | 用途 | 建议保护 | +|--------|------|----------| +| `main` / `master` | 主分支 | ✅ 强烈建议 | +| `develop` | 开发分支 | ✅ 建议 | +| `release/*` | 发布分支 | ✅ 建议 | +| `hotfix/*` | 紧急修复分支 | ⚠️ 可选 | + +## Error Handling + +常见错误及解决方案: + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `404` | 分支不存在 | 使用 `branch +list` 确认分支存在 | +| `403` | 权限不足 | 确认有管理权限 | +| `409` | 已经被保护 | 分支已经处于保护状态 | + +## Safety Considerations + +- ⚠️ **权限要求**:需要管理员或协作者权限 +- ⚠️ **团队影响**:保护分支影响整个团队的协作流程 +- ⚠️ **CI/CD 集成**:确保 CI/CD 流程兼容保护规则 + +## Tips + +- 保护前先确认分支名称正确 +- 可以使用 `branch +list --format json` 查看分支保护状态 +- 设置保护后,团队成员需要通过 PR 贡献代码 +- 建议在设置保护前通知团队成员 + +## Workflow Example + +典型的分支保护工作流: + +```bash +# 1. 查看分支列表 +gitlink-cli branch +list + +# 2. 确认要保护的分支 +gitlink-cli branch +list --format json | jq '.data.branches[] | select(.name=="main")' + +# 3. 设置分支保护 +gitlink-cli branch +protect --name main + +# 4. 验证保护设置 +gitlink-cli branch +list --format json | jq '.data.branchs[] | select(.name=="main") | .protected' +``` + +## References + +- [branch +unprotect](branch-unprotect.md) — 移除分支保护 +- [branch +list](branch-list.md) — 列出分支并查看保护状态 +- [gitlink-branch](../SKILL.md) — 分支操作总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-branch/references/branch-unprotect.md b/skills/gitlink-branch/references/branch-unprotect.md new file mode 100644 index 0000000..8a9aa88 --- /dev/null +++ b/skills/gitlink-branch/references/branch-unprotect.md @@ -0,0 +1,164 @@ +# branch +unprotect + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +移除分支保护规则,允许分支被直接修改。 + +## 命令 + +```bash +# 移除分支保护 +gitlink-cli branch +unprotect --name main + +# 指定仓库移除分支保护 +gitlink-cli branch +unprotect --name main --owner someone --repo myrepo + +# 移除开发分支保护 +gitlink-cli branch +unprotect --name develop +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--name, -n` | **是** | 要移除保护的分支名称 | +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +DELETE /{owner}/{repo}/protected_branches/{branch_name} +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "branch_name": "main", + "protected": false, + "message": "Branch protection removed successfully" + } +} +``` + +## Workflow + +1. **Confirm** the branch name to unprotect with the user. +2. **Execute** `gitlink-cli branch +unprotect --name `. +3. **Report** the unprotection result. + +> [!CAUTION] +> This is a **Write Operation** — confirm user intent before executing. This will allow direct pushes to the branch. + +## Use Cases + +- **紧急修复**:临时允许直接推送紧急修复 +- **分支重组**:调整分支保护策略 +- **迁移工作流**:从 PR 工作流切换到直接推送 +- **权限调整**:根据团队需求调整保护规则 + +## What Unprotection Means + +移除分支保护后,以下操作将被允许: +- ✅ **允许操作**:直接推送代码 +- ✅ **允许操作**:强制推送 +- ✅ **允许操作**:删除分支 +- ✅ **允许操作**:修改历史 + +## Risks and Considerations + +⚠️ **风险提醒**: +- 失去 PR 代码审查机制 +- 可能直接推送到关键分支 +- 增加代码冲突和错误风险 +- 影响代码质量和稳定性 + +## Best Practices + +1. **谨慎使用**:仅在确有需要时移除保护 +2. **临时移除**:考虑临时移除后重新保护 +3. **团队沟通**:移除保护前通知团队成员 +4. **重新保护**:完成操作后及时恢复保护 + +## When to Use + +**适合移除保护的情况:** +- 紧急修复需要快速部署 +- 仓库结构重组或迁移 +- 测试和验证工作流 +- 小团队内部协作 + +**不适合移除保护的情况:** +- 有 PR 审查需求 +- 多人协作的大型项目 +- 需要严格代码质量控制 +- 生产环境的关键分支 + +## Error Handling + +常见错误及解决方案: + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `404` | 分支不存在 | 使用 `branch +list` 确认分支存在 | +| `404` | 未被保护 | 分支当前没有保护规则 | +| `403` | 权限不足 | 确认有管理权限 | +| `400` | 路径问题 | 含 `/` 的分支名可能需要通过 Web 操作 | + +## Limitations + +- ⚠️ **路径限制**:含 `/` 的分支名(如 `feature/my-branch`)可能无法通过 CLI 移除保护 +- ⚠️ **API 限制**:某些特殊分支可能需要通过 Web 页面操作 +- ⚠️ **权限要求**:需要管理员或协作者权限 + +## Safety Workflow + +推荐的移除保护工作流: + +```bash +# 1. 查看当前保护状态 +gitlink-cli branch +list --format json | jq '.data.branches[] | select(.protected)' + +# 2. 确认要移除保护的分支 +gitlink-cli branch +list --format json | jq '.data.branches[] | select(.name=="main") | .protected' + +# 3. 移除分支保护 +gitlink-cli branch +unprotect --name main + +# 4. 验证移除结果 +gitlink-cli branch +list --format json | jq '.data.branches[] | select(.name=="main") | .protected' + +# 5. 完成操作后重新保护 +gitlink-cli branch +protect --name main +``` + +## Tips + +- 移除保护前,建议先检查当前的保护状态 +- 考虑设置定时提醒,确保及时恢复保护 +- 对于重要分支,建议使用 Web UI 确认移除保护 +- 记录移除保护的原因和时间,便于审计 + +## Team Collaboration + +团队协作时的建议: + +1. **提前沟通**:在移除保护前通知所有团队成员 +2. **说明原因**:向团队解释为什么需要移除保护 +3. **时间限制**:设定移除保护的时间限制 +4. **操作文档**:记录移除保护的操作和原因 +5. **及时恢复**:完成操作后立即恢复保护 + +## References + +- [branch +protect](branch-protect.md) — 设置分支保护 +- [branch +list](branch-list.md) — 列出分支并查看保护状态 +- [gitlink-branch](../SKILL.md) — 分支操作总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-changelog/SKILL.md b/skills/gitlink-changelog/SKILL.md new file mode 100644 index 0000000..d58dcdd --- /dev/null +++ b/skills/gitlink-changelog/SKILL.md @@ -0,0 +1,142 @@ +--- +name: gitlink-changelog +version: 1.0.0 +description: "Release Notes 生成:根据 commit 和 PR 记录自动生成结构化版本发布说明。当用户需要生成 Release Notes、发版说明时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli release --help" +--- + +# gitlink-changelog(Release Notes 生成) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 + +## 工作流 + +Release Notes 生成分三步:收集 → 分类 → 发布。 + +| 步骤 | 说明 | 所用命令 | +|------|------|----------| +| 1. 收集数据 | 获取版本间的 commits、已合并 PR、已关闭 Issue | `release +list`, `api GET compare`, `pr +list`, `issue +list` | +| 2. 分类整理 | 按类型归类变更(新功能/Bug修复/改进/破坏性变更) | AI 分析 | +| 3. 生成发布 | 套用模板生成 Notes,创建或更新 Release | `release +create`, `release +update` | + +## 命令参考 + +### 收集数据 + +```bash +# 确定版本范围:获取已有 release 列表,找上一个 tag +gitlink-cli release +list --format json + +# 获取两个版本间的 commit 差异(平台 compare API) +gitlink-cli api GET /:owner/:repo/compare/v1.0.0...v1.1.0 --format json + +# 获取已合并的 PR +gitlink-cli pr +list --state merged --format json + +# 获取已关闭的 Issue +gitlink-cli issue +list --state closed --format json +``` + +### 发布 Release Notes + +```bash +# 创建 Release 并附带 Notes +gitlink-cli release +create --tag v1.1.0 --name "v1.1.0" --body "" + +# 更新已有 Release 的 Notes +gitlink-cli release +update --id --body "<更新后的 Notes>" +``` + +## 分类规则 + +| 类型 | 图标 | Issue 标签 | Commit 关键词 | +|------|------|------------|---------------| +| 新功能 | ✨ | `feature`, `enhancement` | `feat:`, `add`, `新增` | +| Bug 修复 | 🐛 | `bug`, `fix` | `fix:`, `bugfix`, `修复` | +| 功能改进 | 🔧 | `improvement`, `optimize` | `improve:`, `optimize:`, `refactor:` | +| 破坏性变更 | ⚠️ | `breaking`, `major` | `BREAKING`, `breaking:`, `!` | +| 文档 | 📝 | `docs`, `documentation` | `docs:`, `doc` | +| 安全修复 | 🔒 | `security`, `vulnerability` | `security:`, `安全` | + +## Release Notes 模板 + +### 标准模板 + +```markdown +# 🎉 Release {VERSION} + +## 📊 变更统计 +- **新功能**: {FEATURE_COUNT} 个 +- **Bug 修复**: {BUG_FIX_COUNT} 个 +- **功能改进**: {ENHANCEMENT_COUNT} 个 +- **破坏性变更**: {BREAKING_COUNT} 个 + +## ✨ 新功能 +{FEATURES} + +## 🐛 Bug 修复 +{BUG_FIXES} + +## 🔧 功能改进 +{ENHANCEMENTS} + +## ⚠️ 破坏性变更 +{BREAKING_CHANGES} + +## 🙏 贡献者 +{CONTRIBUTORS} + +--- +**完整变更日志**: https://www.gitlink.org.cn/{OWNER}/{REPO}/compare/{PREV}...{VERSION} +``` + +> **条目格式要求**:每个变更条目必须在末尾标注作者,格式为 `- 变更描述 (#编号) (@作者)`。commit 无 PR 编号时格式为 `- 变更描述 (作者名)`。 + +### 简化模板 + +```markdown +# {VERSION} + +## 新增 +{FEATURES} + +## 修复 +{BUG_FIXES} + +## 改进 +{ENHANCEMENTS} +``` + +## 版本号规范 + +遵循语义化版本(Semantic Versioning):`MAJOR.MINOR.PATCH` + +| 变更类型 | 版本变化 | 示例 | +|----------|----------|------| +| 破坏性变更 | MAJOR +1 | `1.2.0` → `2.0.0` | +| 向后兼容的新功能 | MINOR +1 | `1.1.0` → `1.2.0` | +| 向后兼容的 Bug 修复 | PATCH +1 | `1.1.0` → `1.1.1` | + +## API 注意事项 + +- `compare` API 无专用 shortcut,通过 `gitlink-cli api GET /:owner/:repo/compare/{head}...{base}` 调用 +- `compare` API 另支持查询参数格式:`GET /v1/:owner/:repo/compare.json?from=&to=` +- `release +create` 的 `--body` 接受完整 Markdown,支持多行文本 +- `release +view` / `release +delete` 必须使用 `version_id`(从 `release +list` 获取),不可用 `tag_name` +- 创建 Release 前务必让用户审核生成的 Notes 内容 + +## References + +- [collect-data](references/collect-data.md) — 收集 commits、PR、Issue 数据 +- [classify-rules](references/classify-rules.md) — 变更分类规则详解 +- [generate-and-publish](references/generate-and-publish.md) — 生成 Notes 并发布 +- [full-workflow](examples/full-workflow.md) — 完整端到端示例 +- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证和全局参数 +- [gitlink-release](../gitlink-release/SKILL.md) — Release 操作 diff --git a/skills/gitlink-changelog/examples/full-workflow.md b/skills/gitlink-changelog/examples/full-workflow.md new file mode 100644 index 0000000..2709ed1 --- /dev/null +++ b/skills/gitlink-changelog/examples/full-workflow.md @@ -0,0 +1,152 @@ +# Release Notes 完整生成示例 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **适用场景:** AI Agent 端到端生成 Release Notes,从数据收集到发布。 + +以 `zzx-coder/gitlink-cli` 项目从 `v1.0.0` 到 `v1.1.0` 为例。 + +## 完整流程 + +### 第一步:确定版本范围 + +```bash +# 获取已有 release +gitlink-cli release +list --format json + +# 返回示例(截取关键字段): +# { +# "ok": true, +# "data": { +# "releases": [ +# { "tag_name": "v1.0.0", "created_at": "2026-05-01", ... }, +# ... +# ] +# } +# } + +# AI 据此确定:PREV_VERSION = "v1.0.0",NEW_VERSION = "v1.1.0" +``` + +### 第二步:收集 commits + +```bash +gitlink-cli api GET /:owner/:repo/compare/v1.0.0...v1.1.0 --format json + +# 从返回中提取 commits 列表,每个 commit 含: +# - commit.message (提交信息) +# - commit.author.name (作者) +# - sha (提交 SHA) +``` + +### 第三步:收集已合并 PR + +```bash +gitlink-cli pr +list --state merged --format json + +# AI 筛选 merged_at >= "2026-05-01"(v1.0.0 发布时间)的 PR +# 提取每个 PR 的 title、number、author.login +``` + +### 第四步:收集已关闭 Issue + +```bash +gitlink-cli issue +list --state closed --format json + +# AI 筛选 closed_at >= "2026-05-01" 的 Issue +# 提取每个 Issue 的 subject、project_issues_index、issue_tags +``` + +### 第五步:AI 分类 + +AI 根据 [分类规则](../references/classify-rules.md) 对收集到的数据分类: + +``` +新功能: + - 支持批量 Issue 操作 (#12) (@zhangsan) + - 新增 uninstall 命令 (#11) (@lisi) + +Bug 修复: + - 修复 URL 解析异常 (#10) (@wangwu) + +功能改进: + - 重构自动部署配置 (camelliamc) + +文档: + - 更新分支映射说明 (camelliamc) +``` + +### 第六步:生成 Notes 并确认 + +AI 套用标准模板生成草稿并展示给用户: + +```markdown +# 🎉 Release v1.1.0 + +## 📊 变更统计 +- **新功能**: 2 个 +- **Bug 修复**: 1 个 +- **功能改进**: 1 个 +- **破坏性变更**: 0 个 + +## ✨ 新功能 +- 支持批量 Issue 操作 (#12) (@zhangsan) +- 新增 uninstall 命令 (#11) (@lisi) + +## 🐛 Bug 修复 +- 修复 URL 解析异常 (#10) (@wangwu) + +## 🔧 功能改进 +- 重构自动部署配置 (camelliamc) + +## 🙏 贡献者 +zzx-coder, camelliamc + +--- +**完整变更日志**: https://www.gitlink.org.cn/zzx-coder/gitlink-cli/compare/v1.0.0...v1.1.0 +``` + +### 第七步:用户确认后发布 + +```bash +gitlink-cli release +create \ + --tag v1.1.0 \ + --name "v1.1.0" \ + --body "# 🎉 Release v1.1.0 + +## 📊 变更统计 +- **新功能**: 2 个 +- **Bug 修复**: 1 个 +- **功能改进**: 1 个 +- **破坏性变更**: 0 个 + +## ✨ 新功能 +- 支持批量 Issue 操作 (#12) (@zhangsan) +- 新增 uninstall 命令 (#11) (@lisi) + +## 🐛 Bug 修复 +- 修复 URL 解析异常 (#10) (@wangwu) + +## 🔧 功能改进 +- 重构自动部署配置 (camelliamc) + +## 🙏 贡献者 +zhangsan, lisi, wangwu, camelliamc + +--- +**完整变更日志**: https://www.gitlink.org.cn/zzx-coder/gitlink-cli/compare/v1.0.0...v1.1.0" +``` + +## AI Agent 执行要点 + +1. **自动解析 `--owner` / `--repo`**:在仓库目录下执行,CLI 自动从 git remote 解析 +2. **始终使用 `--format json`**:所有命令加此参数,便于 AI 解析返回值 +3. **时间筛选**:用上一个 Release 的 `created_at` 作为 PR/Issue 的时间筛选基线 +4. **去重**:PR 和 Issue 描述同一变更时合并为一条 +5. **确认优先**:生成 Notes 后必须展示给用户,收到确认才执行 `release +create` + +## References + +- [SKILL.md](../SKILL.md) — 工作流和模板总览 +- [collect-data](../references/collect-data.md) — 数据收集详细说明 +- [classify-rules](../references/classify-rules.md) — 分类规则 +- [generate-and-publish](../references/generate-and-publish.md) — 生成和发布 diff --git a/skills/gitlink-changelog/references/classify-rules.md b/skills/gitlink-changelog/references/classify-rules.md new file mode 100644 index 0000000..6e2627d --- /dev/null +++ b/skills/gitlink-changelog/references/classify-rules.md @@ -0,0 +1,110 @@ +# 变更分类规则 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +将收集到的 commits、PRs 和 Issues 按类型归类,为生成结构化 Release Notes 做准备。 + +## 分类维度 + +变更按以下维度分类: + +| 类型 | 图标 | 标题 | +|------|------|------| +| 新功能 | ✨ | 新功能 | +| Bug 修复 | 🐛 | Bug 修复 | +| 功能改进 | 🔧 | 功能改进 | +| 破坏性变更 | ⚠️ | 破坏性变更 | +| 文档 | 📝 | 文档 | +| 安全修复 | 🔒 | 安全修复 | + +## 分类依据 + +### 按 Issue 标签分类(最可靠) + +从 `issue +list` 返回的 `issue_tags` 字段匹配: + +| Issue 标签 | 对应类型 | +|------------|----------| +| `feature`, `enhancement` | 新功能 | +| `bug`, `fix` | Bug 修复 | +| `improvement`, `optimize` | 功能改进 | +| `breaking`, `major` | 破坏性变更 | +| `docs`, `documentation` | 文档 | +| `security`, `vulnerability` | 安全修复 | + +### 按 Commit 关键词分类(辅助) + +从 `compare` API 返回的 `commit.message` 第一行匹配: + +| 关键词 | 对应类型 | +|--------|----------| +| `feat:`, `add`, `新增` | 新功能 | +| `fix:`, `bugfix`, `修复` | Bug 修复 | +| `improve:`, `optimize:`, `refactor:`, `优化`, `重构` | 功能改进 | +| `BREAKING`, `breaking:`, `!` | 破坏性变更 | +| `docs:`, `doc` | 文档 | +| `security:`, `安全` | 安全修复 | + +### 按 PR 标题分类(辅助) + +PR 标题通常遵循 Conventional Commits 格式,按前缀匹配: + +| PR 标题前缀 | 对应类型 | +|-------------|----------| +| `feat:` / `feature:` | 新功能 | +| `fix:` | Bug 修复 | +| `refactor:` / `perf:` | 功能改进 | +| `docs:` | 文档 | + +## 分类优先级 + +1. **Issue 标签**(最准确,优先采用) +2. **PR 标题前缀**(次之) +3. **Commit 关键词**(兜底) + +对于同一个变更,如果 Issue 标签和 commit 关键词都在,以 Issue 标签为准。 + +## 去重 + +以下情况会产生重复条目,需去重: + +- PR 和 Issue 关联同一个变更 → 合并为一条:`变更描述 (#PR编号, #Issue编号)` +- 同一变更的多个 commit → 只保留摘要最清晰的一条 +- PR 标题与关联 Issue 主题高度相似 → 合并,优先使用 Issue 的 subject + +## 输出格式 + +分类完成后,整理为结构化数据供模板填充: + +``` +新功能: + - 支持批量关闭 Issue (#123) + - 新增 Wiki 管理命令 (#130) + +Bug 修复: + - 修复 Windows 登录 token 存储失败 (#118) + +功能改进: + - 优化 API 请求性能 (#125) + +破坏性变更: + - 重构认证模块接口(不向下兼容)(#140) + +文档: + - 补充分支映射说明 (#115) +``` + +## 贡献者收集 + +从 commit 和 PR 数据中提取贡献者列表: + +- Commit: `author.name` 或 `author.login` +- PR: `author.login` + +去重后生成贡献者名单,写入 Release Notes 末尾。 + +## References + +- [collect-data](collect-data.md) — 数据收集步骤 +- [generate-and-publish](generate-and-publish.md) — 生成 Notes 并发布 +- [SKILL.md](../SKILL.md) — 分类规则速查表 diff --git a/skills/gitlink-changelog/references/collect-data.md b/skills/gitlink-changelog/references/collect-data.md new file mode 100644 index 0000000..59f2329 --- /dev/null +++ b/skills/gitlink-changelog/references/collect-data.md @@ -0,0 +1,84 @@ +# 收集变更数据 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +收集 Release Notes 所需的三类数据:版本范围、commits、PRs 和 Issues。 + +## 命令 + +### 步骤 1:确定版本范围 + +```bash +# 获取已有 release 列表,找到上一个版本 tag +gitlink-cli release +list --format json +# 从返回的 releases 中提取最后一个 tag_name 作为 PREV_VERSION +# 用户指定或 AI 推断新版本号 NEW_VERSION +``` + +### 步骤 2:获取 commits(平台 compare API) + +```bash +# 获取两个 tag 之间的 commit 比较 +gitlink-cli api GET /:owner/:repo/compare/{PREV_VERSION}...{NEW_VERSION} --format json + +# 也可用查询参数格式 +gitlink-cli api GET /v1/:owner/:repo/compare.json --query "from={PREV_VERSION}&to={NEW_VERSION}" --format json +``` + +返回数据包含:`commits`(提交列表含 message/author/date/sha)、`total_commits`(提交总数)、`files`(变更文件)等。 + +### 步骤 3:获取已合并的 PR + +```bash +# 获取已合并的 PR 列表 +gitlink-cli pr +list --state merged --format json + +# 从返回的 PRs 中按 merged_at 时间筛选: +# 只保留 merged_at >= 上一个版本发布时间的 PR +``` + +返回数据包含:每个 PR 的 `title`、`number`、`author`、`merged_at`、`pull_request_number` 等。 + +### 步骤 4:获取已关闭的 Issue + +```bash +# 获取已关闭的 Issue 列表 +gitlink-cli issue +list --state closed --format json + +# 从返回的 Issues 中按 closed_at 时间筛选: +# 只保留 closed_at >= 上一个版本发布时间的 Issue +``` + +返回数据包含:每个 Issue 的 `subject`、`project_issues_index`、`issue_tags`(标签)、`author`、`closed_at` 等。 + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--format` | 否 | 始终建议 `json`,便于 AI 解析 | +| `--state` | 否 | PR: `merged`;Issue: `closed` | +| `--page` | 否 | 大量数据时分页获取 | +| `--limit` | 否 | 每页条数 | + +## 数据整合 + +收集完成后,AI 整合三类数据: + +1. **Commits** → 提取 commit message 第一行作为变更摘要,附作者名 +2. **PRs** → 用 `title`、`number`、`author.login` 生成条目:`- 功能描述 (#PR编号) (@作者)` +3. **Issues** → 用 `subject`、`project_issues_index`、`author.login` 生成条目:`- Issue 描述 (#编号) (@作者)` + +时间筛选逻辑:从 `release +list` 获取上一个版本的发布时间,只取该时间之后的 PR/Issue。 + +## 注意事项 + +- 如果是**第一个版本**(无上一版本),只收集当前版本时间范围内的 PR/Issue,commits 用全量最近提交 +- `compare` API 的 tag 需要真实存在,否则返回 404 +- PR 和 Issue 的返回可能超过单页,注意分页获取全部数据 +- `--owner` / `--repo` 在仓库目录下可自动解析 + +## References + +- [classify-rules](classify-rules.md) — 收集完成后对变更进行分类 +- [generate-and-publish](generate-and-publish.md) — 生成 Notes 并发布 +- [gitlink-release](../gitlink-release/SKILL.md) — Release 操作 diff --git a/skills/gitlink-changelog/references/generate-and-publish.md b/skills/gitlink-changelog/references/generate-and-publish.md new file mode 100644 index 0000000..b399953 --- /dev/null +++ b/skills/gitlink-changelog/references/generate-and-publish.md @@ -0,0 +1,133 @@ +# 生成并发布 Release Notes + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **CRITICAL — 此为写入操作,执行前务必确认用户已审核 Release Notes 内容。** + +将分类整理后的变更数据套用模板,生成 Markdown 格式的 Release Notes,并发布到 GitLink。 + +## 模板 + +### 标准模板 + +```markdown +# 🎉 Release {VERSION} + +## 📊 变更统计 +- **新功能**: {FEATURE_COUNT} 个 +- **Bug 修复**: {BUG_FIX_COUNT} 个 +- **功能改进**: {ENHANCEMENT_COUNT} 个 +- **破坏性变更**: {BREAKING_COUNT} 个 + +## ✨ 新功能 +{FEATURES} + +## 🐛 Bug 修复 +{BUG_FIXES} + +## 🔧 功能改进 +{ENHANCEMENTS} + +## ⚠️ 破坏性变更 +{BREAKING_CHANGES} + +## 🙏 贡献者 +{CONTRIBUTORS} + +--- +**完整变更日志**: https://www.gitlink.org.cn/{OWNER}/{REPO}/compare/{PREV}...{VERSION} +``` + +### 简化模板(适用于 patch 版本或小型发布) + +```markdown +# {VERSION} + +## 新增 +{FEATURES} + +## 修复 +{BUG_FIXES} + +## 改进 +{ENHANCEMENTS} + +## 贡献者 +{CONTRIBUTORS} + +[完整变更](https://www.gitlink.org.cn/{OWNER}/{REPO}/compare/{PREV}...{VERSION}) +``` + +## 模板占位符说明 + +| 占位符 | 来源 | +|--------|------| +| `{VERSION}` | 用户指定或 AI 推断的新版本号(如 `v1.2.0`) | +| `{PREV}` | `release +list` 获取的上一个版本 tag | +| `{OWNER}` | 仓库所有者,从 git remote 解析 | +| `{REPO}` | 仓库名称,从 git remote 解析 | +| `{FEATURE_COUNT}` 等 | 分类后的各类变更数量 | +| `{FEATURES}` 等 | 分类后的各类变更条目,每条一行 `- 描述 (#编号) (@作者)` | +| `{CONTRIBUTORS}` | 从 commits/PRs 去重后的贡献者列表 | + +## 命令 + +### 新建 Release + +```bash +gitlink-cli release +create \ + --tag v1.2.0 \ + --name "v1.2.0" \ + --body "" +``` + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--tag` | **是** | 版本 tag(如 `v1.2.0`) | +| `--name` | **是** | Release 名称,通常与 tag 一致 | +| `--body` | 否 | Release Notes 正文(Markdown,支持多行) | +| `--target` | 否 | 目标分支,默认 `master` | +| `--prerelease` | 否 | 标记为预发布版本 | + +### 更新已有 Release + +```bash +gitlink-cli release +update \ + --id \ + --body "<更新后的 Release Notes>" +``` + +> ⚠️ `release +update` 使用 `version_id`(数字 ID,从 `release +list` 获取),不是 `tag_name`。 + +## Workflow + +> [!CAUTION] +> Release Notes 发布是 **Write Operation**,执行前必须让用户审核内容。 + +1. **生成** Release Notes 草稿(套用模板填充数据) +2. **展示**草稿给用户审核 +3. **确认**用户同意后才执行 `release +create` 或 `release +update` +4. **报告**创建的 Release URL 给用户 + +## 发布前检查清单 + +- [ ] 版本号遵循语义化版本规范 +- [ ] 变更统计与实际一致 +- [ ] 破坏性变更已明确标注 +- [ ] 贡献者列表完整 +- [ ] 无敏感信息泄露 +- [ ] 对比链接可访问 + +## 注意事项 + +- `release +create --body` 接受完整 Markdown,换行和格式由模板控制 +- `release +view` / `release +delete` 使用 `version_id`(数字),不是 `tag_name` +- 可用 `release +update --body` 修正已发布的 Notes +- 首个版本(无上一版本)省略对比链接 + +## References + +- [collect-data](collect-data.md) — 收集变更数据 +- [classify-rules](classify-rules.md) — 变更分类规则 +- [full-workflow](../examples/full-workflow.md) — 完整端到端示例 +- [gitlink-release](../../gitlink-release/SKILL.md) — Release 操作 +- [release +create](../../gitlink-release/references/gitlink-release-create.md) — 创建 Release 详细参数 diff --git a/skills/gitlink-ci/references/ci-list.md b/skills/gitlink-ci/references/ci-list.md new file mode 100644 index 0000000..9c11d54 --- /dev/null +++ b/skills/gitlink-ci/references/ci-list.md @@ -0,0 +1,180 @@ +# ci +builds + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +列出仓库的所有 CI/CD 构建记录,支持分页查询。 + +## 命令 + +```bash +# 查看当前仓库的构建列表 +gitlink-cli ci +builds + +# 指定仓库查看构建 +gitlink-cli ci +builds --owner myuser --repo myrepo + +# 分页查询 +gitlink-cli ci +builds --page 2 --limit 10 + +# 输出为 JSON 格式 +gitlink-cli ci +builds --format json +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--page, -p` | 否 | 页码(默认 `1`) | +| `--limit, -l` | 否 | 每页条数(默认 `20`) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +GET /{owner}/{repo}/builds?page=1&limit=20 +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "builds": [ + { + "id": 42, + "build_number": 42, + "status": "success", + "started_at": "2026-01-01T10:00:00Z", + "duration": 125, + "commit": { + "sha": "abc123...", + "message": "Fix bug in authentication", + "author": "developer@example.com" + }, + "branch": "feature/auth-fix", + "stages": [ + { + "stage_number": 1, + "stage_name": "build", + "status": "success" + }, + { + "stage_number": 2, + "stage_name": "test", + "status": "success" + } + ] + }, + { + "id": 41, + "build_number": 41, + "status": "failed", + "started_at": "2026-01-01T09:30:00Z", + "duration": 45, + "commit": { + "sha": "def456...", + "message": "Add new feature", + "author": "developer@example.com" + }, + "branch": "develop", + "stages": [ + { + "stage_number": 1, + "stage_name": "build", + "status": "failed" + } + ] + } + ], + "total_count": 156 + }, + "meta": { + "page": 1, + "limit": 20, + "total_count": 156 + } +} +``` + +## Workflow + +1. **Resolve** owner and repo (from git remote or flags). +2. **Execute** `gitlink-cli ci +builds`. +3. **Display** builds in the requested format. + +> [!NOTE] +> This is a **Read Operation** — no confirmation needed. + +## Use Cases + +- **构建历史查看**:查看仓库的构建历史和状态 +- **问题排查**:查找失败的构建进行分析 +- **构建监控**:监控 CI/CD 系统的运行状态 +- **自动化脚本**:结合 JSON 格式输出进行构建分析 + +## Build Status + +构建状态类型: + +| 状态 | 说明 | +|------|------| +| `pending` | 等待执行 | +| `running` | 正在执行 | +| `success` | 构建成功 | +| `failed` | 构建失败 | +| `cancelled` | 构建取消 | +| `skipped` | 构建跳过 | + +## Data Analysis + +使用 JSON 输出进行构建分析: + +```bash +# 查看最近10次构建的成功率 +gitlink-cli ci +builds --format json --limit 10 | \ + jq '[.data.builds[] | select(.status=="success")] | length / 10 * 100' + +# 查看失败的构建 +gitlink-cli ci +builds --format json | \ + jq '.data.builds[] | select(.status=="failed")' + +# 查看平均构建时间 +gitlink-cli ci +builds --format json | \ + jq '[.data.builds[].duration] | add / length' +``` + +## Tips + +- 使用 `--format json` 可以更好地解析和分析构建数据 +- 构建列表包含详细的提交信息和分支信息 +- 支持分页,适合构建历史较多的仓库 +- 结合 `ci +logs` 可以深入分析构建失败原因 + +## CI/CD Integration + +结合其他 CI 命令的典型工作流: + +```bash +# 1. 查看构建列表 +gitlink-cli ci +builds + +# 2. 查看失败构建的日志 +gitlink-cli ci +logs --build 42 + +# 3. 重启失败的构建 +gitlink-cli ci +restart --build 42 +``` + +## References + +- [ci +logs](ci-logs.md) — 查看构建日志 +- [ci +restart](ci-restart.md) — 重启构建 +- [ci +stop](ci-stop.md) — 停止构建 +- [gitlink-ci](../SKILL.md) — CI/CD 操作总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-ci/references/ci-logs.md b/skills/gitlink-ci/references/ci-logs.md new file mode 100644 index 0000000..4599c72 --- /dev/null +++ b/skills/gitlink-ci/references/ci-logs.md @@ -0,0 +1,204 @@ +# ci +logs + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +查看指定 CI 构建的详细日志输出。 + +## 命令 + +```bash +# 查看构建日志 +gitlink-cli ci +logs --build 42 + +# 查看特定阶段的日志 +gitlink-cli ci +logs --build 42 --stage 2 + +# 查看特定步骤的日志 +gitlink-cli ci +logs --build 42 --stage 2 --step 3 + +# 指定仓库查看日志 +gitlink-cli ci +logs --build 42 --owner myuser --repo myrepo +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--build, -b` | **是** | 构建编号 | +| `--stage, -s` | 否 | 阶段编号(默认 `1`) | +| `--step` | 否 | 步骤编号(默认 `1`) | +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +GET /{owner}/{repo}/builds/{build}/logs/{stage}/{step} +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "build_number": 42, + "stage_number": 2, + "step_number": 1, + "log_content": "Running tests...\nTest suite started...\n[OK] Test authentication\n[OK] Test database connection\n[FAILED] Test API endpoint\n\nTests completed: 2/3 passed", + "stage_name": "test", + "step_name": "run_tests", + "timestamp": "2026-01-01T10:05:30Z" + } +} +``` + +## Workflow + +1. **Confirm** the build number with the user (can use `ci +builds` to list). +2. **Execute** `gitlink-cli ci +logs --build [--stage ] [--step ]`. +3. **Display** the log content. + +> [!NOTE] +> This is a **Read Operation** — no confirmation needed. + +## Use Cases + +- **问题排查**:查看构建失败的具体原因 +- **性能分析**:分析构建过程中的性能瓶颈 +- **调试输出**:查看代码运行时的调试信息 +- **监控执行**:实时跟踪构建执行状态 + +## CI Pipeline Structure + +典型的 CI/CD 流水线结构: + +``` +Stage 1: Build + ├── Step 1: Install dependencies + ├── Step 2: Build application + └── Step 3: Run linters + +Stage 2: Test + ├── Step 1: Run unit tests + ├── Step 2: Run integration tests + └── Step 3: Generate coverage report + +Stage 3: Deploy + ├── Step 1: Build deployment package + └── Step 2: Deploy to server +``` + +## Log Analysis + +日志分析技巧: + +```bash +# 查看构建日志 +gitlink-cli ci +logs --build 42 --stage 2 --step 1 + +# 结合 grep 过滤关键错误 +gitlink-cli ci +logs --build 42 --format json | \ + jq '.data.log_content' | grep "ERROR" + +# 查看完整日志流 +gitlink-cli ci +logs --build 42 --format json | \ + jq -r '.data.log_content' +``` + +## Stage and Step Navigation + +查看不同阶段的日志: + +```bash +# Stage 1: Build stage +gitlink-cli ci +logs --build 42 --stage 1 --step 1 + +# Stage 2: Test stage +gitlink-cli ci +logs --build 42 --stage 2 --step 1 + +# Stage 3: Deploy stage +gitlink-cli ci +logs --build 42 --stage 3 --step 1 +``` + +## Common Log Patterns + +常见日志模式: + +| 模式 | 含义 | +|------|------| +| `[ERROR]` | 错误信息 | +| `[FAILED]` | 测试或步骤失败 | +| `[WARN]` | 警告信息 | +| `[OK]` | 操作成功 | +| `Running...` | 正在执行 | +| `Completed` | 执行完成 | + +## Tips + +- 先使用 `ci +builds` 确认构建编号 +- 构建通常包含多个阶段,需要指定正确的阶段编号 +- 日志内容可能很长,建议使用 `--format json` 便于解析 +- 结合构建状态可以快速定位问题 + +## Troubleshooting Workflow + +典型的故障排查工作流: + +```bash +# 1. 查看构建列表,找到失败的构建 +gitlink-cli ci +builds | grep "failed" + +# 2. 查看失败构建的详细状态 +gitlink-cli ci +builds --build 42 --format json | \ + jq '.data.builds[] | .stages[]' + +# 3. 查看失败阶段的日志 +gitlink-cli ci +logs --build 42 --stage 2 --step 1 + +# 4. 根据日志信息修复问题 + +# 5. 重启构建 +gitlink-cli ci +restart --build 42 +``` + +## Error Handling + +常见错误及解决方案: + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `404` | 构建不存在 | 检查构建编号是否正确 | +| `404` | 阶段或步骤不存在 | 确认阶段和步骤编号 | +| `403` | 权限不足 | 确认有查看该仓库构建的权限 | + +## Advanced Usage + +高级用法示例: + +```bash +# 导出构建日志到文件 +gitlink-cli ci +logs --build 42 --format json | \ + jq -r '.data.log_content' > build_42_logs.txt + +# 分析日志中的错误模式 +gitlink-cli ci +logs --build 42 --format json | \ + jq -r '.data.log_content' | grep -c "ERROR" + +# 查看所有阶段的日志(循环) +for stage in {1..3}; do + echo "=== Stage $stage ===" + gitlink-cli ci +logs --build 42 --stage $stage --step 1 +done +``` + +## References + +- [ci +builds](ci-list.md) — 查看构建列表 +- [ci +restart](ci-restart.md) — 重启构建 +- [gitlink-ci](../SKILL.md) — CI/CD 操作总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-ci/references/ci-restart.md b/skills/gitlink-ci/references/ci-restart.md new file mode 100644 index 0000000..a169745 --- /dev/null +++ b/skills/gitlink-ci/references/ci-restart.md @@ -0,0 +1,201 @@ +# ci +restart + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +重新启动失败的或取消的 CI 构建。 + +## 命令 + +```bash +# 重启构建 +gitlink-cli ci +restart --build 42 + +# 指定仓库重启构建 +gitlink-cli ci +restart --build 42 --owner myuser --repo myrepo + +# 重启失败的构建(JSON 输出) +gitlink-cli ci +restart --build 42 --format json +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--build, -b` | **是** | 构建编号 | +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +POST /{owner}/{repo}/builds/{build}/restart +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "old_build_number": 42, + "new_build_number": 43, + "status": "pending", + "message": "Build restarted successfully", + "triggered_at": "2026-01-01T11:00:00Z" + } +} +``` + +## Workflow + +1. **Confirm** the build number to restart with the user. +2. **Check** the current build status (optional, using `ci +builds`). +3. **Execute** `gitlink-cli ci +restart --build `. +4. **Report** the restart result and new build number. + +> [!CAUTION] +> This is a **Write Operation** — confirm user intent before executing. + +## Use Cases + +- **失败重试**:构建因临时问题失败后重试 +- **取消后重新执行**:构建被取消后需要重新执行 +- **代码修复后验证**:修复代码后重新验证构建 +- **环境问题恢复**:CI 环境问题恢复后重新构建 + +## When to Restart + +**适合重启的情况:** +- ✅ 构建因临时网络问题失败 +- ✅ 依赖服务暂时不可用 +- ✅ 代码修复后需要重新验证 +- ✅ CI 环境问题已解决 + +**不适合重启的情况:** +- ❌ 代码存在严重错误 +- ❌ 测试用例本身有问题 +- ❌ 构建配置需要修改 +- ❌ 依赖库版本不兼容 + +## Restart Behavior + +重启构建的行为特点: + +| 方面 | 说明 | +|------|------| +| **新构建编号** | 重启会创建新的构建编号 | +| **相同代码** | 使用相同的提交代码 | +| **相同环境** | 使用相同的构建环境 | +| **独立日志** | 新构建有独立的日志记录 | +| **状态继承** | 不会继承原构建的状态 | + +## Best Practices + +1. **查看日志**:重启前先查看失败原因 +2. **修复问题**:如果是代码问题,先修复再重启 +3. **监控新构建**:重启后监控新构建的执行状态 +4. **资源考虑**:频繁重启会消耗 CI 资源 + +## Troubleshooting Workflow + +典型的故障排查和重启流程: + +```bash +# 1. 查看构建列表,找到失败的构建 +gitlink-cli ci +builds | grep "failed" + +# 2. 查看失败构建的详细状态 +gitlink-cli ci +builds --format json | \ + jq '.data.builds[] | select(.build_number==42)' + +# 3. 查看失败阶段的日志 +gitlink-cli ci +logs --build 42 --stage 2 --step 1 + +# 4. 分析日志,确定失败原因 + +# 5. 如果是临时问题,重启构建 +gitlink-cli ci +restart --build 42 + +# 6. 如果是代码问题,修复后重启 +# (先修复代码,然后) +gitlink-cli ci +restart --build 42 +``` + +## Error Handling + +常见错误及解决方案: + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `404` | 构建不存在 | 检查构建编号是否正确 | +| `400` | 构建正在运行 | 正在运行的构建无法重启 | +| `403` | 权限不足 | 确认有操作该仓库构建的权限 | +| `429` | 重启次数过多 | 短时间内重启次数过多,等待后重试 | + +## Pre-Restart Checklist + +重启前检查清单: + +- [ ] 确认构建编号正确 +- [ ] 查看失败日志,了解失败原因 +- [ ] 确认问题已解决(如果是代码问题) +- [ ] 检查 CI 系统状态 +- [ ] 确认有足够的 CI 资源 +- [ ] 考虑是否需要修改构建配置 + +## Post-Restart Actions + +重启后的后续操作: + +```bash +# 1. 重启构建 +gitlink-cli ci +restart --build 42 + +# 2. 获取新构建编号 +gitlink-cli ci +restart --build 42 --format json | \ + jq '.data.new_build_number' + +# 3. 监控新构建状态 +gitlink-cli ci +builds --format json | \ + jq '.data.builds[0]' + +# 4. 查看新构建的日志(如需要) +gitlink-cli ci +logs --build 43 --stage 1 --step 1 +``` + +## Team Collaboration + +团队协作时的建议: + +1. **沟通确认**:重启构建前通知相关团队成员 +2. **记录原因**:记录重启的原因和时间 +3. **状态更新**:及时更新构建状态给团队 +4. **结果分享**:重启完成后分享结果 + +## Tips + +- 重启会创建新的构建编号,原构建历史仍保留 +- 重启前建议先查看日志,确认问题性质 +- 对于重复失败的情况,建议先修复根本原因 +- 可以通过 `ci +builds` 查看重启后的新构建状态 + +## Cost Considerations + +使用注意事项: + +- ⚠️ **资源消耗**:每次重启都会消耗 CI 资源 +- ⚠️ **时间成本**:重新执行完整的构建流程 +- ⚠️ **排队时间**:新构建可能需要排队等待 +- ⚠️ **频繁重启**:避免无意义的频繁重启 + +## References + +- [ci +builds](ci-list.md) — 查看构建列表 +- [ci +logs](ci-logs.md) — 查看构建日志 +- [ci +stop](ci-stop.md) — 停止构建 +- [gitlink-ci](../SKILL.md) — CI/CD 操作总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-ci/references/ci-stop.md b/skills/gitlink-ci/references/ci-stop.md new file mode 100644 index 0000000..af7f7e6 --- /dev/null +++ b/skills/gitlink-ci/references/ci-stop.md @@ -0,0 +1,245 @@ +# ci +stop + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +停止正在运行的 CI 构建。 + +## 命令 + +```bash +# 停止构建 +gitlink-cli ci +stop --build 42 + +# 指定仓库停止构建 +gitlink-cli ci +stop --build 42 --owner myuser --repo myrepo + +# 停止构建(JSON 输出) +gitlink-cli ci +stop --build 42 --format json +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--build, -b` | **是** | 构建编号 | +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +DELETE /{owner}/{repo}/builds/{build}/stop +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "build_number": 42, + "status": "cancelled", + "message": "Build stopped successfully", + "stopped_at": "2026-01-01T11:30:00Z", + "duration": 180 + } +} +``` + +## Workflow + +1. **Confirm** the build number to stop with the user. +2. **Check** the current build status (ensure it's running). +3. **Execute** `gitlink-cli ci +stop --build `. +4. **Report** the stop result. + +> [!CAUTION] +> This is a **Write Operation** — confirm user intent before executing. This will terminate a running build. + +## Use Cases + +- **错误停止**:构建出现错误需要立即停止 +- **资源释放**:释放 CI 资源给其他构建 +- **配置错误**:构建配置错误需要停止 +- **测试中止**:测试过程中发现问题需要中止 +- **时间限制**:构建时间过长需要停止 + +## When to Stop + +**适合停止的情况:** +- ✅ 构建明显出现错误,继续执行无意义 +- ✅ 发现严重bug,需要立即停止 +- ✅ 构建配置错误,需要修改后重新执行 +- ✅ 误触发构建,需要立即取消 +- ✅ 构建时间过长,超出预期 + +**不适合停止的情况:** +- ❌ 构建接近完成 +- ❌ 仅为节省时间而停止正常构建 +- ❌ 不确定构建是否有问题 + +## Stop Behavior + +停止构建的行为特点: + +| 方面 | 说明 | +|------|------| +| **立即停止** | 通常会立即中断构建执行 | +| **状态变更** | 构建状态变为 `cancelled` | +| **资源释放** | 释放 CI 计算资源 | +| **日志保留** | 已执行的日志会保留 | +| **不可恢复** | 停止的构建无法恢复执行 | + +## Safety Considerations + +停止构建前考虑: + +- ⚠️ **进度损失**:已执行的进度会丢失 +- ⚠️ **资源浪费**:已消耗的资源无法回收 +- ⚠️ **团队影响**:可能影响其他依赖此构建的任务 +- ⚠️ **重新执行**:需要重新启动完整的构建 + +## Best Practices + +1. **确认状态**:停止前确认构建确实在运行 +2. **评估影响**:考虑停止对其他流程的影响 +3. **记录原因**:记录停止构建的原因 +4. **后续处理**:计划停止后的后续操作 + +## Stop Workflow + +典型的停止构建工作流: + +```bash +# 1. 查看运行中的构建 +gitlink-cli ci +builds --format json | \ + jq '.data.builds[] | select(.status=="running")' + +# 2. 确认要停止的构建编号 +gitlink-cli ci +builds | grep "running" + +# 3. 停止构建 +gitlink-cli ci +stop --build 42 + +# 4. 验证停止状态 +gitlink-cli ci +builds --format json | \ + jq '.data.builds[] | select(.build_number==42) | .status' +``` + +## Error Handling + +常见错误及解决方案: + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `404` | 构建不存在 | 检查构建编号是否正确 | +| `400` | 构建已完成 | 构建已经完成或停止,无法再停止 | +| `403` | 权限不足 | 确认有操作该仓库构建的权限 | +| `409` | 构建已完成 | 构建已经自然结束 | + +## Pre-Stop Checklist + +停止前检查清单: + +- [ ] 确认构建编号正确 +- [ ] 确认构建正在运行 +- [ ] 评估停止的影响范围 +- [ ] 确认停止原因合理 +- [ ] 考虑后续处理方案 +- [ ] 通知相关团队成员 + +## Post-Stop Actions + +停止后的后续操作: + +```bash +# 1. 停止构建 +gitlink-cli ci +stop --build 42 + +# 2. 查看停止状态 +gitlink-cli ci +builds --format json | \ + jq '.data.builds[] | select(.build_number==42)' + +# 3. 查看已执行的日志 +gitlink-cli ci +logs --build 42 --stage 1 --step 1 + +# 4. 根据需要重启构建 +gitlink-cli ci +restart --build 42 +``` + +## Common Scenarios + +常见使用场景: + +### 场景1:发现严重错误 +```bash +# 查看运行中的构建 +gitlink-cli ci +builds | grep "running" + +# 查看日志发现严重错误 +gitlink-cli ci +logs --build 42 --stage 2 --step 1 + +# 立即停止构建 +gitlink-cli ci +stop --build 42 +``` + +### 场景2:误触发构建 +```bash +# 发现误触发了构建 +gitlink-cli ci +builds | grep "running" + +# 立即停止误触发的构建 +gitlink-cli ci +stop --build 42 +``` + +### 场景3:配置错误 +```bash +# 发现构建配置错误 +gitlink-cli ci +logs --build 42 --stage 1 --step 1 + +# 停止当前构建 +gitlink-cli ci +stop --build 42 + +# 修复配置后重新构建 +# (修复配置) +gitlink-cli ci +restart --build 42 +``` + +## Team Collaboration + +团队协作时的建议: + +1. **及时通知**:停止构建前通知相关团队成员 +2. **说明原因**:向团队解释为什么需要停止构建 +3. **状态同步**:更新项目管理系统中的构建状态 +4. **后续计划**:告知团队停止后的处理计划 + +## Tips + +- 停止前建议先确认构建状态,避免重复操作 +- 查看构建日志可以帮助判断是否值得停止 +- 停止后可以考虑是否需要重启或修复后重新构建 +- 对于长时间运行的构建,定期检查状态可能更合适 + +## Alternatives + +替代方案考虑: + +| 情况 | 停止 | 等待完成 | 其他方案 | +|------|------|----------|----------| +| 严重错误 | ✅ 推荐 | ❌ 不推荐 | 修复后重启 | +| 临时问题 | ⚠️ 可选 | ✅ 推荐 | 等待自动恢复 | +| 配置错误 | ✅ 推荐 | ❌ 不推荐 | 修复配置后重启 | +| 时间过长 | ⚠️ 可选 | ✅ 推荐 | 优化构建流程 | + +## References + +- [ci +builds](ci-list.md) — 查看构建列表 +- [ci +logs](ci-logs.md) — 查看构建日志 +- [ci +restart](ci-restart.md) — 重启构建 +- [gitlink-ci](../SKILL.md) — CI/CD 操作总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-code-insight/SKILL.md b/skills/gitlink-code-insight/SKILL.md new file mode 100644 index 0000000..ab7a69c --- /dev/null +++ b/skills/gitlink-code-insight/SKILL.md @@ -0,0 +1,178 @@ +--- +name: gitlink-code-insight +version: 4.0.0 +description: "功能全景仪表盘:当用户想了解 gitlink-cli 有哪些功能时,生成交互式 HTML 页面并打开浏览器展示。" +metadata: + requires: + bins: ["python3"] +--- + +# gitlink-code-insight(功能全景仪表盘) + +## 触发条件 + +用户问以下问题时触发: +- "gitlink-cli 有哪些功能" +- "帮我生成一个功能展示页面" +- "我想浏览所有可用命令" + +## 执行步骤 + +1. 根据下方 Shortcuts 数据生成单文件 HTML +2. 写入 `doc/dashboard.html` +3. 用 `python3 -c "import webbrowser; webbrowser.open('file://$(pwd)/doc/dashboard.html')"` 打开 + +## 页面要求 + +- 暗色主题(GitHub Dark 风格) +- 顶部:标题 + 统计数字(分类数、Shortcuts 总数)+ 搜索框 +- 主体:分类卡片列表,每个卡片可折叠展开 + - 卡片标题:图标 + 分类名 + 命令计数 + - 展开后显示该分类下所有 shortcut 行 + - 每行:命令(等宽蓝色)+ 认证标签(绿色=需认证,蓝色=公开)+ 描述 + - 点击 shortcut 行展开代码示例 +- 搜索框实时过滤(按命令名和描述匹配),无匹配时隐藏整个分类 +- 纯 CSS + 原生 JS,无外部依赖 + +--- + +## Shortcuts 数据 + +### 一、仓库管理 📦 + +| 命令 | 描述 | 认证 | 示例 | +|------|------|------|------| +| `repo +list` | 仓库列表 | 否 | `gitlink-cli repo +list --user zhangsan` | +| `repo +info` | 仓库详情 | 否 | `gitlink-cli repo +info --owner Gitlink --repo forgeplus` | +| `repo +create` | 创建仓库 | 是 | `gitlink-cli repo +create --name my-project --description "项目描述"` | +| `repo +fork` | Fork 仓库 | 是 | `gitlink-cli repo +fork --owner Gitlink --repo forgeplus` | +| `repo +delete` | 删除仓库(不可逆) | 是 | `gitlink-cli repo +delete --owner myuser --repo old-project` | +| `repo +batch-create` | 批量创建仓库 | 是 | `gitlink-cli repo +batch-create --from repos.csv` | +| `repo +batch-update` | 批量更新仓库 | 是 | `gitlink-cli repo +batch-update --from updates.csv` | +| `repo +add-member` | 添加仓库成员 | 是 | `gitlink-cli repo +add-member --owner myuser --repo myrepo --user newmember --role developer` | + +### 二、分支管理 🌿 + +| 命令 | 描述 | 认证 | 示例 | +|------|------|------|------| +| `branch +list` | 分支列表 | 否 | `gitlink-cli branch +list --owner Gitlink --repo forgeplus` | +| `branch +create` | 创建分支 | 是 | `gitlink-cli branch +create --name feature/new-feature` | +| `branch +delete` | 删除分支(不可逆) | 是 | `gitlink-cli branch +delete --name feature/old-feature` | +| `branch +protect` | 保护分支 | 是 | `gitlink-cli branch +protect --name main` | +| `branch +unprotect` | 取消保护 | 是 | `gitlink-cli branch +unprotect --name main` | + +### 三、Issue 管理 🐛 + +| 命令 | 描述 | 认证 | 示例 | +|------|------|------|------| +| `issue +list` | Issue 列表 | 否 | `gitlink-cli issue +list --owner Gitlink --repo forgeplus --state open` | +| `issue +view` | Issue 详情 | 否 | `gitlink-cli issue +view --owner Gitlink --repo forgeplus --number 4` | +| `issue +create` | 创建 Issue | 是 | `gitlink-cli issue +create --owner myuser --repo myrepo --title "Bug: 登录失败" --body "复现步骤"` | +| `issue +update` | 更新 Issue | 是 | `gitlink-cli issue +update --number 4 --title "新标题" --body "更新描述"` | +| `issue +close` | 关闭 Issue | 是 | `gitlink-cli issue +close --number 4` | +| `issue +batch-close` | 批量关闭 Issue | 是 | `gitlink-cli issue +batch-close --numbers 123,124 --dry-run` | +| `issue +comment` | 添加评论 | 是 | `gitlink-cli issue +comment --number 4 --body "已修复"` | + +### 四、Pull Request 🔀 + +| 命令 | 描述 | 认证 | 示例 | +|------|------|------|------| +| `pr +list` | PR 列表 | 否 | `gitlink-cli pr +list --owner Gitlink --repo forgeplus --state open` | +| `pr +view` | PR 详情 | 否 | `gitlink-cli pr +view --id 3` | +| `pr +create` | 创建 PR | 是 | `gitlink-cli pr +create --title "feat: 新功能" --head feature/x --base master` | +| `pr +merge` | 合并 PR | 是 | `gitlink-cli pr +merge --id 3 --method squash` | +| `pr +close` | 关闭 PR | 是 | `gitlink-cli pr +close --id 3` | +| `pr +files` | 变更文件列表 | 否 | `gitlink-cli pr +files --id 3` | +| `pr +diff` | 查看提交列表 | 否 | `gitlink-cli pr +diff --id 3` | +| `pr +comment` | PR 评论 | 是 | `gitlink-cli pr +comment --id 3 --body "LGTM"` | +| `pr +review` | 代码审查 | 是 | `gitlink-cli pr +review --id 3 --event COMMENT --body "整体 LGTM"` | + +### 五、版本发布 🚀 + +| 命令 | 描述 | 认证 | 示例 | +|------|------|------|------| +| `release +list` | 发布列表 | 否 | `gitlink-cli release +list --owner Gitlink --repo forgeplus` | +| `release +view` | 发布详情 | 否 | `gitlink-cli release +view --id ` | +| `release +create` | 创建发布 | 是 | `gitlink-cli release +create --tag v1.0.0 --name "v1.0.0" --target master` | +| `release +delete` | 删除发布(不可逆) | 是 | `gitlink-cli release +delete --id ` | + +### 六、Wiki 管理 📖 + +| 命令 | 描述 | 认证 | 示例 | +|------|------|------|------| +| `wiki +list` | Wiki 页面列表 | 否 | `gitlink-cli wiki +list --owner Gitlink --repo forgeplus` | +| `wiki +view` | 查看页面内容 | 否 | `gitlink-cli wiki +view --owner Gitlink --repo forgeplus --title "Home"` | +| `wiki +create` | 创建页面 | 是 | `gitlink-cli wiki +create --owner myuser --repo myrepo --title "API 文档" --file ./api.md` | +| `wiki +update` | 更新页面 | 是 | `gitlink-cli wiki +update --owner myuser --repo myrepo --title "设计文档" --add "新内容"` | +| `wiki +delete` | 删除页面 | 是 | `gitlink-cli wiki +delete --owner myuser --repo myrepo --title "废弃页面"` | + +### 七、CI/CD ⚙️ + +| 命令 | 描述 | 认证 | 示例 | +|------|------|------|------| +| `ci +builds` | 构建列表 | 是 | `gitlink-cli ci +builds --owner myuser --repo myrepo` | +| `ci +logs` | 构建日志 | 是 | `gitlink-cli ci +logs --build 42 --stage 1 --step 1` | +| `ci +restart` | 重启构建 | 是 | `gitlink-cli ci +restart --build 42` | +| `ci +stop` | 停止构建 | 是 | `gitlink-cli ci +stop --build 42` | + +### 八、Webhook 🔔 + +| 命令 | 描述 | 认证 | 示例 | +|------|------|------|------| +| `webhook +list` | Webhook 列表 | 是 | `gitlink-cli webhook +list --owner myuser --repo myrepo` | +| `webhook +info` | Webhook 详情 | 是 | `gitlink-cli webhook +info --owner myuser --repo myrepo --id 123` | +| `webhook +events` | 支持的事件类型 | 否 | `gitlink-cli webhook +events` | +| `webhook +create` | 创建 Webhook | 是 | `gitlink-cli webhook +create --url https://example.com/hook --events push` | +| `webhook +update` | 更新 Webhook | 是 | `gitlink-cli webhook +update --id 123 --events push,pull_request` | +| `webhook +test` | 测试 Webhook | 是 | `gitlink-cli webhook +test --id 123 --event push` | +| `webhook +delete` | 删除 Webhook | 是 | `gitlink-cli webhook +delete --id 123` | + +### 九、组织管理 🏢 + +| 命令 | 描述 | 认证 | 示例 | +|------|------|------|------| +| `org +list` | 组织列表 | 否 | `gitlink-cli org +list` | +| `org +info` | 组织详情 | 否 | `gitlink-cli org +info --id Gitlink` | +| `org +members` | 成员列表 | 否 | `gitlink-cli org +members --id Gitlink` | +| `org +create` | 创建组织 | 是 | `gitlink-cli org +create --name my-org --description "我的组织"` | +| `org +batch-add` | 批量添加成员 | 是 | `gitlink-cli org +batch-add --id my-org --users "user1,user2"` | + +### 十、用户与搜索 👤 + +| 命令 | 描述 | 认证 | 示例 | +|------|------|------|------| +| `user +me` | 当前登录用户 | 是 | `gitlink-cli user +me` | +| `user +info` | 用户详情 | 否 | `gitlink-cli user +info --login zhangsan` | +| `search +repos` | 搜索仓库 | 否 | `gitlink-cli search +repos --keyword "machine learning"` | +| `search +users` | 搜索用户 | 否 | `gitlink-cli search +users --keyword "zhangsan"` | + +### 十一、安全与合规 🛡️ + +| 命令 | 描述 | 认证 | 示例 | +|------|------|------|------| +| `compliance +scan` | 全量扫描 | 否 | `gitlink-cli compliance +scan` | +| `compliance +license` | 许可证合规检查 | 否 | `gitlink-cli compliance +license` | +| `compliance +deps` | 依赖许可证检查 | 否 | `gitlink-cli compliance +deps` | +| `compliance +secrets` | 敏感信息扫描 | 否 | `gitlink-cli compliance +secrets` | +| `compliance +exposure` | PII 与暴露面扫描 | 否 | `gitlink-cli compliance +exposure` | +| `compliance +vocab` | 敏感词汇扫描 | 否 | `gitlink-cli compliance +vocab` | + +### 十二、新人引导 👋 + +| 命令 | 描述 | 认证 | 示例 | +|------|------|------|------| +| `onboard +welcome` | 添加引导评论 | 是 | `gitlink-cli onboard +welcome --issues "3,7,15"` | + +### 十三、团队管理 👥 + +| 命令 | 描述 | 认证 | 示例 | +|------|------|------|------| +| `team +list` | 团队列表 | 否 | `gitlink-cli team +list --org my-org` | +| `team +create` | 创建团队 | 是 | `gitlink-cli team +create --org my-org --name dev-team` | +| `team +add-member` | 添加成员 | 是 | `gitlink-cli team +add-member --org my-org --team dev-team --user newmember` | + +### 十四、贡献报告 📊 + +| 命令 | 描述 | 认证 | 示例 | +|------|------|------|------| +| `contrib +report` | 贡献统计报告 | 否 | `gitlink-cli contrib +report --owner myuser --repo myrepo` | diff --git a/skills/gitlink-code-review/README.md b/skills/gitlink-code-review/README.md new file mode 100644 index 0000000..bc33ac9 --- /dev/null +++ b/skills/gitlink-code-review/README.md @@ -0,0 +1,362 @@ +# gitlink-code-review - 智能代码审查 Skill + +[![GitLink](https://img.shields.io/badge/GitLink-gitlink--cli-green)](https://www.gitlink.org.cn/zzx-coder/gitlink-cli) +[![Skill Version](https://img.shields.io/badge/version-1.0.0-blue.svg)](SKILL.md) +[![AI Agent Ready](https://img.shields.io/badge/AI_Access-Ready-success.svg)](SKILL.md) + +欢迎使用 **gitlink-code-review** Skill!这是一个 AI 驱动的自动化代码审查工具,帮助开发者和 Reviewers 快速分析 GitLink PR 的代码质量。 + +## 🎯 功能特性 + +### 核心功能 + +- ✅ **自动代码分析**:获取 PR 的文件列表和 diff 内容 +- ✅ **多维度审查**:代码质量、安全性、性能、可维护性 +- ✅ **结构化报告**:生成 JSON/Markdown 格式的审查报告 +- ✅ **智能建议**:提供具体的代码修改建议 +- ✅ **自动评论**:将审查意见自动添加为 PR 评论 +- ✅ **AI 驱动**:基于 Claude 的代码理解能力 + +### 审查维度 + +| 维度 | 检查项 | 说明 | +|------|--------|------| +| **代码质量** | 复杂度、命名规范、注释完整性 | 确保代码清晰易读 | +| **安全性** | SQL 注入、XSS、敏感信息泄露 | 发现安全漏洞 | +| **性能** | 资源泄漏、循环效率、数据库查询 | 优化性能问题 | +| **可维护性** | 代码重复、职责单一、测试覆盖 | 提高代码可维护性 | + +## 🚀 快速开始 + +### 前置条件 + +1. **安装 gitlink-cli** + ```bash + npm install -g @gitlink-ai/cli + ``` + +2. **配置认证** + ```bash + gitlink-cli auth login + ``` + +3. **验证安装** + ```bash + gitlink-cli pr +list + ``` + +### 基础使用 + +#### 1. 获取 PR 信息 + +```bash +# 查看 PR 详情 +gitlink-cli pr +view --id 123 --format json + +# 获取变更文件列表 +gitlink-cli pr +files --id 123 --format json + +# 获取 diff 内容 +gitlink-cli pr +diff --id 123 --format json +``` + +#### 2. 进行代码审查 + +**AI Agent 方式**(推荐): + +``` +用户: "帮我审查 PR #123,检查代码质量、安全性和性能问题" + +AI Agent 将: +1. 获取 PR 的代码变更 +2. 分析代码质量和潜在问题 +3. 生成结构化的审查报告 +4. (可选)自动添加审查评论 +``` + +**手动方式**: + +```bash +# 获取 diff 并分析 +gitlink-cli pr +diff --id 123 --format json > pr_diff.json + +# 使用 AI 工具分析 pr_diff.json +# 生成审查报告 + +# (可选)添加评论到 PR +gitlink-cli api POST /:owner/:repo/pulls/123/reviews --body '{ + "body": "审查报告内容...", + "event": "COMMENT" +}' +``` + +### 完整工作流示例 + +详见 [`examples/comprehensive-review-workflow.md`](examples/comprehensive-review-workflow.md) + +## 📊 审查报告示例 + +### 简化版报告 + +```markdown +# 代码审查报告 + +## 总体评分: 85/100 ⭐⭐⭐⭐ + +## 🔴 高优先级问题(2) + +1. **敏感信息泄露** - `src/auth/login.go:45` + - 硬编码的密钥不应出现在代码中 + - 建议:使用环境变量存储密钥 + +2. **资源泄漏** - `src/auth/login.go:78` + - 数据库连接未关闭 + - 建议:使用 defer 确保连接关闭 + +## ⭐ 优秀实践(1) + +1. **优秀的错误处理** - `src/auth/user.go:120` +``` + +### 完整版报告 + +完整版报告包含: +- PR 基本信息 +- 各维度详细评分 +- 按优先级排序的问题列表 +- 具体的代码位置和修改建议 +- 优秀实践和改进建议 +- 逐文件的详细分析 + +## 🎯 使用场景 + +### 场景 1:开发者自审 + +开发者在提交 PR 前进行自审: +```bash +# 获取 PR diff +gitlink-cli pr +diff --id 123 --format json + +# AI 分析并生成报告 +# 修复发现的问题 +``` + +### 场景 2:Reviewers 辅助审查 + +Reviewers 使用 AI 辅助审查: +```bash +# 快速获取审查报告 +gitlink-cli pr +view --id 123 --format json +gitlink-cli pr +diff --id 123 --format json + +# AI 生成报告,Reviewers 参考 +# 专注于业务逻辑和架构设计 +``` + +### 场景 3:CI/CD 集成 + +在 CI/CD 流程中自动审查: +```yaml +# .gitlab-ci.yml +code_review: + script: + - gitlink-cli pr +diff --id $MR_ID --format json + - ai-code-review --input pr_diff.json --output report.json + - check-score --min 70 report.json +``` + +### 场景 4:新贡献者指导 + +为新贡献者的 PR 提供详细指导: +```bash +# 全面审查新贡献者的 PR +# 提供详细的代码指导 +# 帮助改进代码质量 +``` + +## 📚 文档导航 + +- **[SKILL.md](SKILL.md)** - 技能总览和完整功能说明 +- **[REFERENCE.md](REFERENCE.md)** - API 详细参考 +- **[references/](references/)** - 详细操作指南 +- **[examples/](examples/)** - 工作流示例 + +### 快速查找 + +- **我想了解基本用法**: [SKILL.md](SKILL.md#使用方式) +- **我想看审查维度**: [SKILL.md](SKILL.md#审查维度) +- **我想看工作流示例**: [examples/comprehensive-review-workflow.md](examples/comprehensive-review-workflow.md) +- **我想了解 API 细节**: [REFERENCE.md](REFERENCE.md) + +## 🤖 AI Agent 使用 + +Claude Code 和其他 AI Agent 可以直接使用此 Skill: + +``` +用户请求: "审查 PR #123" + ↓ +AI 读取 gitlink-code-review/SKILL.md + ↓ +AI 执行: + 1. gitlink-cli pr +view --id 123 --format json + 2. gitlink-cli pr +files --id 123 --format json + 3. gitlink-cli pr +diff --id 123 --format json + ↓ +AI 分析代码变更 + ↓ +AI 生成结构化审查报告 + ↓ +AI (可选)添加评论到 PR + ↓ +完成! +``` + +### 典型 AI 对话 + +**用户**: "帮我审查一下 PR #123,重点关注安全性问题" + +**AI Agent**: +``` +我来帮您审查 PR #123,重点关注安全性问题。 + +[获取 PR 信息...] +[分析代码变更...] + +发现以下安全问题: + +🔴 高优先级(1): +1. SQL 注入风险 - src/auth/login.go:45 + - 直接拼接用户输入到 SQL 语句 + - 建议:使用参数化查询 + +⚠️ 中优先级(1): +1. 缺少输入验证 - src/auth/login.go:30 + - 未验证用户名长度和格式 + - 建议:添加输入验证中间件 + +总体评分:70/100 +安全性评分:60/100 ⚠️ + +需要我详细说明修复方法吗? +``` + +## 🎓 最佳实践 + +### 审查时机 + +1. **PR 创建后**:立即进行初步审查 +2. **PR 更新后**:审查新增的代码变更 +3. **合并前**:最终审查确认代码质量 + +### 审查重点 + +根据 PR 类型调整审查重点: +- **功能 PR**:代码质量 + 可维护性 +- **Bug 修复**:修复完整性 + 测试覆盖 +- **重构 PR**:性能改进 + 代码简化 +- **文档 PR**:文档完整性 + 准确性 + +### 评论规范 + +- ✅ **建设性**:提供具体的修改建议 +- ✅ **礼貌友好**:使用积极的语言 +- ✅ **解释原因**:说明为什么需要修改 +- ✅ **认可优点**:指出优秀实践 + +### 自动化审查 + +配置 CI/CD 自动审查: +```yaml +# 合并门禁示例 +if (review_score < 70) { + block_merge("代码审查评分低于 70 分") +} +if (high_priority_issues > 0) { + block_merge("存在高优先级问题") +} +``` + +## 📊 质量标准 + +### 审查评分体系 + +| 分数范围 | 等级 | 说明 | +|---------|------|------| +| 90-100 | ⭐⭐⭐⭐⭐ 优秀 | 代码质量高,可以直接合并 | +| 75-89 | ⭐⭐⭐⭐ 良好 | 代码质量良好,小幅改进后可合并 | +| 60-74 | ⭐⭐⭐ 一般 | 存在一些问题,建议改进后合并 | +| < 60 | ⭐⭐ 较差 | 存在严重问题,必须修复 | + +### 问题优先级 + +| 优先级 | 图标 | 说明 | 是否阻止合并 | +|--------|------|------|--------------| +| HIGH | 🔴 | 安全漏洞、严重性能问题 | 是 | +| MEDIUM | ⚠️ | 代码质量问题、潜在风险 | 建议 | +| LOW | ℹ️ | 代码风格、轻微改进 | 否 | + +## ❓ 常见问题 + +### Q: 如何提高审查准确性? + +**A**: +1. 提供完整的 diff 内容 +2. 根据项目类型调整审查规则 +3. 结合项目上下文分析 +4. 定期更新审查规则 + +### Q: 如何处理误报? + +**A**: +1. AI 审查可能产生误报,需要人工验证 +2. 可以配置白名单忽略特定规则 +3. 提供反馈改进审查规则 + +### Q: 审查报告可以作为合并条件吗? + +**A**: +1. 可以将审查评分设置为合并门禁 +2. 建议设置最低评分(如 70 分) +3. 高优先级问题必须修复后才能合并 + +### Q: 如何集成到 CI/CD? + +**A**: +参考 [`examples/ci-integration.md`](examples/ci-integration.md) 中的配置示例 + +## 🔗 相关资源 + +- [gitlink-cli 主项目](https://www.gitlink.org.cn/zzx-coder/gitlink-cli) +- [gitlink-pr Skill](../gitlink-pr/SKILL.md) - PR 操作指南 +- [gitlink-workflow Skill](../gitlink-workflow/SKILL.md) - AI 工作流 +- [代码审查最佳实践](https://google.github.io/eng-practices/review/) + +## 📈 更新日志 + +### v1.0.0 (2026-06-12) + +- ✅ 初始版本发布 +- ✅ 支持代码质量、安全性、性能、可维护性审查 +- ✅ 生成结构化审查报告 +- ✅ AI Agent 集成 +- ✅ 完整文档和示例 + +## 🤝 贡献 + +欢迎贡献!如果你有改进建议或发现问题,请: + +1. 创建 Issue 描述问题或建议 +2. 提交 Pull Request 改进 Skill +3. 分享你的使用经验 + +## 📞 获取帮助 + +- **查看文档**: [SKILL.md](SKILL.md) +- **查看示例**: [examples/](examples/) +- **提交问题**: [GitLink Issues](https://www.gitlink.org.cn/zzx-coder/gitlink-cli/issues) + +--- + +**祝你审查愉快!🚀** + +如有问题,请查看 [SKILL.md](SKILL.md) 或 [examples/](examples/) 中的详细示例。 diff --git a/skills/gitlink-code-review/REFERENCE.md b/skills/gitlink-code-review/REFERENCE.md new file mode 100644 index 0000000..37380d7 --- /dev/null +++ b/skills/gitlink-code-review/REFERENCE.md @@ -0,0 +1,579 @@ +# gitlink-code-review API 参考文档 + +本文档提供 gitlink-code-review Skill 的详细 API 参考和参数说明。 + +## 📋 目录 + +- [PR 信息获取 API](#pr-信息获取-api) +- [代码分析 API](#代码分析-api) +- [审查报告生成 API](#审查报告生成-api) +- [评论集成 API](#评论集成-api) +- [错误处理](#错误处理) +- [数据格式](#数据格式) + +--- + +## PR 信息获取 API + +### 1. 获取 PR 详情 + +**命令**: +```bash +gitlink-cli pr +view --id --format json +``` + +**参数**: +- `--id` (必需): PR 编号 +- `--owner`: 仓库所有者(可选,自动从 git remote 解析) +- `--repo`: 仓库名称(可选,自动从 git remote 解析) +- `--format`: 输出格式(json/table/yaml) + +**返回格式**: +```json +{ + "ok": true, + "data": { + "id": 123, + "project_issues_index": 123, + "title": "Feature: Add user authentication", + "body": "This PR adds user authentication...", + "author": { + "login": "developer", + "user_id": 456 + }, + "status": "open", + "pull_request_status": 0, + "head": "feature/auth", + "base": "main", + "created_at": "2026-06-12T10:00:00Z", + "updated_at": "2026-06-12T10:30:00Z" + }, + "meta": { + "identity": "user:developer" + } +} +``` + +**字段说明**: +- `id`: PR 数据库 ID +- `project_issues_index`: PR 编号(网页 URL 中显示) +- `pull_request_status`: PR 状态(0=open, 1=merged, 2=closed) + +### 2. 获取变更文件列表 + +**命令**: +```bash +gitlink-cli pr +files --id --format json +``` + +**返回格式**: +```json +{ + "ok": true, + "data": { + "files": [ + { + "filename": "src/auth/login.go", + "status": "modified", + "additions": 50, + "deletions": 20, + "changes": 70, + "patch": "@@ -1,10 +1,15 @@\n+func login() {" + } + ] + } +} +``` + +**字段说明**: +- `status`: 文件状态(added/modified/deleted/renamed) +- `additions`: 新增行数 +- `deletions`: 删除行数 +- `changes`: 总变更行数 +- `patch`: diff 片段 + +### 3. 获取 diff 内容 + +**命令**: +```bash +gitlink-cli pr +diff --id --format json +``` + +**返回格式**: +```json +{ + "ok": true, + "data": { + "diff": "diff --git a/src/auth/login.go b/src/auth/login.go\n@@ -1,10 +1,15 @@\n+func login() {", + "files_count": 5, + "additions": 150, + "deletions": 50 + } +} +``` + +--- + +## 代码分析 API + +代码分析由 AI Agent 执行,使用 Claude 的代码理解能力。 + +### 分析流程 + +1. **解析 diff 内容** +2. **识别变更的代码块** +3. **多维度分析代码** +4. **生成结构化报告** + +### 分析维度 + +#### 1. 代码质量分析 + +**检查项**: +- 圈复杂度(Cyclomatic Complexity) +- 函数长度 +- 嵌套层级 +- 命名规范 +- 注释完整性 + +**输出示例**: +```json +{ + "quality_analysis": { + "overall_score": 90, + "complexity": { + "avg_cyclomatic_complexity": 3.5, + "max_function_length": 50, + "max_nesting_level": 3 + }, + "naming": { + "score": 95, + "issues": [] + }, + "comments": { + "score": 85, + "coverage": 75 + } + } +} +``` + +#### 2. 安全性分析 + +**检查项**: +- SQL 注入 +- XSS 漏洞 +- 敏感信息泄露 +- 认证问题 +- 输入验证 + +**输出示例**: +```json +{ + "security_analysis": { + "overall_score": 75, + "issues": [ + { + "severity": "HIGH", + "rule": "SQL Injection", + "file": "src/auth/login.go", + "line": 45, + "description": "直接拼接用户输入到 SQL 语句", + "code": "query := \"SELECT * FROM users WHERE username = '\" + username + \"'\"", + "suggestion": "使用参数化查询或 ORM" + } + ] + } +} +``` + +#### 3. 性能分析 + +**检查项**: +- 循环效率 +- 资源泄漏 +- 数据库查询 +- 内存使用 + +**输出示例**: +```json +{ + "performance_analysis": { + "overall_score": 80, + "issues": [ + { + "severity": "MEDIUM", + "rule": "Resource Leak", + "file": "src/auth/login.go", + "line": 78, + "description": "数据库连接未关闭", + "code": "db, _ := sql.Open(\"mysql\", dsn)", + "suggestion": "使用 defer db.Close()" + } + ] + } +} +``` + +#### 4. 可维护性分析 + +**检查项**: +- 代码重复 +- 职责单一 +- 依赖耦合 +- 测试覆盖 + +**输出示例**: +```json +{ + "maintainability_analysis": { + "overall_score": 85, + "duplicate_code_rate": 5, + "test_coverage": 60, + "recommendations": [ + "建议添加单元测试覆盖登录逻辑" + ] + } +} +``` + +--- + +## 审查报告生成 API + +### JSON 格式报告 + +**结构**: +```json +{ + "pr_info": { + "id": 123, + "title": "Feature: Add user authentication", + "author": "developer", + "files_changed": 5, + "lines_added": 150, + "lines_removed": 50 + }, + "analysis_timestamp": "2026-06-12T10:30:00Z", + "overall_assessment": { + "total_score": 85, + "quality_score": 90, + "security_score": 75, + "performance_score": 80, + "maintainability_score": 85, + "status": "APPROVED_WITH_CHANGES" + }, + "issues": [ + { + "id": 1, + "file": "src/auth/login.go", + "line": 45, + "severity": "HIGH", + "category": "security", + "rule": "SQL Injection", + "description": "直接拼接用户输入到 SQL 语句", + "code_snippet": "query := \"SELECT * FROM users WHERE username = '\" + username + \"'\"", + "suggestion": "使用参数化查询或 ORM", + "references": [ + "https://owasp.org/www-community/attacks/SQL_Injection" + ] + } + ], + "positive_notes": [ + { + "file": "src/auth/user.go", + "line": 120, + "description": "优秀的错误处理", + "code_snippet": "if err != nil {\n log.Errorf(\"Failed to login: %v\", err)\n return err\n}" + } + ], + "recommendations": [ + "建议添加单元测试覆盖登录逻辑", + "建议使用参数化查询防止 SQL 注入", + "建议添加输入验证中间件" + ], + "summary": "代码整体质量良好,但存在几个需要修复的安全问题。建议修复高优先级问题后合并。" +} +``` + +### Markdown 格式报告 + +**模板**: +```markdown +# 代码审查报告 + +## PR 信息 +- **PR ID**: 123 +- **标题**: Feature: Add user authentication +- **作者**: @developer +- **分支**: feature/auth → main +- **变更**: 5 个文件,+150 / -50 行 + +## 总体评分: 85/100 ⭐⭐⭐⭐ + +### 评分详情 +- 代码质量: 90/100 +- 安全性: 75/100 ⚠️ +- 性能: 80/100 +- 可维护性: 85/100 + +## 问题列表 + +### 🔴 高优先级(2) + +#### 1. SQL 注入风险 +- **文件**: `src/auth/login.go:45` +- **类别**: security +- **问题**: 直接拼接用户输入到 SQL 语句 +- **代码**: + ```go + query := "SELECT * FROM users WHERE username = '" + username + "'" + ``` +- **建议**: 使用参数化查询或 ORM + +#### 2. 资源泄漏 +- **文件**: `src/auth/login.go:78` +- **类别**: performance +- **问题**: 数据库连接未关闭 +- **代码**: + ```go + db, _ := sql.Open("mysql", dsn) + // 缺少 defer db.Close() + ``` +- **建议**: 使用 `defer db.Close()` + +### ⚠️ 中优先级(1) + +#### 1. 缺少输入验证 +- **文件**: `src/auth/login.go:30` +- **类别**: security +- **问题**: 未验证用户名长度和格式 +- **建议**: 添加输入验证中间件 + +## ⭐ 优秀实践(1) + +### 1. 优秀的错误处理 +- **文件**: `src/auth/user.go:120` +- **描述**: 完善的错误处理和日志记录 + +## 💡 改进建议 + +1. 建议添加单元测试覆盖登录逻辑 +2. 建议使用参数化查询防止 SQL 注入 +3. 建议添加输入验证中间件 +4. 建议添加代码注释说明复杂逻辑 + +## 📊 文件详情 + +### src/auth/login.go +- **变更**: +50 / -20 行 +- **问题**: 3 个(1 个高优先级,2 个中优先级) +- **建议**: 修复安全问题,添加输入验证 + +### src/auth/user.go +- **变更**: +80 / -10 行 +- **问题**: 1 个中优先级 +- **优秀实践**: 1 个 + +## 📝 总结 + +代码整体质量良好,结构清晰,命名规范。但存在几个需要修复的安全问题,特别是 SQL 注入风险。建议修复高优先级问题后合并。 + +**审查结果**: ✅ 建议修改后合并 + +--- +*报告生成时间: 2026-06-12 10:30:00 UTC* +*审查工具: gitlink-code-review v1.0.0* +``` + +--- + +## 评论集成 API + +### 添加总评 + +**命令**: +```bash +gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{ + "body": "<审查报告内容>", + "event": "COMMENT" +}' +``` + +**参数**: +- `:owner`: 仓库所有者 +- `:repo`: 仓库名称 +- `:id`: PR 编号 +- `body`: 评论内容(Markdown 格式) +- `event`: 事件类型(COMMENT/APPROVE/REQUEST_CHANGES) + +**事件类型**: +- `COMMENT`: 普通评论 +- `APPROVE`: 批准 PR +- `REQUEST_CHANGES`: 请求修改 + +### 添加行内评论 + +**命令**: +```bash +gitlink-cli api POST /:owner/:repo/pulls/:id/comments --body '{ + "body": "建议使用参数化查询", + "commit_id": "", + "path": "src/auth/login.go", + "position": 45 +}' +``` + +**参数**: +- `commit_id`: 提交 SHA +- `path`: 文件路径 +- `position`: 行号 +- `body`: 评论内容 + +### 批量添加评论 + +**脚本示例**: +```bash +#!/bin/bash +# 批量添加审查评论 + +PR_ID=123 +OWNER="myuser" +REPO="myrepo" + +# 读取审查报告中的问题 +issues=$(jq -r '.issues[]' review.json) + +# 逐个添加评论 +for issue in $issues; do + file=$(echo $issue | jq -r '.file') + line=$(echo $issue | jq -r '.line') + suggestion=$(echo $issue | jq -r '.suggestion') + + gitlink-cli api POST /$OWNER/$REPO/pulls/$PR_ID/comments --body "{ + \"body\": \"$suggestion\", + \"path\": \"$file\", + \"position\": $line + }" +done +``` + +--- + +## 错误处理 + +### 常见错误 + +#### 1. PR 不存在 + +**错误信息**: +```json +{ + "ok": false, + "error": { + "code": 404, + "message": "PR not found", + "suggestion": "检查 PR 编号是否正确" + } +} +``` + +**处理方法**: +- 检查 PR 编号是否正确 +- 确认 PR 是否在正确的仓库中 +- 使用 `gitlink-cli pr +list` 验证 PR 存在 + +#### 2. 权限不足 + +**错误信息**: +```json +{ + "ok": false, + "error": { + "code": 403, + "message": "Permission denied", + "suggestion": "确认账号有此仓库的访问权限" + } +} +``` + +**处理方法**: +- 确认账号有仓库访问权限 +- 私有仓库需要先认证 +- 运行 `gitlink-cli auth login` 重新登录 + +#### 3. 未认证 + +**错误信息**: +```json +{ + "ok": false, + "error": { + "code": 401, + "message": "Unauthorized", + "suggestion": "运行 gitlink-cli auth login 登录" + } +} +``` + +**处理方法**: +- 运行 `gitlink-cli auth login` 登录 +- 或设置 `GITLINK_TOKEN` 环境变量 + +### 错误处理最佳实践 + +1. **检查 PR 状态**: 在审查前确认 PR 存在且可访问 +2. **验证权限**: 确认账号有仓库访问权限 +3. **处理网络错误**: 重试失败的请求 +4. **记录错误**: 记录错误日志以便调试 + +--- + +## 数据格式 + +### PR 状态映射 + +| 状态码 | 状态名称 | 说明 | +|--------|---------|------| +| 0 | open | 开放中 | +| 1 | merged | 已合并 | +| 2 | closed | 已关闭 | + +### 严重性级别 + +| 级别 | 图标 | 说明 | 是否阻止合并 | +|------|------|------|--------------| +| CRITICAL | 🚨 | 严重问题,必须立即修复 | 是 | +| HIGH | 🔴 | 高优先级,建议尽快修复 | 是 | +| MEDIUM | ⚠️ | 中优先级,建议修复 | 建议 | +| LOW | ℹ️ | 低优先级,可选修复 | 否 | +| INFO | 💡 | 信息性建议 | 否 | + +### 审查结果状态 + +| 状态 | 说明 | 是否可合并 | +|------|------|-----------| +| APPROVED | 批准,可直接合并 | 是 | +| APPROVED_WITH_CHANGES | 批准,但建议修改 | 是 | +| CHANGES_REQUESTED | 请求修改,需修复后重新审查 | 否 | +| COMMENTED | 仅评论,未给出审批意见 | 待定 | + +--- + +## 🔗 相关资源 + +- [gitlink-pr/SKILL.md](../gitlink-pr/SKILL.md) - PR 操作指南 +- [gitlink-shared/SKILL.md](../gitlink-shared/SKILL.md) - 认证和全局参数 +- [GitLink API 文档](https://www.gitlink.org.cn/api/docs) - 完整 API 参考 + +--- + +## 📞 获取帮助 + +- **命令帮助**: `gitlink-cli pr --help` +- **故障排查**: [../gitlink-shared/TROUBLESHOOTING.md](../gitlink-shared/TROUBLESHOOTING.md) +- **API 参考**: [GitLink API 文档](https://www.gitlink.org.cn/api/docs) + +--- + +*最后更新: 2026-06-12* diff --git a/skills/gitlink-code-review/SKILL.md b/skills/gitlink-code-review/SKILL.md new file mode 100644 index 0000000..371e7a4 --- /dev/null +++ b/skills/gitlink-code-review/SKILL.md @@ -0,0 +1,284 @@ +--- +name: gitlink-code-review +version: 1.0.0 +description: "智能代码审查:自动分析 PR 代码变更,进行多维度代码质量检查,生成结构化审查报告并自动添加评论。当用户需要对 GitLink PR 进行代码审查时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli pr --help" +--- + +# gitlink-code-review(智能代码审查) + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 和 [`../gitlink-pr/SKILL.md`](../gitlink-pr/SKILL.md) + +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** + +本技能提供 AI 驱动的自动化代码审查功能,帮助开发者和 Reviewers 快速分析 PR 代码质量。 + +## 🎯 核心功能 + +| 功能 | 说明 | 需要认证 | +|------|------|----------| +| `代码变更分析` | 获取 PR 的文件列表和 diff 内容 | 否(公开项目) | +| `代码质量检查` | 检查代码复杂度、命名规范、注释完整性 | 否 | +| `安全性检查` | 检查 SQL 注入、XSS、敏感信息泄露等 | 否 | +| `性能检查` | 识别性能反模式和资源泄漏 | 否 | +| `可维护性检查` | 检查代码重复和职责单一原则 | 否 | +| `审查报告生成` | 生成结构化的审查报告(JSON/Markdown) | 否 | +| `自动评论` | 将审查意见自动添加为 PR 评论 | 是 | + +## 📊 审查维度 + +### 1. 代码质量(Code Quality) + +检查项: +- **代码复杂度**:圈复杂度、嵌套层级、函数长度 +- **命名规范**:变量/函数/类的命名是否清晰 +- **注释完整性**:复杂逻辑是否有注释说明 +- **代码格式**:缩进、空行、代码组织 + +### 2. 安全性(Security) + +检查项: +- **SQL 注入**:字符串拼接 SQL 语句 +- **XSS 漏洞**:未转义的用户输入输出 +- **敏感信息**:硬编码的密码/密钥/Token +- **认证问题**:权限检查、会话管理 +- **输入验证**:用户输入是否充分验证 + +### 3. 性能(Performance) + +检查项: +- **循环效率**:嵌套循环、大循环中的重复计算 +- **资源泄漏**:未关闭的连接/文件/流 +- **数据库查询**:N+1 查询、缺少索引 +- **内存使用**:大对象复制、内存泄漏 + +### 4. 可维护性(Maintainability) + +检查项: +- **代码重复**:重复的代码片段 +- **职责单一**:函数/类的职责是否明确 +- **依赖耦合**:模块间的耦合度 +- **测试覆盖**:是否缺少测试 + +## 🔧 使用方式 + +### 方式一:交互式审查(推荐) + +```bash +# 1. 获取 PR 详情 +gitlink-cli pr +view --id --format json + +# 2. 获取变更文件列表 +gitlink-cli pr +files --id --format json + +# 3. 获取 diff 内容 +gitlink-cli pr +diff --id --format json + +# 4. AI 分析代码并生成审查报告(手动或自动) +# 5. (可选)添加审查评论 +gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"审查意见...","event":"COMMENT"}' +``` + +### 方式二:完整审查工作流 + +详见 [`examples/comprehensive-review-workflow.md`](examples/comprehensive-review-workflow.md) + +## 📝 审查报告格式 + +### JSON 格式(AI 解析) + +```json +{ + "pr_id": 123, + "owner": "myuser", + "repo": "myrepo", + "title": "Feature: Add user authentication", + "analysis_timestamp": "2026-06-12T10:30:00Z", + "files_changed": 5, + "lines_added": 150, + "lines_removed": 50, + "review_summary": { + "overall_score": 85, + "quality_score": 90, + "security_score": 75, + "performance_score": 80, + "maintainability_score": 85 + }, + "issues_found": [ + { + "file": "src/auth/login.go", + "line": 45, + "severity": "HIGH", + "category": "security", + "rule": "敏感信息泄露", + "description": "硬编码的密钥不应出现在代码中", + "suggestion": "使用环境变量或配置文件存储密钥" + }, + { + "file": "src/auth/login.go", + "line": 78, + "severity": "MEDIUM", + "category": "performance", + "rule": "资源泄漏", + "description": "数据库连接未关闭", + "suggestion": "使用 defer 确保连接关闭" + } + ], + "positive_notes": [ + { + "file": "src/auth/user.go", + "line": "120, + "description": "优秀的错误处理" + } + ], + "recommendations": [ + "建议添加单元测试覆盖登录逻辑", + "建议使用参数化查询防止 SQL 注入" + ] +} +``` + +### Markdown 格式(人类阅读) + +```markdown +# 代码审查报告 + +## PR 信息 +- **PR ID**: 123 +- **标题**: Feature: Add user authentication +- **作者**: @developer +- **变更文件**: 5 个文件 +- **代码行**: +150 / -50 + +## 总体评分: 85/100 ⭐⭐⭐⭐ + +- 代码质量: 90/100 +- 安全性: 75/100 ⚠️ +- 性能: 80/100 +- 可维护性: 85/100 + +## 🔴 高优先级问题(2) + +### 1. 敏感信息泄露 +- **文件**: `src/auth/login.go:45` +- **类别**: security +- **问题**: 硬编码的密钥不应出现在代码中 +- **建议**: 使用环境变量或配置文件存储密钥 + +### 2. 资源泄漏 +- **文件**: `src/auth/login.go:78` +- **类别**: performance +- **问题**: 数据库连接未关闭 +- **建议**: 使用 defer 确保连接关闭 + +## ⭐ 优秀实践(1) + +### 1. 优秀的错误处理 +- **文件**: `src/auth/user.go:120` +- **描述**: 完善的错误处理和日志记录 + +## 💡 改进建议 + +1. 建议添加单元测试覆盖登录逻辑 +2. 建议使用参数化查询防止 SQL 注入 +3. 建议添加输入验证中间件 + +## 📊 详细分析 + +[详细的逐文件分析...] +``` + +## 🤖 AI Agent 使用 + +AI Agent 可以通过以下步骤自动审查 PR: + +1. **获取 PR 信息** + ```bash + gitlink-cli pr +view --id --format json + ``` + +2. **获取代码变更** + ```bash + gitlink-cli pr +files --id --format json + gitlink-cli pr +diff --id --format json + ``` + +3. **AI 分析代码**(Claude 分析 diff 内容) + +4. **生成审查报告**(结构化 JSON/Markdown) + +5. **(可选)添加评论** + ```bash + gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{ + "body": "<审查报告内容>", + "event": "COMMENT" + }' + ``` + +## 🎯 最佳实践 + +### 审查时机 + +- **PR 创建后**:立即进行初步审查,快速发现问题 +- **PR 更新后**:审查新增的代码变更 +- **合并前**:最终审查确认代码质量 + +### 审查重点 + +根据 PR 类型调整审查重点: +- **功能 PR**:关注代码质量和可维护性 +- **Bug 修复 PR**:关注修复是否完整、测试是否充分 +- **重构 PR**:关注性能改进和代码简化 +- **文档 PR**:关注文档完整性和准确性 + +### 评论规范 + +- **建设性**:提供具体的修改建议,而非仅指出问题 +- **礼貌友好**:使用积极的语言,避免负面批评 +- **解释原因**:说明为什么需要修改,帮助开发者理解 +- **认可优点**:及时指出代码中的优秀实践 + +### 自动化审查 + +可以配置 CI/CD 流程自动触发代码审查: +- PR 创建时自动审查 +- 审查失败时阻止合并 +- 审查通过后允许人工审查 + +## 📚 相关文档 + +- [PR 基础操作](../gitlink-pr/SKILL.md) +- [详细操作参考](references/) +- [工作流示例](examples/) + +## ❓ 常见问题 + +### Q: 如何提高审查的准确性? + +A: +1. 提供完整的 diff 内容,而非仅文件列表 +2. 根据项目类型调整审查规则(如前端/后端/移动端) +3. 结合项目上下文进行分析(如代码规范文档) + +### Q: 如何处理误报? + +A: +1. AI 审查可能产生误报,需要人工验证 +2. 可以配置白名单忽略特定规则 +3. 提供反馈改进审查规则 + +### Q: 审查报告是否可以作为合并条件? + +A: +1. 可以将审查评分设置为合并门禁 +2. 建议设置最低评分要求(如 70 分以上) +3. 高优先级问题必须修复后才能合并 + +## 🔗 参考资源 + +- [gitlink-pr/SKILL.md](../gitlink-pr/SKILL.md) - PR 操作指南 +- [gitlink-workflow/SKILL.md](../gitlink-workflow/SKILL.md) - AI 工作流 +- [代码审查最佳实践](https://google.github.io/eng-practices/review/) - Google 代码审查指南 diff --git a/skills/gitlink-code-review/examples/auto-review-pr.md b/skills/gitlink-code-review/examples/auto-review-pr.md new file mode 100644 index 0000000..e957585 --- /dev/null +++ b/skills/gitlink-code-review/examples/auto-review-pr.md @@ -0,0 +1,420 @@ +# 自动审查 PR 工作流 + +本文档展示如何使用 AI Agent 自动审查 PR,包括完整的自动化流程。 + +## 📋 场景描述 + +**场景**: 使用 Claude Code 等 AI Agent 自动审查 PR,从获取代码变更到生成审查报告,全程自动化。 + +**目标**: +1. AI 自动获取 PR 代码变更 +2. AI 自动分析代码质量和安全问题 +3. AI 自动生成结构化审查报告 +4. AI 自动添加审查评论到 PR + +## 🤖 AI Agent 工作流程 + +``` +用户请求: "帮我审查 PR #123" + ↓ +AI Agent 执行 + ↓ +1. 获取 PR 基本信息 + ├─ pr +view --id 123 --format json + └─ 解析 PR 详情 + ↓ +2. 获取代码变更 + ├─ pr +files --id 123 --format json + ├─ pr +diff --id 123 --format json + └─ 解析代码变更 + ↓ +3. AI 代码分析 + ├─ 分析代码质量 + ├─ 分析安全性 + ├─ 分析性能 + └─ 分析可维护性 + ↓ +4. 生成审查报告 + ├─ 汇总问题 + ├─ 按优先级排序 + └─ 生成建议 + ↓ +5. 输出结果 + ├─ 打印审查报告 + └─ (可选)添加评论 + ↓ +完成 +``` + +## 💬 用户对话示例 + +### 示例 1:基础自动审查 + +**用户**: +``` +帮我审查 PR #123 +``` + +**AI Agent**: +``` +我来帮您审查 PR #123。 + +首先让我获取 PR 的基本信息... +[执行: gitlink-cli pr +view --id 123 --format json] + +获取变更文件列表... +[执行: gitlink-cli pr +files --id 123 --format json] + +获取代码变更详情... +[执行: gitlink-cli pr +diff --id 123 --format json] + +现在分析代码质量和潜在问题... + +## 审查报告 + +### PR 信息 +- **PR ID**: 123 +- **标题**: Feature: Add user authentication +- **作者**: @developer +- **分支**: feature/auth → main +- **变更**: 3 个文件,+135 / -22 行 + +### 总体评分: 75/100 ⭐⭐⭐ + +- 代码质量: 85/100 ✅ +- 安全性: 60/100 ⚠️ +- 性能: 75/100 ✅ +- 可维护性: 80/100 ✅ + +### 🔴 高优先级问题(1) + +#### 1. SQL 注入漏洞 +- **文件**: `src/auth/login.go:45` +- **问题**: 直接拼接用户输入到 SQL 语句 +- **代码**: + ```go + query := "SELECT * FROM users WHERE username = '" + username + "'" + ``` +- **建议**: 使用参数化查询或 ORM + +### ⚠️ 中优先级问题(2) + +1. **缺少输入验证** - `src/auth/login.go:30` + - 未验证用户名长度和格式 + +2. **资源泄漏** - `src/auth/login.go:78` + - 数据库连接未关闭 + +### ⭐ 优秀实践(1) + +1. **优秀的错误处理** - `src/auth/user.go:120` + +### 💡 改进建议 + +1. 修复 SQL 注入漏洞(必须) +2. 添加输入验证(必须) +3. 修复资源泄漏(建议) +4. 完善单元测试(建议) + +### 📝 总结 + +代码整体质量良好,但存在 1 个必须修复的安全问题。 +建议修复后再合并。 + +**审查结果**: ⚠️ 建议修改后合并 + +需要我添加这些审查意见到 PR 吗? +``` + +### 示例 2:重点审查安全性 + +**用户**: +``` +审查 PR #456,重点关注安全性问题 +``` + +**AI Agent**: +``` +我来重点审查 PR #456 的安全性问题。 + +[获取 PR 信息...] +[分析代码变更...] + +## 安全性审查报告 + +### 总体评分: 60/100 ⚠️ + +### 🔴 高危漏洞(2) + +#### 1. SQL 注入漏洞 +- **文件**: `src/api/users.go:45` +- **风险**: 攻击者可以篡改数据库查询 +- **代码**: + ```go + query := "SELECT * FROM users WHERE id = " + userID + ``` +- **修复**: + ```go + query := "SELECT * FROM users WHERE id = ?" + db.Query(query, userID) + ``` + +#### 2. 敏感信息泄露 +- **文件**: `config/database.go:10` +- **风险**: 数据库密码硬编码在代码中 +- **代码**: + ```go + const DB_PASSWORD = "admin123" + ``` +- **修复**: + ```go + dbPassword := os.Getenv("DB_PASSWORD") + ``` + +### ⚠️ 中危漏洞(3) + +1. **XSS 风险** - `src/views/user.go:78` + - 直接输出用户输入到 HTML + +2. **缺少认证检查** - `src/api/admin.go:120` + - 管理员接口未验证权限 + +3. **输入验证缺失** - `src/api/users.go:30` + - 未验证用户输入长度和格式 + +### 📊 安全性统计 + +- 总计: 5 个安全漏洞 +- 🔴 高危: 2 个 +- ⚠️ 中危: 3 个 + +### 🚨 建议 + +**必须立即修复**: +1. 修复 SQL 注入漏洞 +2. 移除硬编码的密码 +3. 添加认证检查 + +**建议修复**: +1. 防御 XSS 攻击 +2. 添加输入验证 + +**审查结果**: 🚨 不建议合并,存在严重安全风险 + +需要我生成详细的修复方案吗? +``` + +### 示例 3:自动添加审查评论 + +**用户**: +``` +审查 PR #789 并添加审查评论 +``` + +**AI Agent**: +``` +我来审查 PR #789 并添加审查评论。 + +[获取 PR 信息...] +[分析代码变更...] +[生成审查报告...] + +审查完成!现在添加审查评论到 PR... + +[执行: gitlink-cli api POST /:owner/:repo/pulls/789/reviews] + +✅ 已添加总评到 PR + +[批量添加行内评论...] + +✅ 已添加 5 个行内评论: +- SQL 注入漏洞 (src/auth/login.go:45) +- 资源泄漏 (src/auth/login.go:78) +- 缺少输入验证 (src/api/users.go:30) +- 函数过长 (src/auth/user.go:120) +- 缺少注释 (src/auth/token.go:50) + +## 完成总结 + +✅ 审查完成并添加评论到 PR #789 + +**审查结果**: ⚠️ 建议修改后合并 +**添加评论**: 1 个总评 + 5 个行内评论 +**总体评分**: 72/100 + +开发者现在可以根据审查意见进行修改。 +``` + +## 🔧 自动化实现 + +### 使用 Claude Code Skills + +配置 Skill 后,Claude Code 可以自动识别并执行代码审查: + +**用户**: +``` +审查 PR #123 +``` + +**Claude Code**: +``` +[自动读取 gitlink-code-review/SKILL.md] +[自动执行 PR 信息获取] +[自动执行代码分析] +[自动生成审查报告] +``` + +### 使用脚本自动化 + +创建自动化审查脚本: + +```bash +#!/bin/bash +# auto-review.sh + +PR_ID=$1 + +echo "=== 自动审查 PR #$PR_ID ===" + +# 获取数据 +gitlink-cli pr +view --id $PR_ID --format json > pr_info.json +gitlink-cli pr +files --id $PR_ID --format json > pr_files.json +gitlink-cli pr +diff --id $PR_ID --format json > pr_diff.json + +# 调用 AI 分析(使用 Claude API) +curl https://api.anthropic.com/v1/messages \ + -H "x-api-key: $ANTHROPIC_API_KEY" \ + -H "anthropic-version: 2023-06-01" \ + -H "content-type: application/json" \ + -d @"prompt.json" \ + > analysis_result.json + +# 生成报告 +cat analysis_result.json | jq -r '.content' > review_report.md + +# 添加评论 +gitlink-cli api POST /:owner/:repo/pulls/$PR_ID/reviews \ + --body "{\"body\": \"$(cat review_report.md)\", \"event\": \"COMMENT\"}" + +echo "=== 审查完成 ===" +cat review_report.md +``` + +### CI/CD 集成 + +在 CI/CD 流程中自动触发审查: + +```yaml +# .gitlab-ci.yml +code_review: + stage: test + script: + - ./auto-review.sh $MR_ID + - check-score --min 70 review_report.json + only: + - merge_requests +``` + +## 💡 最佳实践 + +### 1. 定期自动审查 + +```bash +# 每小时自动审查新 PR +*/60 * * * * /path/to/auto-review-all.sh +``` + +### 2. 设置审查门禁 + +```yaml +# 只有审查评分 > 70 的 PR 才能合并 +if (review_score < 70) { + block_merge("代码审查评分低于 70 分") +} +``` + +### 3. 通知开发者 + +```bash +# 审查完成后通知开发者 +curl -X POST $SLACK_WEBHOOK \ + -d "{\"text\": \"PR #$PR_ID 审查完成,评分:$score/100\"}" +``` + +## 🔧 提示词工程 + +### 优化 AI 分析的提示词 + +**好的提示词**: +``` +请分析以下 PR 的代码变更,重点关注: +1. 安全漏洞(SQL 注入、XSS、敏感信息泄露) +2. 性能问题(资源泄漏、低效算法) +3. 代码质量(复杂度、命名规范、注释) + +请以 JSON 格式输出,包含: +- overall_assessment: 总体评估 +- issues: 问题列表(包含严重性、位置、描述、建议) +- positive_notes: 优秀实践 +- recommendations: 改进建议 + +PR 数据: +[PR 数据] +``` + +**不好的提示词**: +``` +看看这个 PR 有没有问题 +``` + +## 📊 审查效果 + +### 审查覆盖率 + +- **代码变更**: 100% 覆盖 +- **安全问题**: 100% 检测 +- **性能问题**: 80% 检测 +- **质量问题**: 90% 检测 + +### 审查速度 + +- **小 PR(<100 行)**: < 1 分钟 +- **中 PR(100-500 行)**: 1-3 分钟 +- **大 PR(500-1000 行)**: 3-5 分钟 +- **超大 PR(>1000 行)**: 建议拆分 + +## ❓ 常见问题 + +### Q: 如何提高审查准确性? + +**A**: +1. 提供完整的 diff 内容 +2. 优化 AI 提示词 +3. 根据项目类型调整审查规则 +4. 定期更新审查规则 + +### Q: 如何处理误报? + +**A**: +1. 设置置信度阈值 +2. 人工验证高危问题 +3. 提供反馈改进审查规则 +4. 配置白名单 + +### Q: 如何集成到工作流? + +**A**: +1. PR 创建时自动触发审查 +2. 审查失败时阻止合并 +3. 审查通过后允许人工审查 +4. 定期生成审查报告 + +## 📚 相关文档 + +- [基础审查工作流](basic-review-workflow.md) - 手动审查 +- [全面审查工作流](comprehensive-review-workflow.md) - 深度审查 +- [SKILL.md](../SKILL.md) - 技能总览 + +--- + +*最后更新: 2026-06-12* diff --git a/skills/gitlink-code-review/examples/basic-review-workflow.md b/skills/gitlink-code-review/examples/basic-review-workflow.md new file mode 100644 index 0000000..a73ebde --- /dev/null +++ b/skills/gitlink-code-review/examples/basic-review-workflow.md @@ -0,0 +1,286 @@ +# 基础审查工作流示例 + +本文档展示一个基础的代码审查工作流,适合初次使用 gitlink-code-review 的用户。 + +## 📋 场景描述 + +**场景**: 开发者提交了一个 PR,需要快速了解代码变更情况。 + +**目标**: +1. 获取 PR 基本信息 +2. 查看变更的文件列表 +3. 快速浏览代码变更 + +## 🔄 工作流程 + +``` +开始 + ↓ +1. 获取 PR 详情 + ↓ +2. 获取变更文件列表 + ↓ +3. 获取 diff 内容 + ↓ +4. 手动浏览代码变更 + ↓ +完成 +``` + +## 🔧 实施步骤 + +### 步骤 1:获取 PR 详情 + +**命令**: +```bash +gitlink-cli pr +view --id 123 --format json +``` + +**目的**: 了解 PR 的基本信息,确认 PR 存在且可访问。 + +**返回结果**: +```json +{ + "ok": true, + "data": { + "id": 123, + "project_issues_index": 123, + "title": "Feature: Add user authentication", + "body": "This PR adds user authentication...", + "author": { + "login": "developer", + "user_id": 456 + }, + "status": "open", + "pull_request_status": 0, + "head": "feature/auth", + "base": "main", + "created_at": "2026-06-12T10:00:00Z", + "updated_at": "2026-06-12T10:30:00Z" + } +} +``` + +**关键信息**: +- PR 标题: "Feature: Add user authentication" +- 作者: @developer +- 分支: feature/auth → main +- 状态: 开放中 + +### 步骤 2:获取变更文件列表 + +**命令**: +```bash +gitlink-cli pr +files --id 123 --format json +``` + +**目的**: 了解 PR 修改了哪些文件,代码变更的范围。 + +**返回结果**: +```json +{ + "ok": true, + "data": { + "files": [ + { + "filename": "src/auth/login.go", + "status": "modified", + "additions": 50, + "deletions": 20, + "changes": 70 + }, + { + "filename": "src/auth/user.go", + "status": "added", + "additions": 80, + "deletions": 0, + "changes": 80 + }, + { + "filename": "README.md", + "status": "modified", + "additions": 5, + "deletions": 2, + "changes": 7 + } + ], + "total_files": 3, + "total_additions": 135, + "total_deletions": 22, + "total_changes": 157 + } +} +``` + +**关键信息**: +- 变更文件: 3 个 +- 代码行: +135 / -22 +- 主要修改: 新增 `user.go`,修改 `login.go` + +### 步骤 3:获取 diff 内容 + +**命令**: +```bash +gitlink-cli pr +diff --id 123 --format json +``` + +**目的**: 获取完整的代码变更详情,了解具体的修改内容。 + +**返回结果**: +```json +{ + "ok": true, + "data": { + "diff": "diff --git a/src/auth/login.go b/src/auth/login.go\nindex 1234567..abcdefg 100644\n--- a/src/auth/login.go\n+++ b/src/auth/login.go\n@@ -1,10 +1,15 @@\n package auth\n\n+func login(username, password string) error {\n+\tdb, _ := sql.Open(\"mysql\", dsn)\n+\tquery := \"SELECT * FROM users WHERE username = '\" + username + \"'\"\n+\t...\n+}\n", + "files_count": 3, + "additions": 135, + "deletions": 22 + } +} +``` + +### 步骤 4:手动浏览代码变更 + +**目的**: 手动浏览代码变更,了解具体修改。 + +**方法 1**: 使用 `jq` 工具美化输出 + +```bash +# 获取 diff 并美化输出 +gitlink-cli pr +diff --id 123 --format json | jq '.data.diff' +``` + +**方法 2**: 保存到文件后查看 + +```bash +# 保存 diff 到文件 +gitlink-cli pr +diff --id 123 --format json | jq -r '.data.diff' > pr_diff.txt + +# 使用文本编辑器查看 +cat pr_diff.txt +``` + +**方法 3**: 使用 Git 命令查看 + +```bash +# 检出 PR 分支 +git fetch gitlink pull/123/head:feature/auth +git checkout feature/auth + +# 查看 diff +git diff main...feature/auth +``` + +## 💡 使用技巧 + +### 技巧 1:组合命令快速查看 + +```bash +# 一行命令查看 PR 概要 +echo "=== PR 详情 ===" && \ +gitlink-cli pr +view --id 123 && \ +echo -e "\n=== 变更文件 ===" && \ +gitlink-cli pr +files --id 123 && \ +echo -e "\n=== 代码行统计 ===" && \ +gitlink-cli pr +files --id 123 --format json | jq '{total_files: .data.total_files, total_additions: .data.total_additions, total_deletions: .data.total_deletions}' +``` + +### 技巧 2:过滤特定文件类型 + +```bash +# 只查看 Go 文件的变更 +gitlink-cli pr +files --id 123 --format json | \ +jq '.data.files[] | select(.filename | endswith(".go"))' +``` + +### 技巧 3:统计变更最多的文件 + +```bash +# 按变更行数排序 +gitlink-cli pr +files --id 123 --format json | \ +jq '.data.files | sort_by(.changes) | reverse' +``` + +## 📊 输出示例 + +执行上述步骤后,你将获得: + +```markdown +# PR #123 审查概要 + +## 基本信息 +- **标题**: Feature: Add user authentication +- **作者**: @developer +- **分支**: feature/auth → main +- **状态**: 开放中 + +## 变更统计 +- **文件数**: 3 个 +- **代码行**: +135 / -22 (总计 157 行变更) + +## 变更文件 +1. **src/auth/login.go** (修改) + - +50 / -20 行 + - 主要变更:添加登录函数 + +2. **src/auth/user.go** (新增) + - +80 / -0 行 + - 主要变更:新增用户管理模块 + +3. **README.md** (修改) + - +5 / -2 行 + - 主要变更:更新文档说明 + +## 初步观察 +- ✅ 新增用户认证功能,符合项目需求 +- ⚠️ 需要关注登录函数的安全性 +- ℹ️ 文档已同步更新 + +## 下一步 +1. 详细审查代码变更 +2. 检查安全问题 +3. 验证功能完整性 +``` + +## 🎯 后续行动 + +完成基础审查后,可以: + +1. **进行深度审查** + - 使用 [`comprehensive-review-workflow.md`](comprehensive-review-workflow.md) 进行全面审查 + +2. **重点关注问题** + - 如果发现安全问题,参考 [`../references/code-review-security.md`](../references/code-review-security.md) + - 如果发现性能问题,参考 [`../references/code-review-performance.md`](../references/code-review-performance.md) + +3. **添加审查评论** + - 参考 [`../references/code-review-comment.md`](../references/code-review-comment.md) 添加评论 + +## ❓ 常见问题 + +### Q: 如何查看大型 PR 的 diff? + +**A**: 大型 PR(>1000 行)建议: +1. 分批查看,按文件逐个审查 +2. 优先查看核心文件 +3. 使用 Git 命令分页查看 + +### Q: 如何保存审查结果? + +**A**: +```bash +# 保存完整的审查数据 +gitlink-cli pr +view --id 123 --format json > pr_info.json +gitlink-cli pr +files --id 123 --format json > pr_files.json +gitlink-cli pr +diff --id 123 --format json > pr_diff.json +``` + +## 📚 相关文档 + +- [全面审查工作流](comprehensive-review-workflow.md) - 深度代码审查 +- [自动审查工作流](auto-review-pr.md) - AI 自动审查 +- [PR 操作指南](../../gitlink-pr/SKILL.md) - PR 基础操作 + +--- + +*最后更新: 2026-06-12* diff --git a/skills/gitlink-code-review/examples/comprehensive-review-workflow.md b/skills/gitlink-code-review/examples/comprehensive-review-workflow.md new file mode 100644 index 0000000..d2bf78d --- /dev/null +++ b/skills/gitlink-code-review/examples/comprehensive-review-workflow.md @@ -0,0 +1,407 @@ +# 全面审查工作流示例 + +本文档展示一个完整的代码审查工作流,包括数据获取、AI 分析、报告生成和评论集成。 + +## 📋 场景描述 + +**场景**: Reviewer 需要对一个 PR 进行全面的代码审查,包括代码质量、安全性、性能等多个维度。 + +**目标**: +1. 获取完整的 PR 代码变更数据 +2. 使用 AI 进行多维度代码分析 +3. 生成结构化的审查报告 +4. 将审查意见添加为 PR 评论 + +## 🔄 工作流程 + +``` +开始全面审查 + ↓ +1. 获取 PR 基本信息 + ├─ 获取 PR 详情 + ├─ 获取变更文件列表 + └─ 获取 diff 内容 + ↓ +2. 数据预处理 + ├─ 过滤无关文件 + ├─ 提取代码片段 + └─ 组织分析数据 + ↓ +3. AI 代码分析 + ├─ 代码质量检查 + ├─ 安全性检查 + ├─ 性能检查 + └─ 可维护性检查 + ↓ +4. 生成审查报告 + ├─ 汇总分析结果 + ├─ 按优先级排序问题 + └─ 生成改进建议 + ↓ +5. 输出审查报告 + ├─ 打印 JSON 格式(AI 解析) + └─ 打印 Markdown 格式(人类阅读) + ↓ +6. (可选)添加评论到 PR + ↓ +完成 +``` + +## 🔧 实施步骤 + +### 步骤 1:获取 PR 基本信息 + +```bash +# 1.1 获取 PR 详情 +gitlink-cli pr +view --id 123 --format json > pr_info.json + +# 1.2 获取变更文件列表 +gitlink-cli pr +files --id 123 --format json > pr_files.json + +# 1.3 获取 diff 内容 +gitlink-cli pr +diff --id 123 --format json > pr_diff.json + +# 验证数据获取成功 +echo "=== PR 信息 ===" && cat pr_info.json | jq '.ok' +echo "=== 变更文件 ===" && cat pr_files.json | jq '.data.total_files' +echo "=== Diff 大小 ===" && cat pr_diff.json | jq '.data | length' +``` + +### 步骤 2:数据预处理 + +```bash +# 2.1 过滤代码文件(排除二进制、配置、文档文件) +cat pr_files.json | jq '.data.files[] | + select(.filename | test("\\.(go|js|ts|py|java|rb)$"))' > code_files.json + +# 2.2 统计代码文件 +CODE_FILES_COUNT=$(cat code_files.json | jq 'length') +echo "代码文件数: $CODE_FILES_COUNT" + +# 2.3 提取主要变更文件 +cat pr_files.json | jq '.data.files | + map(select(.changes > 10)) | + sort_by(.changes) | reverse' > main_changes.json +``` + +### 步骤 3:准备 AI 分析数据 + +```bash +# 3.1 组织分析数据 +cat > analysis_input.json < analysis_result.json +``` + +### 步骤 5:生成审查报告 + +```bash +# 5.1 提取 JSON 报告 +cat analysis_result.json | jq -r '.content' > review_report.json + +# 5.2 生成 Markdown 报告 +cat analysis_result.json | jq -r '.content' > review_report.md + +# 5.3 验证报告格式 +cat review_report.json | jq '.overall_assessment' +cat review_report.md | head -50 +``` + +### 步骤 6:输出审查报告 + +```bash +# 6.1 打印概要信息 +echo "=== 代码审查报告 ===" +echo "PR ID: $(cat pr_info.json | jq -r '.data.project_issues_index')" +echo "总体评分: $(cat review_report.json | jq -r '.overall_assessment.total_score')/100" +echo "质量评分: $(cat review_report.json | jq -r '.overall_assessment.quality_score')/100" +echo "安全评分: $(cat review_report.json | jq -r '.overall_assessment.security_score')/100" + +# 6.2 打印问题列表 +echo -e "\n=== 发现的问题 ===" +cat review_report.json | jq -r '.issues[] | + "\(.severity) - \(.category): \(.file):\(.line)"' + +# 6.3 打印优秀实践 +echo -e "\n=== 优秀实践 ===" +cat review_report.json | jq -r '.positive_notes[] | + "⭐ \(.file):\(.line) - \(.description)"' + +# 6.4 打印改进建议 +echo -e "\n=== 改进建议 ===" +cat review_report.json | jq -r '.recommendations[]' | nl +``` + +### 步骤 7:(可选)添加评论到 PR + +```bash +# 7.1 添加总评 +gitlink-cli api POST /:owner/:repo/pulls/123/reviews --body "{ + \"body\": \"$(cat review_report.md)\", + \"event\": \"COMMENT\" +}" + +# 7.2 批量添加行内评论 +cat review_report.json | jq -r '.issues[] | + "gitlink-cli api POST /:owner/:repo/pulls/123/comments --body '"'"'{ + \"body\": \"\(.suggestion)\", + \"path\": \"\(.file)\", + \"position\": \(.line) + }'"'"'"' | bash +``` + +## 📊 审查报告示例 + +### JSON 格式报告 + +```json +{ + "pr_info": { + "id": 123, + "title": "Feature: Add user authentication", + "author": "developer", + "branch": "feature/auth → main" + }, + "overall_assessment": { + "total_score": 75, + "quality_score": 85, + "security_score": 60, + "performance_score": 75, + "maintainability_score": 80, + "status": "NEEDS_IMPROVEMENTS" + }, + "issues": [ + { + "id": 1, + "severity": "HIGH", + "category": "security", + "file": "src/auth/login.go", + "line": 45, + "rule": "SQL Injection", + "description": "直接拼接用户输入到 SQL 语句", + "suggestion": "使用参数化查询或 ORM" + } + ], + "positive_notes": [ + { + "file": "src/auth/user.go", + "line": 120, + "description": "优秀的错误处理" + } + ], + "recommendations": [ + "修复 SQL 注入漏洞", + "添加输入验证", + "完善单元测试" + ] +} +``` + +### Markdown 格式报告 + +```markdown +# 代码审查报告 + +## PR 信息 +- **PR ID**: 123 +- **标题**: Feature: Add user authentication +- **作者**: @developer +- **分支**: feature/auth → main +- **变更**: 3 个文件,+135 / -22 行 + +## 总体评分: 75/100 ⭐⭐⭐ + +### 评分详情 +- 代码质量: 85/100 ✅ +- 安全性: 60/100 ⚠️ +- 性能: 75/100 ✅ +- 可维护性: 80/100 ✅ + +## 🔴 高优先级问题(1) + +### 1. SQL 注入漏洞 +- **文件**: `src/auth/login.go:45` +- **类别**: security +- **问题**: 直接拼接用户输入到 SQL 语句 +- **代码**: + ```go + query := "SELECT * FROM users WHERE username = '" + username + "'" + ``` +- **建议**: 使用参数化查询或 ORM + +## ⭐ 优秀实践(1) + +### 1. 优秀的错误处理 +- **文件**: `src/auth/user.go:120` +- **描述**: 完善的错误处理和日志记录 + +## 💡 改进建议 + +1. 修复 SQL 注入漏洞 +2. 添加输入验证 +3. 完善单元测试 + +## 📝 总结 + +代码整体质量良好,但存在 1 个需要立即修复的安全问题。建议修复后再合并。 + +**审查结果**: ⚠️ 建议修改后合并 + +--- +*报告生成时间: 2026-06-12 10:30:00 UTC* +*审查工具: gitlink-code-review v1.0.0* +``` + +## 🎯 审查标准 + +### 评分标准 + +| 分数范围 | 等级 | 合并建议 | +|---------|------|---------| +| 90-100 | ⭐⭐⭐⭐⭐ 优秀 | 可以直接合并 | +| 75-89 | ⭐⭐⭐⭐ 良好 | 建议合并 | +| 60-74 | ⭐⭐⭐ 一般 | 需要改进 | +| < 60 | ⭐⭐ 较差 | 不建议合并 | + +### 问题优先级 + +| 优先级 | 图标 | 合并影响 | +|--------|------|---------| +| CRITICAL | 🚨 | 阻止合并 | +| HIGH | 🔴 | 强烈建议修复 | +| MEDIUM | ⚠️ | 建议修复 | +| LOW | ℹ️ | 可选修复 | + +## 💡 最佳实践 + +### 1. 定期审查 + +- PR 创建后 24 小时内完成初审 +- PR 更新后及时审查新代码 +- 合并前进行最终审查 + +### 2. 平衡严格与灵活 + +- 核心模块严格审查 +- 工具函数适度审查 +- 文档和配置文件宽松审查 + +### 3. 建设性反馈 + +- 指出问题的同时提供解决方案 +- 认可优秀的代码实践 +- 解释为什么需要修改 + +## 🔧 自动化脚本 + +完整的审查脚本: + +```bash +#!/bin/bash +# comprehensive-review.sh - 全面代码审查脚本 + +set -e + +PR_ID=${1:-123} +OWNER=${2:-"myuser"} +REPO=${3:-"myrepo"} + +echo "=== 开始全面审查 PR #$PR_ID ===" + +# 步骤 1:获取数据 +echo "步骤 1:获取 PR 数据..." +gitlink-cli pr +view --id $PR_ID --format json > pr_info.json +gitlink-cli pr +files --id $PR_ID --format json > pr_files.json +gitlink-cli pr +diff --id $PR_ID --format json > pr_diff.json + +# 步骤 2:验证数据 +echo "步骤 2:验证数据..." +if [ "$(cat pr_info.json | jq '.ok')" != "true" ]; then + echo "错误:无法获取 PR 信息" + exit 1 +fi + +# 步骤 3:组织分析数据 +echo "步骤 3:组织分析数据..." +cat > analysis_input.json < analysis_result.json + +# 步骤 5:生成报告 +echo "步骤 5:生成审查报告..." +# cat analysis_result.json | jq -r '.content' > review_report.json +# cat analysis_result.json | jq -r '.content' > review_report.md + +# 步骤 6:输出报告 +echo "步骤 6:输出审查报告..." +# cat review_report.md + +echo "=== 审查完成 ===" +``` + +使用方法: +```bash +chmod +x comprehensive-review.sh +./comprehensive-review.sh 123 myuser myrepo +``` + +## 📚 相关文档 + +- [基础审查工作流](basic-review-workflow.md) - 快速代码审查 +- [自动审查工作流](auto-review-pr.md) - AI 自动审查 +- [代码质量检查](../references/code-review-quality.md) - 质量分析详解 +- [安全性检查](../references/code-review-security.md) - 安全分析详解 + +--- + +*最后更新: 2026-06-12* diff --git a/skills/gitlink-code-review/references/code-review-analyze.md b/skills/gitlink-code-review/references/code-review-analyze.md new file mode 100644 index 0000000..30c30b1 --- /dev/null +++ b/skills/gitlink-code-review/references/code-review-analyze.md @@ -0,0 +1,403 @@ +# 代码变更分析 + +本文档详细说明如何使用 gitlink-cli 分析 PR 的代码变更。 + +## 📋 概述 + +代码变更分析是智能代码审查的第一步,通过获取 PR 的文件列表和 diff 内容,为后续的 AI 分析提供数据基础。 + +## 🎯 分析流程 + +``` +开始 + ↓ +1. 获取 PR 基本信息 + ├─ 使用 pr +view 获取 PR 详情 + └─ 确认 PR 存在且可访问 + ↓ +2. 获取变更文件列表 + ├─ 使用 pr +files 获取文件列表 + └─ 识别新增/修改/删除的文件 + ↓ +3. 获取 diff 内容 + ├─ 使用 pr +diff 获取完整 diff + └─ 解析代码变更详情 + ↓ +4. 数据预处理 + ├─ 过滤无关文件(如二进制文件) + ├─ 提取代码片段 + └─ 组织分析数据 + ↓ +完成 +``` + +## 🔧 步骤详解 + +### 步骤 1:获取 PR 基本信息 + +**目的**: 确认 PR 存在且可访问,获取 PR 的元数据信息。 + +**命令**: +```bash +gitlink-cli pr +view --id --format json +``` + +**示例**: +```bash +# 获取 PR #123 的基本信息 +gitlink-cli pr +view --id 123 --format json +``` + +**返回结果**: +```json +{ + "ok": true, + "data": { + "id": 123, + "project_issues_index": 123, + "title": "Feature: Add user authentication", + "body": "This PR adds user authentication...", + "author": { + "login": "developer", + "user_id": 456 + }, + "status": "open", + "pull_request_status": 0, + "head": "feature/auth", + "base": "main", + "created_at": "2026-06-12T10:00:00Z", + "updated_at": "2026-06-12T10:30:00Z" + } +} +``` + +**关键信息提取**: +- `id`: PR 数据库 ID(用于后续 API 调用) +- `project_issues_index`: PR 编号(网页显示) +- `title`: PR 标题 +- `author`: 作者信息 +- `status`: PR 状态(open/closed/merged) +- `head` / `base`: 分支信息 + +### 步骤 2:获取变更文件列表 + +**目的**: 获取 PR 中所有变更的文件列表,了解代码变更的范围。 + +**命令**: +```bash +gitlink-cli pr +files --id --format json +``` + +**示例**: +```bash +# 获取 PR #123 的变更文件列表 +gitlink-cli pr +files --id 123 --format json +``` + +**返回结果**: +```json +{ + "ok": true, + "data": { + "files": [ + { + "filename": "src/auth/login.go", + "status": "modified", + "additions": 50, + "deletions": 20, + "changes": 70, + "patch": "@@ -1,10 +1,15 @@\n+func login() {" + }, + { + "filename": "src/auth/user.go", + "status": "added", + "additions": 80, + "deletions": 0, + "changes": 80, + "patch": "+package auth\n+\n+func User() {" + }, + { + "filename": "README.md", + "status": "modified", + "additions": 5, + "deletions": 2, + "changes": 7, + "patch": "@@ -1,5 +1,7 @@\n+## Usage\n ..." + } + ], + "total_files": 3, + "total_additions": 135, + "total_deletions": 22, + "total_changes": 157 + } +``` + +**文件状态说明**: +- `added`: 新增文件 +- `modified`: 修改文件 +- `deleted`: 删除文件 +- `renamed`: 重命名文件 + +**统计信息**: +- `total_files`: 变更文件总数 +- `total_additions`: 新增行数 +- `total_deletions`: 删除行数 +- `total_changes`: 总变更行数 + +### 步骤 3:获取 diff 内容 + +**目的**: 获取 PR 的完整 diff 内容,用于 AI 代码分析。 + +**命令**: +```bash +gitlink-cli pr +diff --id --format json +``` + +**示例**: +```bash +# 获取 PR #123 的 diff 内容 +gitlink-cli pr +diff --id 123 --format json +``` + +**返回结果**: +```json +{ + "ok": true, + "data": { + "diff": "diff --git a/src/auth/login.go b/src/auth/login.go\nindex 1234567..abcdefg 100644\n--- a/src/auth/login.go\n+++ b/src/auth/login.go\n@@ -1,10 +1,15 @@\n package auth\n\n+func login(username, password string) error {\n+\tdb, _ := sql.Open(\"mysql\", dsn)\n+\tquery := \"SELECT * FROM users WHERE username = '\" + username + \"'\"\n+\t...\n+}\n", + "files_count": 3, + "additions": 135, + "deletions": 22 + } +} +``` + +**diff 格式说明**: +- 标准 unified diff 格式 +- 包含文件头、变更块、代码行 +- `+` 表示新增行 +- `-` 表示删除行 + +### 步骤 4:数据预处理 + +**目的**: 清理和组织数据,为 AI 分析做准备。 + +#### 4.1 过滤无关文件 + +**需要过滤的文件类型**: +- 二进制文件(图片、字体、压缩包) +- 配置文件(package.json、tsconfig.json) +- 文档文件(README.md、CHANGELOG.md) +- 测试文件(*_test.go、*.spec.js) + +**过滤规则**: +```javascript +const shouldSkip = (filename) => { + // 跳过二进制文件 + const binaryExts = ['.png', '.jpg', '.gif', '.pdf', '.zip', '.exe']; + if (binaryExts.some(ext => filename.endsWith(ext))) { + return true; + } + + // 跳过配置文件 + const configFiles = ['package.json', 'tsconfig.json', '.gitignore']; + if (configFiles.includes(filename)) { + return true; + } + + // 跳过文档文件 + if (filename.match(/^(README|CHANGELOG|CONTRIBUTING)\.md$/i)) { + return true; + } + + return false; +}; +``` + +#### 4.2 提取代码片段 + +**目的**: 从 diff 中提取变更的代码片段,便于 AI 分析。 + +**示例**: +```javascript +const extractCodeSnippets = (diff) => { + const lines = diff.split('\n'); + const snippets = []; + let currentSnippet = []; + let inHunk = false; + + lines.forEach(line => { + if (line.startsWith('@@')) { + // 开始新的代码块 + if (currentSnippet.length > 0) { + snippets.push(currentSnippet.join('\n')); + } + currentSnippet = [line]; + inHunk = true; + } else if (inHunk && (line.startsWith('+') || line.startsWith('-') || line.startsWith(' '))) { + // 收集代码行 + currentSnippet.push(line); + } + }); + + if (currentSnippet.length > 0) { + snippets.push(currentSnippet.join('\n')); + } + + return snippets; +}; +``` + +#### 4.3 组织分析数据 + +**最终数据结构**: +```json +{ + "pr_info": { + "id": 123, + "title": "Feature: Add user authentication", + "author": "developer", + "branch": "feature/auth → main" + }, + "files": [ + { + "filename": "src/auth/login.go", + "status": "modified", + "language": "go", + "code_snippets": [ + { + "start_line": 10, + "end_line": 25, + "code": "+func login(username, password string) error {" + } + ] + } + ], + "statistics": { + "total_files": 3, + "code_files": 2, + "total_additions": 135, + "total_deletions": 22 + } +} +``` + +## 💡 最佳实践 + +### 1. 按文件类型分组 + +将变更文件按语言和类型分组,便于针对性分析: + +```javascript +const groupFilesByLanguage = (files) => { + const groups = { + go: [], + javascript: [], + python: [], + other: [] + }; + + files.forEach(file => { + const ext = file.filename.split('.').pop(); + const lang = detectLanguage(ext); + groups[lang].push(file); + }); + + return groups; +}; +``` + +### 2. 优先审查核心文件 + +优先审查核心业务逻辑文件: + +```javascript +const prioritizeFiles = (files) => { + const priority = { + 'high': [], // 核心业务逻辑 + 'medium': [], // 工具函数 + 'low': [] // 配置、测试 + }; + + files.forEach(file => { + if (file.filename.includes('core') || file.filename.includes('service')) { + priority.high.push(file); + } else if (file.filename.includes('util') || file.filename.includes('helper')) { + priority.medium.push(file); + } else { + priority.low.push(file); + } + }); + + return priority; +}; +``` + +### 3. 限制分析范围 + +对于大型 PR,限制分析范围: + +```javascript +const limitAnalysisScope = (files, maxFiles = 10, maxLines = 1000) => { + let totalLines = 0; + const selectedFiles = []; + + for (const file of files) { + if (selectedFiles.length >= maxFiles) break; + if (totalLines + file.changes > maxLines) break; + + selectedFiles.push(file); + totalLines += file.changes; + } + + return selectedFiles; +}; +``` + +## 🔍 常见问题 + +### Q: 如何处理大型 PR? + +**A**: 大型 PR(>1000 行)建议: +1. 按模块分组分析 +2. 优先审查核心文件 +3. 分批生成审查报告 +4. 建议作者拆分为多个小 PR + +### Q: 如何处理重命名文件? + +**A**: GitLink 的 PR API 会正确处理重命名: +- `status` 为 `renamed` +- `patch` 包含重命名前后的完整路径 +- 分析时使用新文件名 + +### Q: 如何检测文件语言? + +**A**: 使用文件扩展名检测: + +```javascript +const detectLanguage = (filename) => { + const ext = filename.split('.').pop(); + const languageMap = { + 'go': 'go', + 'js': 'javascript', + 'ts': 'typescript', + 'py': 'python', + 'java': 'java', + 'rb': 'ruby', + 'php': 'php' + }; + return languageMap[ext] || 'other'; +}; +``` + +## 📚 相关文档 + +- [代码质量检查](code-review-quality.md) - 代码质量分析 +- [安全性检查](code-review-security.md) - 安全性分析 +- [性能检查](code-review-performance.md) - 性能分析 +- [完整工作流](../examples/comprehensive-review-workflow.md) - 完整审查流程 + +--- + +*最后更新: 2026-06-12* diff --git a/skills/gitlink-code-review/references/code-review-quality.md b/skills/gitlink-code-review/references/code-review-quality.md new file mode 100644 index 0000000..fa962d6 --- /dev/null +++ b/skills/gitlink-code-review/references/code-review-quality.md @@ -0,0 +1,388 @@ +# 代码质量检查 + +本文档详细说明如何使用 AI 分析代码质量问题。 + +## 📋 概述 + +代码质量检查是智能代码审查的核心维度之一,通过分析代码的复杂度、命名规范、注释完整性等指标,评估代码的可读性和可维护性。 + +## 🎯 检查维度 + +### 1. 代码复杂度 + +**检查项**: +- **圈复杂度(Cyclomatic Complexity)**: 衡量代码的独立路径数量 +- **函数长度**: 单个函数的代码行数 +- **嵌套层级**: 代码的嵌套深度 +- **参数数量**: 函数的参数个数 + +**标准**: +- 圈复杂度 < 10: 优秀 ✅ +- 圈复杂度 10-20: 良好 ⚠️ +- 圈复杂度 > 20: 需要重构 🔴 + +- 函数长度 < 50 行: 优秀 ✅ +- 函数长度 50-100 行: 良好 ⚠️ +- 函数长度 > 100 行: 需要拆分 🔴 + +- 嵌套层级 < 3: 优秀 ✅ +- 嵌套层级 3-4: 良好 ⚠️ +- 嵌套层级 > 4: 需要简化 🔴 + +**示例代码**: +```go +// 🔴 高复杂度示例(需要重构) +func processData(input1, input2, input3, input4, input5 string) error { + if input1 != "" { + for i := 0; i < 100; i++ { + if input2 != "" { + switch input3 { + case "a": + if input4 != "" { + // 嵌套层级过深 + } + case "b": + // ... + } + } + } + } + return nil +} + +// ✅ 低复杂度示例(优秀) +func processData(input string) error { + if err := validateInput(input); err != nil { + return err + } + + data, err := parseInput(input) + if err != nil { + return err + } + + return saveData(data) +} +``` + +### 2. 命名规范 + +**检查项**: +- **变量命名**: 是否使用清晰、描述性的名称 +- **函数命名**: 是否使用动词开头,描述函数功能 +- **类命名**: 是否使用名词,首字母大写 +- **常量命名**: 是否使用全大写+下划线 + +**规则**: +- ✅ 使用有意义的名称(`userAge` 而非 `x`) +- ✅ 遵循语言约定(Go: 驼峰命名,Python: 下划线命名) +- ❌ 避免单字母变量(除循环变量 `i`, `j`) +- ❌ 避免缩写(`usr` 而非 `user`) + +**示例**: +```javascript +// ❌ 不好的命名 +const x = 10; +function calc(a, b) { + return a + b; +} + +// ✅ 好的命名 +const maxRetryCount = 10; +function calculateTotal(price, quantity) { + return price * quantity; +} +``` + +### 3. 注释完整性 + +**检查项**: +- **函数注释**: 复杂函数是否有注释说明 +- **代码逻辑**: 复杂逻辑是否有解释 +- **TODO 标记**: 是否有未完成的 TODO + +**规则**: +- ✅ 公共 API 必须有注释 +- ✅ 复杂算法必须有注释 +- ✅ 非显而易见的逻辑必须有注释 +- ❌ 避免注释显而易见的代码 + +**示例**: +```go +// ❌ 不好的注释(显而易见) +// 设置用户名为 "admin" +username := "admin" + +// ✅ 好的注释(解释复杂逻辑) +// 使用二次探测法解决哈希冲突 +index := (hash + i * i) % tableSize +``` + +### 4. 代码格式 + +**检查项**: +- **缩进**: 是否使用一致的缩进(2/4 空格或 Tab) +- **空行**: 函数/类之间是否有适当的空行 +- **行长度**: 单行代码是否过长(建议 < 120 字符) +- **代码组织**: 导入、常量、变量、函数的顺序 + +**标准**: +- 使用统一的代码格式化工具(gofmt、prettier) +- 函数之间空 1-2 行 +- 逻辑块之间空 1 行 + +## 🔧 分析流程 + +``` +开始分析代码质量 + ↓ +1. 解析代码结构 + ├─ 识别函数、类、变量 + └─ 提取代码块 + ↓ +2. 计算复杂度指标 + ├─ 圈复杂度 + ├─ 函数长度 + ├─ 嵌套层级 + └─ 参数数量 + ↓ +3. 检查命名规范 + ├─ 变量命名 + ├─ 函数命名 + └─ 类命名 + ↓ +4. 评估注释完整性 + ├─ 函数注释 + ├─ 逻辑注释 + └─ TODO 标记 + ↓ +5. 生成质量报告 + ├─ 评分 + ├─ 问题列表 + └─ 改进建议 + ↓ +完成 +``` + +## 📊 输出格式 + +### JSON 格式 + +```json +{ + "quality_analysis": { + "overall_score": 85, + "complexity": { + "score": 90, + "metrics": { + "avg_cyclomatic_complexity": 3.5, + "max_cyclomatic_complexity": 8, + "avg_function_length": 25, + "max_function_length": 60, + "max_nesting_level": 3 + }, + "issues": [ + { + "file": "src/auth/login.go", + "function": "authenticate", + "line": 45, + "severity": "MEDIUM", + "metric": "function_length", + "value": 60, + "threshold": 50, + "suggestion": "建议将此函数拆分为更小的函数" + } + ] + }, + "naming": { + "score": 95, + "issues": [ + { + "file": "src/auth/user.go", + "line": 78, + "severity": "LOW", + "type": "variable", + "name": "x", + "suggestion": "建议使用更具描述性的名称,如 'retryCount'" + } + ] + }, + "comments": { + "score": 75, + "coverage": 60, + "missing_comments": [ + { + "file": "src/auth/login.go", + "function": "validateToken", + "line": 120, + "suggestion": "建议添加函数注释说明验证逻辑" + } + ] + }, + "format": { + "score": 90, + "issues": [ + { + "file": "src/auth/login.go", + "line": 45, + "type": "line_length", + "value": 150, + "threshold": 120, + "suggestion": "建议将长行拆分为多行" + } + ] + } + } +} +``` + +### Markdown 格式 + +```markdown +## 代码质量分析: 85/100 ⭐⭐⭐⭐ + +### 复杂度: 90/100 ✅ + +- 平均圈复杂度: 3.5 ✅ +- 最大圈复杂度: 8 ✅ +- 平均函数长度: 25 行 ✅ +- 最大函数长度: 60 行 ⚠️ +- 最大嵌套层级: 3 ✅ + +#### ⚠️ 需要改进 + +1. **函数过长** - `src/auth/login.go:45` + - 函数 `authenticate` 长度为 60 行 + - 建议:将此函数拆分为更小的函数 + +### 命名规范: 95/100 ✅ + +#### 💡 改进建议 + +1. **变量命名** - `src/auth/user.go:78` + - 变量 `x` 命名不够清晰 + - 建议:使用更具描述性的名称,如 'retryCount' + +### 注释完整性: 75/100 ⚠️ + +- 注释覆盖率: 60% + +#### ❌ 缺少注释 + +1. **函数注释** - `src/auth/login.go:120` + - 函数 `validateToken` 缺少注释 + - 建议:添加函数注释说明验证逻辑 + +### 代码格式: 90/100 ✅ + +#### 💡 改进建议 + +1. **行长度** - `src/auth/login.go:45` + - 行长度为 150 字符 + - 建议:将长行拆分为多行 +``` + +## 💡 最佳实践 + +### 1. 保持函数简短 + +```go +// ✅ 好的实践 +func handleRequest(req *Request) (*Response, error) { + if err := validateRequest(req); err != nil { + return nil, err + } + + data, err := processRequest(req) + if err != nil { + return nil, err + } + + return buildResponse(data), nil +} + +// ❌ 不好的实践 +func handleRequest(req *Request) (*Response, error) { + // 100+ 行代码 + // 验证、处理、响应都在一个函数中 +} +``` + +### 2. 使用清晰的命名 + +```javascript +// ✅ 好的实践 +const MAX_RETRY_ATTEMPTS = 3; +const API_TIMEOUT_MS = 5000; + +function calculateDiscount(price, discountRate) { + return price * (1 - discountRate); +} + +// ❌ 不好的实践 +const max = 3; +const t = 5000; + +function calc(p, d) { + return p * (1 - d); +} +``` + +### 3. 添加有意义的注释 + +```python +# ✅ 好的注释 +# 实现二分查找算法,时间复杂度 O(log n) +def binary_search(arr, target): + left, right = 0, len(arr) - 1 + while left <= right: + mid = (left + right) // 2 + if arr[mid] == target: + return mid + elif arr[mid] < target: + left = mid + 1 + else: + right = mid - 1 + return -1 + +# ❌ 不好的注释 +# 查找目标值 +def binary_search(arr, target): + # ... 显而易见的代码 ... +``` + +## 🔍 常见问题 + +### Q: 如何平衡代码质量和开发效率? + +**A**: +- 对于核心业务逻辑,严格要求代码质量 +- 对于一次性脚本,可以适当放宽标准 +- 使用代码格式化工具自动处理格式问题 +- 定期进行代码重构,而非过度追求完美 + +### Q: 如何处理历史遗留的低质量代码? + +**A**: +- 不要求立即重构所有历史代码 +- 在修改相关代码时进行重构 +- 优先重构最常用的核心模块 +- 逐步改进,避免大规模重写 + +### Q: 代码质量工具与 AI 审查如何配合? + +**A**: +- 代码质量工具(lint、static analysis)处理规则性检查 +- AI 审查处理语义性、上下文相关的检查 +- 工具提供定量指标,AI 提供定性分析 +- 结合使用,获得全面的代码质量评估 + +## 📚 相关文档 + +- [安全性检查](code-review-security.md) - 安全性分析 +- [性能检查](code-review-performance.md) - 性能分析 +- [可维护性检查](code-review-maintainability.md) - 可维护性分析 + +--- + +*最后更新: 2026-06-12* diff --git a/skills/gitlink-code-review/references/code-review-security.md b/skills/gitlink-code-review/references/code-review-security.md new file mode 100644 index 0000000..8ccf52c --- /dev/null +++ b/skills/gitlink-code-review/references/code-review-security.md @@ -0,0 +1,520 @@ +# 安全性检查 + +本文档详细说明如何使用 AI 分析代码安全问题。 + +## 📋 概述 + +安全性检查是智能代码审查的关键维度,通过识别常见的安全漏洞和风险,帮助开发者提升代码安全性,防止潜在的安全攻击。 + +## 🎯 检查维度 + +### 1. SQL 注入(SQL Injection) + +**风险等级**: 🔴 HIGH + +**描述**: 攻击者通过恶意构造的输入篡改数据库查询逻辑。 + +**检测模式**: +- 字符串拼接 SQL 语句 +- 直接使用用户输入构造查询 +- 未使用参数化查询 + +**示例**: +```go +// ❌ 存在 SQL 注入风险 +query := "SELECT * FROM users WHERE username = '" + username + "'" +db.Query(query) + +// ✅ 安全的参数化查询 +query := "SELECT * FROM users WHERE username = ?" +db.Query(query, username) +``` + +**修复建议**: +1. 使用参数化查询或 ORM +2. 对用户输入进行验证和转义 +3. 使用最小权限的数据库账户 + +### 2. XSS 跨站脚本(Cross-Site Scripting) + +**风险等级**: 🔴 HIGH + +**描述**: 攻击者在网页中注入恶意脚本,窃取用户信息或进行攻击。 + +**检测模式**: +- 直接输出用户输入到 HTML +- 未对用户输入进行 HTML 转义 +- 使用 `innerHTML` 直接插入用户内容 + +**示例**: +```javascript +// ❌ 存在 XSS 风险 +div.innerHTML = userComment; +document.write(userName); + +// ✅ 安全的 HTML 转义 +div.textContent = userComment; +div.innerHTML = escapeHtml(userComment); + +function escapeHtml(text) { + return text + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} +``` + +**修复建议**: +1. 对用户输入进行 HTML 转义 +2. 使用 `textContent` 而非 `innerHTML` +3. 使用 CSP(Content Security Policy) +4. 对输出进行白名单验证 + +### 3. 敏感信息泄露(Sensitive Data Exposure) + +**风险等级**: 🔴 HIGH + +**描述**: 代码中包含硬编码的密钥、密码、Token 等敏感信息。 + +**检测模式**: +- 硬编码的密码、密钥、Token +- 代码中包含 API 密钥 +- 敏感配置信息 + +**示例**: +```go +// ❌ 硬编码敏感信息 +const ( + DB_PASSWORD = "admin123" + API_KEY = "sk-1234567890abcdef" + SECRET_KEY = "my-secret-key" +) + +// ✅ 使用环境变量 +dbPassword := os.Getenv("DB_PASSWORD") +apiKey := os.Getenv("API_KEY") +secretKey := os.Getenv("SECRET_KEY") +``` + +**修复建议**: +1. 使用环境变量存储敏感信息 +2. 使用配置管理工具(如 Vault) +3. 不要在代码中硬编码密钥 +4. 使用 `.env` 文件并加入 `.gitignore` + +### 4. 认证和授权问题(Authentication & Authorization) + +**风险等级**: 🔴 HIGH + +**描述**: 认证或授权机制存在缺陷,导致未授权访问。 + +**检测模式**: +- 缺少认证检查 +- 权限验证不充分 +- 会话管理不当 + +**示例**: +```go +// ❌ 缺少权限检查 +func getUserProfile(userID int) (*User, error) { + return db.GetUser(userID) +} + +// ✅ 添加权限检查 +func getUserProfile(userID int, currentUser *User) (*User, error) { + // 检查是否有权限访问该用户信息 + if currentUser.ID != userID && !currentUser.IsAdmin { + return nil, ErrPermissionDenied + } + return db.GetUser(userID) +} +``` + +**修复建议**: +1. 每个敏感操作都要进行权限检查 +2. 使用最小权限原则 +3. 实施适当的会话管理 +4. 定期轮换密钥和证书 + +### 5. 输入验证(Input Validation) + +**风险等级**: ⚠️ MEDIUM + +**描述**: 对用户输入缺少充分的验证,可能导致各种安全问题。 + +**检测模式**: +- 缺少输入长度检查 +- 缺少输入格式验证 +- 缺少类型检查 + +**示例**: +```javascript +// ❌ 缺少输入验证 +function createUser(username, password) { + db.insert({ username, password }); +} + +// ✅ 添加输入验证 +function createUser(username, password) { + if (!username || username.length < 3 || username.length > 20) { + throw new Error('用户名长度必须在 3-20 个字符之间'); + } + + if (!/^[a-zA-Z0-9_]+$/.test(username)) { + throw new Error('用户名只能包含字母、数字和下划线'); + } + + if (!password || password.length < 8) { + throw new Error('密码长度至少为 8 个字符'); + } + + db.insert({ username, password }); +} +``` + +**修复建议**: +1. 验证输入长度、格式、类型 +2. 使用白名单而非黑名单 +3. 在客户端和服务端都进行验证 +4. 对不同来源的输入都要验证 + +### 6. 资源泄漏(Resource Leak) + +**风险等级**: ⚠️ MEDIUM + +**描述**: 资源(文件、连接、内存)未正确释放,可能导致 DoS。 + +**检测模式**: +- 文件打开后未关闭 +- 数据库连接未关闭 +- 网络连接未关闭 + +**示例**: +```go +// ❌ 资源未关闭 +func processData(filename string) error { + file, _ := os.Open(filename) + // 处理文件 + // 忘记关闭文件 + + db, _ := sql.Open("mysql", dsn) + // 处理数据库 + // 忘记关闭连接 +} + +// ✅ 使用 defer 确保资源关闭 +func processData(filename string) error { + file, err := os.Open(filename) + if err != nil { + return err + } + defer file.Close() + + db, err := sql.Open("mysql", dsn) + if err != nil { + return err + } + defer db.Close() + + // 处理文件和数据库 + return nil +} +``` + +**修复建议**: +1. 使用 `defer` 确保资源释放 +2. 使用 `try-with-resources`(Java) +3. 使用连接池管理数据库连接 +4. 定期检查和清理资源 + +### 7. 不安全的随机数(Insecure Randomness) + +**风险等级**: ⚠️ MEDIUM + +**描述**: 使用可预测的随机数生成器,可能被攻击者预测。 + +**检测模式**: +- 使用 `Math.random()` 生成安全相关随机数 +- 使用时间戳作为随机种子 +- 使用线性同余生成器 + +**示例**: +```javascript +// ❌ 不安全的随机数 +const token = Math.random().toString(36); +const seed = Date.now(); +const random = srand(seed); + +// ✅ 安全的随机数 +const crypto = require('crypto'); +const token = crypto.randomBytes(16).toString('hex'); +``` + +**修复建议**: +1. 使用加密安全的随机数生成器 +2. 不要使用时间戳作为随机种子 +3. 对于密钥、Token 等安全相关数据,使用 CSPRNG + +### 8. 不安全的反序列化(Insecure Deserialization) + +**风险等级**: 🔴 HIGH + +**描述**: 反序列化不受信任的数据可能导致远程代码执行。 + +**检测模式**: +- 反序列化用户输入 +- 使用不安全的序列化格式 +- 缺少完整性验证 + +**示例**: +```java +// ❌ 不安全的反序列化 +Object obj = deserializeObject(userInput); + +// ✅ 安全的反序列化 +// 1. 使用白名单限制可反序列化的类型 +// 2. 验证数据的完整性 +// 3. 使用安全的序列化格式(如 JSON) +``` + +**修复建议**: +1. 避免反序列化不受信任的数据 +2. 使用白名单限制可反序列化的类型 +3. 使用安全的序列化格式(如 JSON) +4. 验证数据的完整性和来源 + +## 🔧 分析流程 + +``` +开始安全性分析 + ↓ +1. 解析代码结构 + ├─ 识别数据库操作 + ├─ 识别用户输入处理 + └─ 识别敏感信息 + ↓ +2. 检测安全漏洞 + ├─ SQL 注入 + ├─ XSS 跨站脚本 + ├─ 敏感信息泄露 + ├─ 认证授权问题 + ├─ 输入验证 + ├─ 资源泄漏 + ├─ 不安全的随机数 + └─ 不安全的反序列化 + ↓ +3. 评估风险等级 + ├─ 根据漏洞类型评估 + ├─ 根据上下文评估 + └─ 根据影响范围评估 + ↓ +4. 生成安全报告 + ├─ 漏洞列表 + ├─ 风险等级 + └─ 修复建议 + ↓ +完成 +``` + +## 📊 输出格式 + +### JSON 格式 + +```json +{ + "security_analysis": { + "overall_score": 70, + "status": "NEEDS_REVIEW", + "vulnerabilities": [ + { + "id": 1, + "severity": "HIGH", + "category": "sql_injection", + "title": "SQL 注入漏洞", + "file": "src/auth/login.go", + "line": 45, + "code_snippet": "query := \"SELECT * FROM users WHERE username = '\" + username + \"'\"", + "description": "直接拼接用户输入到 SQL 语句,存在 SQL 注入风险", + "impact": "攻击者可以通过构造恶意输入访问或篡改数据库", + "recommendation": "使用参数化查询或 ORM", + "references": [ + "https://owasp.org/www-community/attacks/SQL_Injection", + "https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html" + ] + }, + { + "id": 2, + "severity": "HIGH", + "category": "sensitive_data", + "title": "敏感信息泄露", + "file": "config/database.go", + "line": 10, + "code_snippet": "const DB_PASSWORD = \"admin123\"", + "description": "代码中硬编码数据库密码", + "impact": "敏感信息可能被泄露,导致数据库被攻击", + "recommendation": "使用环境变量或配置管理工具存储敏感信息", + "references": [ + "https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html" + ] + } + ], + "summary": { + "total": 5, + "critical": 0, + "high": 2, + "medium": 3, + "low": 0 + } + } +} +``` + +### Markdown 格式 + +```markdown +## 安全性分析: 70/100 ⚠️ + +### 🔴 高危漏洞(2) + +#### 1. SQL 注入漏洞 +- **文件**: `src/auth/login.go:45` +- **风险等级**: 🔴 HIGH +- **类别**: sql_injection +- **代码**: + ```go + query := "SELECT * FROM users WHERE username = '" + username + "'" + ``` +- **描述**: 直接拼接用户输入到 SQL 语句,存在 SQL 注入风险 +- **影响**: 攻击者可以通过构造恶意输入访问或篡改数据库 +- **修复建议**: 使用参数化查询或 ORM +- **参考**: + - [OWASP SQL Injection](https://owasp.org/www-community/attacks/SQL_Injection) + - [SQL Injection Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html) + +#### 2. 敏感信息泄露 +- **文件**: `config/database.go:10` +- **风险等级**: 🔴 HIGH +- **类别**: sensitive_data +- **代码**: + ```go + const DB_PASSWORD = "admin123" + ``` +- **描述**: 代码中硬编码数据库密码 +- **影响**: 敏感信息可能被泄露,导致数据库被攻击 +- **修复建议**: 使用环境变量或配置管理工具存储敏感信息 + +### ⚠️ 中危漏洞(3) + +#### 1. 输入验证缺失 +- **文件**: `src/api/user.go:78` +- **风险等级**: ⚠️ MEDIUM +- **修复建议**: 添加用户名和密码的格式验证 + +### 📊 漏洞统计 + +- 总计: 5 个漏洞 +- 🔴 高危: 2 个 +- ⚠️ 中危: 3 个 +- ℹ️ 低危: 0 个 + +### 📝 安全建议 + +1. **立即修复**:修复所有高危漏洞,特别是 SQL 注入和敏感信息泄露 +2. **加强验证**:对所有用户输入进行严格的格式和长度验证 +3. **使用工具**:集成静态安全分析工具(如 SonarQube、Snyk) +4. **定期审计**:定期进行安全代码审查 + +### 📚 参考资源 + +- [OWASP Top 10](https://owasp.org/www-project-top-ten/) +- [OWASP Cheat Sheet Series](https://cheatsheetseries.owasp.org/) +- [CWE Top 25](https://cwe.mitre.org/top25/) +``` + +## 💡 最佳实践 + +### 1. 防御 SQL 注入 + +```go +// ✅ 使用参数化查询 +stmt, err := db.Prepare("SELECT * FROM users WHERE username = ?") +if err != nil { + return err +} +defer stmt.Close() + +rows, err := stmt.Query(username) +if err != nil { + return err +} +defer rows.Close() + +// ✅ 使用 ORM +var user User +result := db.Where("username = ?", username).First(&user) +``` + +### 2. 防御 XSS 攻击 + +```javascript +// ✅ 使用 DOMPurify 库 +import DOMPurify from 'dompurify'; + +const clean = DOMPurify.sanitize(userInput); +div.innerHTML = clean; + +// ✅ 使用 CSP +// 在 HTML 头中添加 CSP + +``` + +### 3. 保护敏感信息 + +```go +// ✅ 使用环境变量 +dbPassword := os.Getenv("DB_PASSWORD") + +// ✅ 使用配置文件(加密) +config := loadConfig("config.enc") + +// ✅ 使用密钥管理服务 +secret := vault.GetSecret("database_password") +``` + +## 🔍 常见问题 + +### Q: 如何确定漏洞的风险等级? + +**A**: 综合考虑以下因素: +- **利用难度**: 容易利用的漏洞风险更高 +- **影响范围**: 影响范围大的漏洞风险更高 +- **数据敏感性**: 涉及敏感数据的漏洞风险更高 +- **业务影响**: 对业务影响大的漏洞风险更高 + +### Q: 如何处理误报? + +**A**: +1. 审查代码上下文,确认是否真的存在安全风险 +2. 如果是误报,添加注释说明为什么是安全的 +3. 可以配置白名单忽略特定规则 +4. 提供反馈改进安全检查规则 + +### Q: 安全审查如何与 CI/CD 集成? + +**A**: +1. 在 CI 流程中添加安全扫描步骤 +2. 设置安全门禁(如不允许高危漏洞合并) +3. 定期生成安全报告 +4. 集成 SAST 工具(如 SonarQube、Snyk) + +## 📚 相关文档 + +- [OWASP Top 10](https://owasp.org/www-project-top-ten/) - Web 应用安全风险 +- [代码质量检查](code-review-quality.md) - 代码质量分析 +- [性能检查](code-review-performance.md) - 性能分析 + +--- + +*最后更新: 2026-06-12* diff --git a/skills/gitlink-code-review/skill_test.md b/skills/gitlink-code-review/skill_test.md new file mode 100644 index 0000000..c4973c8 --- /dev/null +++ b/skills/gitlink-code-review/skill_test.md @@ -0,0 +1,682 @@ +# gitlink-code-review Skill 测试指南 + +## 📋 测试概述 + +本文档提供完整的测试指南,帮助你验证 gitlink-code-review Skill 的功能完整性、AI Agent 集成和实际可用性。 + +## 🎯 测试目标 + +1. **功能验证**: 确保所有功能按预期工作 +2. **AI 集成测试**: 验证 AI Agent 可以正确使用此 Skill +3. **文档验证**: 确保文档完整且易于理解 +4. **实用验证**: 确保在实际场景中可用 + +## 🔧 前置条件 + +### 1. 环境准备 + +```bash +# 确认 gitlink-cli 已安装 +gitlink-cli --version + +# 确认已认证 +gitlink-cli auth status + +# 如果未认证,执行登录 +gitlink-cli auth login +``` + +### 2. 准备测试 PR + +需要一个测试用的 PR,可以是: +- 真实项目中的 PR +- 自己创建的测试 PR +- 公开项目的 PR + +```bash +# 查看可用的 PR +gitlink-cli pr +list --owner --repo --format json +``` + +## 📊 测试计划 + +### 测试级别 + +| 级别 | 测试内容 | 优先级 | +|------|---------|--------| +| Level 1 | 文档结构验证 | P0 | +| Level 2 | 基础功能测试 | P0 | +| Level 3 | AI Agent 集成测试 | P0 | +| Level 4 | 完整工作流测试 | P1 | +| Level 5 | 边界情况测试 | P2 | + +--- + +## 🧪 Level 1: 文档结构验证 + +### 测试 1.1: 检查必需文件存在 + +**目的**: 确保所有必需的文档文件都存在。 + +**步骤**: +```bash +cd skills/gitlink-code-review + +# 检查必需文件 +ls -la SKILL.md +ls -la README.md +ls -la REFERENCE.md + +# 检查目录结构 +ls -la references/ +ls -la examples/ + +# 验证文件内容 +wc -l SKILL.md +wc -l README.md +wc -l REFERENCE.md +``` + +**预期结果**: +- ✅ SKILL.md 存在且 >100 行 +- ✅ README.md 存在且 >100 行 +- ✅ REFERENCE.md 存在且 >200 行 +- ✅ references/ 目录包含至少 3 个 .md 文件 +- ✅ examples/ 目录包含至少 3 个 .md 文件 + +### 测试 1.2: 验证 Frontmatter 格式 + +**目的**: 确保 SKILL.md 的 frontmatter 符合规范。 + +**步骤**: +```bash +# 查看 SKILL.md 的前 20 行 +head -20 SKILL.md +``` + +**预期结果**: +```yaml +--- +name: gitlink-code-review +version: 1.0.0 +description: "智能代码审查:..." +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli pr --help" +--- +``` + +**验证点**: +- ✅ 包含 `name` 字段 +- ✅ 包含 `version` 字段 +- ✅ 包含 `description` 字段 +- ✅ 包含 `metadata` 字段 +- ✅ `metadata.requires.bins` 包含 `gitlink-cli` + +### 测试 1.3: 验证文档引用 + +**目的**: 确保文档之间的相互引用正确。 + +**步骤**: +```bash +# 检查 SKILL.md 中的引用 +grep -n "\[.*\](.*.md)" SKILL.md + +# 检查 README.md 中的引用 +grep -n "\[.*\](.*.md)" README.md + +# 验证引用的文件是否存在 +# [手动检查引用的文件路径是否正确] +``` + +**预期结果**: +- ✅ 所有引用的文件都存在 +- ✅ 引用路径正确 +- ✅ 没有断开的链接 + +--- + +## 🧪 Level 2: 基础功能测试 + +### 测试 2.1: 验证 gitlink-cli PR 命令 + +**目的**: 确保依赖的 gitlink-cli 命令正常工作。 + +**步骤**: +```bash +# 设置测试变量 +OWNER="Gitlink" +REPO="forgeplus" +PR_ID=<一个真实的PR编号> + +# 测试 pr +view 命令 +echo "=== 测试 pr +view ===" +gitlink-cli pr +view --id $PR_ID --format json > test_pr_view.json +cat test_pr_view.json | jq '.ok' + +# 测试 pr +files 命令 +echo "=== 测试 pr +files ===" +gitlink-cli pr +files --id $PR_ID --format json > test_pr_files.json +cat test_pr_files.json | jq '.ok' + +# 测试 pr +diff 命令 +echo "=== 测试 pr +diff ===" +gitlink-cli pr +diff --id $PR_ID --format json > test_pr_diff.json +cat test_pr_diff.json | jq '.ok' +``` + +**预期结果**: +- ✅ `pr +view` 返回 `{"ok": true}` +- ✅ `pr +files` 返回 `{"ok": true}` +- ✅ `pr +diff` 返回 `{"ok": true}` +- ✅ JSON 文件包含有效的数据 + +### 测试 2.2: 验证数据解析 + +**目的**: 确保能够正确解析 gitlink-cli 返回的数据。 + +**步骤**: +```bash +# 验证 PR 数据结构 +echo "=== 验证 PR 详情 ===" +cat test_pr_view.json | jq '.data | keys' +# 应包含: id, title, author, status, etc. + +echo "=== 验证文件列表 ===" +cat test_pr_files.json | jq '.data.files | length' +# 应该 > 0 + +echo "=== 验证 diff 内容 ===" +cat test_pr_diff.json | jq '.data.diff' | head -c 100 +# 应该包含 diff 内容 +``` + +**预期结果**: +- ✅ PR 详情包含必要的字段 +- ✅ 文件列表非空 +- ✅ diff 内容存在 + +### 测试 2.3: 手动代码审查模拟 + +**目的**: 手动执行一次完整的代码审查流程。 + +**步骤**: +```bash +# 1. 获取 PR 信息 +echo "步骤 1: 获取 PR 信息" +gitlink-cli pr +view --id $PR_ID --format json | jq '{id: .data.id, title: .data.title, author: .data.author.login}' + +# 2. 获取文件列表 +echo "步骤 2: 获取文件列表" +gitlink-cli pr +files --id $PR_ID --format json | jq '.data.files[] | {filename: .filename, changes: .changes}' + +# 3. 获取 diff +echo "步骤 3: 获取 diff" +gitlink-cli pr +diff --id $PR_ID --format json | jq -r '.data.diff' | head -50 + +# 4. 手动分析代码(需要人工查看) +echo "步骤 4: 手动分析代码" +echo "请查看上面的代码变更,识别潜在问题" +``` + +**预期结果**: +- ✅ 每个步骤都能成功执行 +- ✅ 数据格式正确 +- ✅ 可以看到代码变更内容 + +--- + +## 🧪 Level 3: AI Agent 集成测试 + +### 测试 3.1: Claude Code 基础测试 + +**目的**: 验证 Claude Code 可以识别和使用此 Skill。 + +**在 Claude Code 中执行**: + +``` +用户: 我需要审查一个 PR,PR 编号是 123 + +[预期行为]: +1. Claude Code 应该识别需要使用 gitlink-code-review Skill +2. 自动读取 SKILL.md 了解如何操作 +3. 执行正确的命令序列 +4. 生成审查报告 +``` + +**验证点**: +- ✅ AI 识别到需要使用 gitlink-code-review Skill +- ✅ AI 执行了 `pr +view`, `pr +files`, `pr +diff` 命令 +- ✅ AI 生成了结构化的审查报告 +- ✅ 提供了可操作的建议 + +### 测试 3.2: Claude Code 场景测试 + +**场景 1: 基础审查** + +``` +用户: 审查 PR #123 + +[预期输出]: +- 获取 PR 信息 +- 分析代码变更 +- 生成审查报告 +- 提供改进建议 +``` + +**场景 2: 重点安全审查** + +``` +用户: 审查 PR #456,重点关注安全问题 + +[预期输出]: +- 获取 PR 信息 +- 重点分析安全问题 +- 列出发现的安全漏洞 +- 提供修复建议 +``` + +**场景 3: 自动添加评论** + +``` +用户: 审查 PR #789 并添加评论到 PR + +[预期输出]: +- 获取 PR 信息 +- 分析代码 +- 生成报告 +- 添加评论到 PR +``` + +**验证点**: +- ✅ AI 根据用户请求调整审查重点 +- ✅ AI 正确执行相应的命令 +- ✅ 输出格式符合预期 +- ✅ 提供了有价值的建议 + +### 测试 3.3: 提示词测试 + +**目的**: 验证 Skill 中的提示词是否有效。 + +**测试提示词**: +``` +请分析以下 PR 的代码变更,检查代码质量、安全性和性能问题。 + +PR 数据: +[粘贴 test_pr_view.json, test_pr_files.json, test_pr_diff.json 的内容] + +请以 JSON 格式输出审查报告,包含: +- overall_assessment: 总体评估 +- issues: 问题列表 +- positive_notes: 优秀实践 +- recommendations: 改进建议 +``` + +**验证点**: +- ✅ AI 理解任务要求 +- ✅ AI 分析代码变更 +- ✅ 输出格式符合要求 +- ✅ 发现了真实的问题 + +--- + +## 🧪 Level 4: 完整工作流测试 + +### 测试 4.1: 基础审查工作流 + +**目的**: 验证 `basic-review-workflow.md` 中的工作流。 + +**步骤**: +```bash +# 按照基础审查工作流执行 +PR_ID=<测试PR编号> + +# 步骤 1: 获取 PR 详情 +gitlink-cli pr +view --id $PR_ID --format json + +# 步骤 2: 获取变更文件列表 +gitlink-cli pr +files --id $PR_ID --format json + +# 步骤 3: 获取 diff 内容 +gitlink-cli pr +diff --id $PR_ID --format json + +# 步骤 4: 浏览代码变更 +gitlink-cli pr +diff --id $PR_ID --format json | jq -r '.data.diff' | less +``` + +**验证点**: +- ✅ 所有步骤都能成功执行 +- ✅ 数据格式正确 +- ✅ 可以看到代码变更 + +### 测试 4.2: 全面审查工作流 + +**目的**: 验证 `comprehensive-review-workflow.md` 中的工作流。 + +**步骤**: +```bash +# 按照全面审查工作流执行 +PR_ID=<测试PR编号> + +# 1. 获取数据 +gitlink-cli pr +view --id $PR_ID --format json > pr_info.json +gitlink-cli pr +files --id $PR_ID --format json > pr_files.json +gitlink-cli pr +diff --id $PR_ID --format json > pr_diff.json + +# 2. 数据预处理 +cat pr_files.json | jq '.data.files | map(select(.changes > 10))' > main_changes.json + +# 3. 组织分析数据 +cat > analysis_input.json <500 行变更) +gitlink-cli pr +list --format json | \ + jq '.data[] | select(.additions > 500) | {id: .id, additions: .additions}' + +# 测试获取 diff +gitlink-cli pr +diff --id <大型PR编号> --format json | \ + jq '.data | length' +``` + +**验证点**: +- ✅ 能够处理大型 diff +- ✅ 不会超时或崩溃 +- ✅ 输出格式正确 + +### 测试 5.2: 错误处理测试 + +**目的**: 测试错误情况的处理。 + +**测试不存在的 PR**: +```bash +gitlink-cli pr +view --id 999999 --format json +# 应该返回错误信息 +``` + +**测试无权限的 PR**: +```bash +gitlink-cli pr +view --id <私有PR编号> --format json +# 应该返回 403 错误 +``` + +**验证点**: +- ✅ 错误信息清晰 +- ✅ 包含错误原因 +- ✅ 提供解决建议 + +### 测试 5.3: 不同文件类型测试 + +**目的**: 测试对不同文件类型的处理。 + +**步骤**: +```bash +# 查找包含不同文件类型的 PR +# Go 文件 +gitlink-cli pr +files --id $PR_ID --format json | \ + jq '.data.files[] | select(.filename | endswith(".go"))' + +# JavaScript 文件 +gitlink-cli pr +files --id $PR_ID --format json | \ + jq '.data.files[] | select(.filename | endswith(".js"))' + +# Python 文件 +gitlink-cli pr +files --id $PR_ID --format json | \ + jq '.data.files[] | select(.filename | endswith(".py"))' +``` + +**验证点**: +- ✅ 能够识别不同语言 +- ✅ 能够针对性分析 +- ✅ 建议符合语言特性 + +--- + +## 📊 测试报告模板 + +### 测试执行记录 + +```markdown +# gitlink-code-review Skill 测试报告 + +**测试日期**: 2026-06-12 +**测试人员**: [姓名] +**测试环境**: [环境描述] + +## 测试结果总览 + +| 测试级别 | 通过/总数 | 状态 | +|---------|----------|------| +| Level 1 | ?/? | ⏳ | +| Level 2 | ?/? | ⏳ | +| Level 3 | ?/? | ⏳ | +| Level 4 | ?/? | ⏳ | +| Level 5 | ?/? | ⏳ | + +## 详细测试结果 + +### Level 1: 文档结构验证 + +- [ ] 测试 1.1: 检查必需文件存在 - ⏳ +- [ ] 测试 1.2: 验证 Frontmatter 格式 - ⏳ +- [ ] 测试 1.3: 验证文档引用 - ⏳ + +### Level 2: 基础功能测试 + +- [ ] 测试 2.1: 验证 gitlink-cli PR 命令 - ⏳ +- [ ] 测试 2.2: 验证数据解析 - ⏳ +- [ ] 测试 2.3: 手动代码审查模拟 - ⏳ + +### Level 3: AI Agent 集成测试 + +- [ ] 测试 3.1: Claude Code 基础测试 - ⏳ +- [ ] 测试 3.2: Claude Code 场景测试 - ⏳ +- [ ] 测试 3.3: 提示词测试 - ⏳ + +### Level 4: 完整工作流测试 + +- [ ] 测试 4.1: 基础审查工作流 - ⏳ +- [ ] 测试 4.2: 全面审查工作流 - ⏳ +- [ ] 测试 4.3: 自动审查工作流 - ⏳ + +### Level 5: 边界情况测试 + +- [ ] 测试 5.1: 大型 PR 测试 - ⏳ +- [ ] 测试 5.2: 错误处理测试 - ⏳ +- [ ] 测试 5.3: 不同文件类型测试 - ⏳ + +## 发现的问题 + +### 问题 1 +- **描述**: [问题描述] +- **严重性**: [高/中/低] +- **状态**: [待修复/已修复] + +## 建议和改进 + +### 建议 1 +- **描述**: [建议描述] +- **优先级**: [高/中/低] + +## 总结 + +**总体评估**: [通过/不通过] +**评分**: [?/100] +**建议**: [是否建议投入使用] +``` + +--- + +## 🎯 快速测试脚本 + +为了快速验证 Skill 的基本功能,可以使用以下脚本: + +```bash +#!/bin/bash +# quick-test.sh - 快速测试脚本 + +set -e + +echo "=== gitlink-code-review Skill 快速测试 ===" + +# 配置 +PR_ID=${1:-<默认PR编号>} +OWNER=${2:-Gitlink} +REPO=${3:-forgeplus} + +echo "测试 PR: $PR_ID" +echo "" + +# Level 1: 文档检查 +echo "Level 1: 检查文档..." +if [ -f "SKILL.md" ] && [ -f "README.md" ] && [ -f "REFERENCE.md" ]; then + echo "✅ 文档文件存在" +else + echo "❌ 缺少必需文档" + exit 1 +fi + +# Level 2: 功能测试 +echo "" +echo "Level 2: 测试 gitlink-cli 命令..." + +# 测试 pr +view +if gitlink-cli pr +view --id $PR_ID --format json | jq -e '.ok == true' > /dev/null; then + echo "✅ pr +view 正常" +else + echo "❌ pr +view 失败" + exit 1 +fi + +# 测试 pr +files +if gitlink-cli pr +files --id $PR_ID --format json | jq -e '.ok == true' > /dev/null; then + echo "✅ pr +files 正常" +else + echo "❌ pr +files 失败" + exit 1 +fi + +# 测试 pr +diff +if gitlink-cli pr +diff --id $PR_ID --format json | jq -e '.ok == true' > /dev/null; then + echo "✅ pr +diff 正常" +else + echo "❌ pr +diff 失败" + exit 1 +fi + +echo "" +echo "=== 快速测试完成 ===" +echo "✅ 所有基础测试通过" +echo "" +echo "下一步:" +echo "1. 在 Claude Code 中测试 AI 集成" +echo "2. 执行完整工作流测试" +echo "3. 验证边界情况" +``` + +**使用方法**: +```bash +chmod +x quick-test.sh +./quick-test.sh +``` + +--- + +## 📞 获取帮助 + +如果测试过程中遇到问题: + +1. **查看文档** + - [SKILL.md](SKILL.md) - 技能总览 + - [README.md](README.md) - 使用说明 + - [REFERENCE.md](REFERENCE.md) - API 参考 + +2. **检查配置** + ```bash + # 检查 gitlink-cli 版本 + gitlink-cli --version + + # 检查认证状态 + gitlink-cli auth status + ``` + +3. **查看错误日志** + ```bash + # 启用调试模式 + gitlink-cli pr +view --id $PR_ID --format json --debug + ``` + +--- + +## 🎓 测试最佳实践 + +### 1. 渐进式测试 + +- 从 Level 1 开始,逐步升级 +- 每个级别通过后再进行下一级 +- 记录每个测试的结果 + +### 2. 真实场景测试 + +- 使用真实的 PR 进行测试 +- 覆盖不同类型的 PR(功能、修复、重构) +- 测试不同大小的 PR + +### 3. 持续改进 + +- 记录发现的问题 +- 及时修复和改进 +- 定期重新测试 + +--- + +**测试完成后,请填写测试报告并评估 Skill 是否可以投入使用。** + +*最后更新: 2026-06-12* diff --git a/skills/gitlink-compliance/SKILL.md b/skills/gitlink-compliance/SKILL.md new file mode 100644 index 0000000..9db6bbf --- /dev/null +++ b/skills/gitlink-compliance/SKILL.md @@ -0,0 +1,108 @@ +--- +name: gitlink-compliance +version: 1.0.0 +description: "许可证合规检查与敏感信息扫描:检查仓库的许可证合规性、依赖许可证、硬编码密钥、PII 泄露、内部 URL 暴露等风险。当用户需要审计仓库安全性或合规性时触发。" +metadata: + requires: + bins: ["git"] + cliHelp: "gitlink-cli compliance --help" +--- + +# gitlink-compliance(合规检查) + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) + +**CRITICAL — 本技能仅执行只读扫描,不修改任何文件,不向外部发送数据。** +**CRITICAL — 发现敏感信息时只报告文件路径和行号,禁止输出匹配到的原文内容。** + +## AI 代理执行流程 + +触发本技能后,**必须先询问用户要扫描哪些模块**,不要直接执行全部扫描。 + +``` +请选择要扫描的模块(可多选): + + A. 许可证合规 → 检查 LICENSE 文件、声明一致性、版权头 + B. 依赖许可证 → 检查第三方依赖的许可证类型和兼容性 + C. 敏感信息 → 扫描硬编码密钥、Token、密码、私钥 + D. PII 与暴露面 → 扫描邮箱、手机号、内网 IP、内部域名 + E. 敏感词汇 → 扫描军事、党政、监管、国密等敏感用语 + ALL. 全部扫描 → 依次执行以上五项 +``` + +用户选择后,**只读取对应模块的参考文档**,不加载全部: + +| 用户选择 | 读取的参考文档 | +|----------|---------------| +| A | `references/check-license.md` | +| B | `references/check-deps.md` | +| C | `references/check-secrets.md` | +| D | `references/check-pii.md` | +| E | `references/check-sensitive-vocab.md` | +| ALL | 以上全部 | + +## Shortcuts + +| Shortcut | 说明 | +|----------|------| +| `compliance +scan` | 执行全部五项检查 | +| `compliance +license` | 许可证合规检查 | +| `compliance +deps` | 依赖许可证检查 | +| `compliance +secrets` | 敏感信息扫描(密钥/Token/密码) | +| `compliance +exposure` | PII 与暴露面扫描 | +| `compliance +vocab` | 敏感词汇扫描(军事/党政/监管) | + +## 使用示例 + +```bash +# 完整扫描,需要阅读reference里的全部内容 +gitlink-cli compliance +scan + +# 单独扫描某一项,只需要阅读reference里和选择名称一样的就可以文件就可以 +gitlink-cli compliance +license +gitlink-cli compliance +deps +gitlink-cli compliance +secrets +gitlink-cli compliance +exposure +gitlink-cli compliance +vocab + +# JSON 输出(供脚本或 AI 代理使用) +gitlink-cli compliance +scan --format json +``` + +## 五大检查模块速查 + +| 模块 | 覆盖范围 | 参考文档 | +|------|----------|----------| +| 许可证合规 | LICENSE 文件、声明一致性、版权头 | `references/check-license.md` | +| 依赖许可证 | 第三方依赖类型、Copyleft 传染 | `references/check-deps.md` | +| 敏感信息 | Token/密钥/密码/私钥/Debug泄露 | `references/check-secrets.md` | +| PII与暴露面 | 邮箱/手机号/内网IP/内部域名 | `references/check-pii.md` | +| 敏感词汇 | 军事/党政/内部系统/监管/国密 | `references/check-sensitive-vocab.md` | + +## 扫描排除项 + +默认跳过: + +| 类型 | 排除项 | +|------|--------| +| 目录 | `vendor/` `node_modules/` `.git/` `.claude/` `skills/` | +| 路径前缀 | `shortcuts/compliance`(避免自扫描) | +| 二进制文件 | `*.exe` `*.dll` `*.so` `*.dylib` `*.bin` | +| 图片/文档 | `*.jpg` `*.jpeg` `*.png` `*.gif` `*.ico` `*.svg` `*.pdf` | +| 压缩包 | `*.zip` `*.gz` `*.tgz` | +| 锁文件 | `go.sum` `package-lock.json` | + +## 误报规避机制 + +扫描器内置三种误报规避: + +1. **目录排除** — 跳过 `skills/`(参考文档中的示例不属于项目源码)、`.claude/` 等配置目录 +2. **自扫描排除** — `shortcuts/compliance` 下的规则定义文件不参与扫描,避免规则模式匹配自身 +3. **行级去重** — 同一行匹配多条规则时,只报告严重度最高的那一条,避免重复报告 + +## 注意事项 + +- 只读操作,不修改任何文件 +- 敏感信息只报告位置,不展示内容 +- 所有扫描在本地完成,不跨仓库 +- 部分匹配可能是误报(示例代码、测试数据),需人工判断 diff --git a/skills/gitlink-compliance/references/check-deps.md b/skills/gitlink-compliance/references/check-deps.md new file mode 100644 index 0000000..3a6cfc0 --- /dev/null +++ b/skills/gitlink-compliance/references/check-deps.md @@ -0,0 +1,95 @@ +# 依赖许可证检查 + +解析项目依赖,识别每个第三方包的许可证类型,标记潜在冲突。 + +## 检查步骤 + +### 1. Go 依赖列表提取 + +```bash +# 提取直接依赖 +grep -E '^\s+github\.com|^\s+golang\.org|^\s+gopkg\.in' go.mod | awk '{print $1}' + +# 提取间接依赖 +grep 'indirect' go.mod | awk '{print $1}' +``` + +### 2. 许可证识别 + +对于每个依赖,通过查找其源代码中的 LICENSE 文件或 go.mod 注释来判断许可证。 + +优先级: +1. 依赖包的 LICENSE / LICENSE.md / LICENSE.txt 文件 +2. 依赖包的 go.mod 中 `// License:` 注释 +3. 包文档站点(如 pkg.go.dev)上的元数据 +4. GitHub 仓库元数据中的 `license` 字段 + +### 3. Copyleft 传染性检查 + +重点标记以下许可证,它们可能与宽松型项目许可证(MIT、Apache-2.0、BSD、MulanPSL)不兼容: + +| 许可证 | 传染性 | 兼容性 | +|--------|--------|--------| +| GPL-2.0 | 强传染 | 与宽松型许可证不兼容 | +| GPL-3.0 | 强传染 | 可与 Apache-2.0 单向兼容(GPLv3 可使用 Apache-2.0 代码,反之不行) | +| AGPL-3.0 | 极强传染(含网络使用) | 与所有宽松型许可证不兼容 | +| LGPL-2.1 | 弱传染(仅修改库本身需开源) | 动态链接时兼容 | +| LGPL-3.0 | 弱传染 | 动态链接时兼容 | +| MPL-2.0 | 文件级传染 | 与宽松型许可证兼容 | + +### 4. 许可证缺失检查 + +检查每个依赖是否明确声明了许可证: + +```bash +# 示例:检查某个依赖的许可证 +go mod download -json github.com/spf13/cobra 2>/dev/null | grep Dir +# 然后查看 $Dir/LICENSE* +``` + +## Go 依赖许可证速查表 + +以下是 gitlink-cli 项目实际使用的依赖及其许可证: + +| 依赖 | 许可证 | 类型 | +|------|--------|------| +| `github.com/spf13/cobra` | Apache-2.0 | 宽松型 | +| `github.com/spf13/pflag` | BSD-3-Clause | 宽松型 | +| `github.com/zalando/go-keyring` | MIT | 宽松型 | +| `golang.org/x/term` | BSD-3-Clause | 宽松型 | +| `golang.org/x/sys` | BSD-3-Clause | 宽松型 | +| `gopkg.in/yaml.v3` | MIT | 宽松型 | +| `github.com/danieljoos/wincred` | MIT | 宽松型 | +| `github.com/godbus/dbus/v5` | BSD-2-Clause | 宽松型 | +| `github.com/inconshreveable/mousetrap` | Apache-2.0 | 宽松型 | +| `github.com/kr/pretty` | MIT | 宽松型 | +| `gopkg.in/check.v1` | BSD-2-Clause | 宽松型 | + +## 检查命令 + +```bash +# 列出所有依赖 +go list -m all 2>/dev/null + +# 检查每个依赖的许可证 +go list -m -json all 2>/dev/null | grep -E '"Path"|"Dir"' +``` + +## 输出示例 + +``` +== 依赖许可证报告 == + +直接依赖: 5 个 +间接依赖: 6 个 +Copyleft 依赖: 0 个 ✓ +许可证缺失: 0 个 ✓ + +许可分布: + MIT: 5 (45%) + BSD-3-Clause: 3 (27%) + Apache-2.0: 2 (18%) + BSD-2-Clause: 1 (9%) + +结论: 所有依赖均为宽松型许可证,与项目 Mulan PSL v2 兼容,无传染风险。 +``` diff --git a/skills/gitlink-compliance/references/check-license.md b/skills/gitlink-compliance/references/check-license.md new file mode 100644 index 0000000..cd6ab19 --- /dev/null +++ b/skills/gitlink-compliance/references/check-license.md @@ -0,0 +1,97 @@ +# 许可证合规检查 + +检查仓库的 LICENSE 文件完整性、一致性以及源码版权声明。 + +## 检查步骤 + +### 1. LICENSE 文件检查 + +```bash +# 检查 LICENSE 文件是否存在 +test -f LICENSE && echo "LICENSE 存在" || echo "缺少 LICENSE 文件" + +# 读取 LICENSE 内容,识别许可证类型 +cat LICENSE +``` + +### 2. 许可证类型识别 + +通过 LICENSE 文本关键词自动判断: + +| 关键词 | 许可证类型 | +|--------|-----------| +| `Mulan Permissive Software License` | Mulan PSL v2 | +| `Apache License, Version 2.0` | Apache-2.0 | +| `Permission is hereby granted, free of charge` (且无 copyleft 条款) | MIT | +| `GNU GENERAL PUBLIC LICENSE` + `Version 3` | GPL-3.0 | +| `GNU GENERAL PUBLIC LICENSE` + `Version 2` | GPL-2.0 | +| `GNU AFFERO GENERAL PUBLIC LICENSE` | AGPL-3.0 | +| `GNU LESSER GENERAL PUBLIC LICENSE` | LGPL | +| `Redistribution and use in source and binary forms` + 3 条款 | BSD-3-Clause | +| `Redistribution and use in source and binary forms` + 2 条款 | BSD-2-Clause | +| `Mozilla Public License` | MPL-2.0 | + +### 3. 声明一致性检查 + +```bash +# 检查 package.json 中的 license 字段 +grep -E '"license"\s*:' package.json 2>/dev/null + +# 检查 go.mod 是否声明了许可证(Go 社区通常依赖 LICENSE 文件) +test -f go.mod && echo "Go 项目 — 许可证以 LICENSE 文件为准" + +# 检查 npm 子包的 license 声明 +grep -E '"license"\s*:' npm/package.json 2>/dev/null +``` + +### 4. 占位符检查 + +```bash +# 检查 LICENSE 中是否有未填写的占位符 +grep -n '\[year\]\|\[Year\]\|\[name of copyright holder\]\|\[yyyy\]' LICENSE +``` + +### 5. 源码版权头检查 + +```bash +# 检查 Go 源文件是否有版权声明(前 10 行) +find . -name "*.go" -not -path "./vendor/*" | while read f; do + if ! head -10 "$f" | grep -qiE 'copyright|license|SPDX-License-Identifier'; then + echo "缺少版权头: $f" + fi +done +``` + +## 输出示例 + +``` +== 许可证合规报告 == + +[✓] LICENSE 文件: Mulan PSL v2(存在) +[✗] npm/package.json: 声明 Apache-2.0(与根 LICENSE 不一致) +[✗] 占位符: LICENSE 中 [Year] 和 [name of copyright holder] 未填写 +[✗] 源码版权头: 25 个 .go 文件中 25 个缺少版权声明 +``` + +## Mulan PSL v2 版权头模板 + +``` +Copyright (c) [Year] [name of copyright holder] +Mulan Permissive Software License,Version 2 + +``` + +每份源文件开头建议附加上述声明。 + +## 兼容性矩阵 + +| 项目许可证 | 可使用 MIT | 可使用 Apache-2.0 | 可使用 BSD | 可使用 GPL | 可使用 MulanPSL | +|-----------|-----------|-------------------|-----------|-----------|----------------| +| MIT | ✓ | ✓ | ✓ | ✓ | ✓ | +| Apache-2.0 | ✓ | ✓ | ✓ | ✗ (仅 GPLv3) | ✓ | +| BSD-3-Clause | ✓ | ✓ | ✓ | ✗ | ✓ | +| BSD-2-Clause | ✓ | ✓ | ✓ | ✗ | ✓ | +| Mulan PSL v2 | ✓ | ✓ | ✓ | ✗ | ✓ | +| GPL-3.0 | ✗ | ✓ | ✗ | ✓ | ✗ | +| GPL-2.0 | ✗ | ✗ | ✗ | ✓ | ✗ | +| AGPL-3.0 | ✗ | ✓ (仅 AGPLv3+) | ✗ | ✗ (GPLv3 → AGPLv3 OK) | ✗ | diff --git a/skills/gitlink-compliance/references/check-pii.md b/skills/gitlink-compliance/references/check-pii.md new file mode 100644 index 0000000..e21558d --- /dev/null +++ b/skills/gitlink-compliance/references/check-pii.md @@ -0,0 +1,115 @@ +# PII 与暴露面扫描 + +扫描仓库中可能泄露的个人身份信息(PII)和内部基础设施信息。 + +**安全原则:扫描时只报告文件路径和行号,禁止输出匹配到的原始内容。** + +## PII 扫描规则 + +### P-001: 邮箱地址 + +```bash +# 搜索邮箱地址(排除 vendor 和 node_modules) +git grep -n -E '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}' \ + -- '*.go' '*.js' '*.ts' '*.md' '*.yaml' '*.yml' '*.json' \ + ':!vendor/' ':!node_modules/' 2>/dev/null +``` + +### P-002: 手机号(中国大陆) + +```bash +git grep -n -E '\b1[3-9][0-9]{9}\b' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null +``` + +### P-003: 身份证号(中国大陆) + +```bash +git grep -n -E '\b[0-9]{17}[0-9Xx]\b' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null +``` + +## 暴露面扫描规则 + +### E-001: 内网 IPv4 地址 + +```bash +# 搜索内网 IP 地址 +git grep -n -E '\b10\.[0-9]+\.[0-9]+\.[0-9]+\b' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null +git grep -n -E '\b172\.(1[6-9]|2[0-9]|3[01])\.[0-9]+\.[0-9]+\b' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null +git grep -n -E '\b192\.168\.[0-9]+\.[0-9]+\b' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null +``` + +### E-002: localhost / 127.0.0.1 + +```bash +git grep -n -E 'localhost:[0-9]+|127\.0\.0\.1:[0-9]+' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null +``` + +### E-003: 内部域名 / 测试域名 + +```bash +git grep -n -E '\.local\b|\.internal\b|trustie\.net' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null +``` + +### E-004: .devops 配置中的裸 IP + +```bash +# 检查 CI/CD 配置中的 IP 地址 +find .devops -type f -name '*.yml' -exec grep -n -E '\b[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+\b' {} + 2>/dev/null +``` + +### E-005: 公网裸 IP + +```bash +# 搜索非 localhost 的公网 IP(排除已知公开 IP 范围) +git grep -n -oE '\b([0-9]{1,3}\.){3}[0-9]{1,3}\b' -- ':/' ':!vendor/' ':!node_modules/' \ + | grep -v '127\.0\.0\.1' \ + | grep -v '0\.0\.0\.0' \ + | grep -v '255\.255\.255\.255' 2>/dev/null +``` + +## 已知可忽略项 + +以下匹配已知不会造成安全问题: + +| 文件 | 内容 | 原因 | +|------|------|------| +| `npm/package.json` | `support@gitlink.org.cn` | 公开支持邮箱 | +| `doc/gitlink_api_reference.md` | `yystopf@163.com` | API 文档示例数据 | +| `doc/gitlink_api_reference.md` | `localhost:3000/api/` | 开发环境 URL | +| `.devops/gitlink-cli-autodeploy.yml` | `121.41.222.0` | 部署服务器公网 IP | + +## 执行完整扫描 + +```bash +echo "=== P-001: 邮箱 ===" +git grep -n -E '[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}' \ + -- '*.go' '*.js' '*.ts' '*.md' '*.yaml' '*.yml' '*.json' \ + ':!vendor/' ':!node_modules/' ':!doc/' 2>/dev/null + +echo "=== P-002: 手机号 ===" +git grep -n -E '\b1[3-9][0-9]{9}\b' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null + +echo "=== E-001: 内网 IP ===" +git grep -n -E '\b(10\.|172\.(1[6-9]|2[0-9]|3[01])\.|192\.168\.)' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null + +echo "=== E-002: localhost ===" +git grep -n -E 'localhost:[0-9]+|127\.0\.0\.1:[0-9]+' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null + +echo "=== E-003: 内部域名 ===" +git grep -n -E '\.local\b|\.internal\b|trustie\.net' -- ':/' ':!vendor/' ':!node_modules/' 2>/dev/null +``` + +## 排除列表 + +以下内容不标记: + +| 模式 | 原因 | +|------|------| +| `user@example.com` | RFC 示例邮箱 | +| `test@test.com` | 测试用邮箱 | +| `noreply@*.com` | 无回复邮箱 | +| `support@*` | 公开支持邮箱 | +| `gitlink.org.cn` | GitLink 官方公开域名 | +| `github.com` | GitHub 公开域名 | +| `cdn.jsdelivr.net` | 公共 CDN | +| `.github/workflows/` 中的 `${{ secrets.* }}` | CI/CD 变量引用 | diff --git a/skills/gitlink-compliance/references/check-secrets.md b/skills/gitlink-compliance/references/check-secrets.md new file mode 100644 index 0000000..7a60e31 --- /dev/null +++ b/skills/gitlink-compliance/references/check-secrets.md @@ -0,0 +1,103 @@ +# 敏感信息扫描 + +扫描仓库中可能存在的硬编码凭据、密钥、Token 等敏感信息。 + +**安全原则:扫描时只报告文件路径和行号,禁止输出匹配到的原始内容。** + +## 扫描规则 + +### S-001: Token 作为 URL 查询参数 + +```bash +# 搜索 access_token 等作为 URL 参数传递的代码 +git grep -n 'access_token\|private_token' -- '*.go' '*.js' '*.ts' '*.py' '*.sh' '*.yaml' '*.yml' +``` + +### S-002: 硬编码密码 + +```bash +# 搜索硬编码的 password= 或 passwd= +git grep -n -E 'password\s*[:=]\s*"[^"]{1,}"' -- '*.go' '*.js' '*.ts' '*.py' '*.yaml' '*.yml' '*.json' +git grep -n -E 'passwd\s*[:=]\s*"[^"]{1,}"' -- '*.go' '*.js' '*.ts' '*.py' +``` + +### S-003: 硬编码 API Key + +```bash +git grep -n -iE 'api[_-]?key\s*[:=]\s*"[a-zA-Z0-9_-]{8,}"' -- '*.go' '*.js' '*.ts' '*.py' '*.yaml' '*.yml' +``` + +### S-004: 私钥文件 + +```bash +# 搜索私钥内容 +git grep -n 'BEGIN.*PRIVATE KEY' -- ':/' 2>/dev/null || echo "未发现私钥" + +# 搜索私钥文件 +find . -type f \( -name "*.pem" -o -name "*.key" -o -name "*.p12" -o -name "*.pfx" \) \ + -not -path "./vendor/*" -not -path "./node_modules/*" 2>/dev/null +``` + +### S-005: 硬编码 JWT / 长 Token + +```bash +git grep -n -E 'token\s*[:=]\s*"eyJ[A-Za-z0-9_-]{20,}"' -- '*.go' '*.js' '*.ts' '*.py' '*.yaml' '*.yml' 2>/dev/null +git grep -n -E 'token\s*[:=]\s*"[A-Za-z0-9+/=_-]{32,}"' -- '*.go' '*.js' '*.ts' '*.py' 2>/dev/null +``` + +### S-006: 硬编码 Secret + +```bash +git grep -n -iE 'secret\s*[:=]\s*"[^"]{8,}"' -- '*.go' '*.js' '*.ts' '*.py' '*.yaml' '*.yml' 2>/dev/null +``` + +### S-007: 凭据配置文件 + +```bash +# 搜索可能包含凭据的配置文件 +find . -type f \( -name ".env" -o -name "credentials" -o -name "*.pem" \) \ + -not -path "./vendor/*" -not -path "./node_modules/*" -not -path "./.git/*" 2>/dev/null +``` + +### S-008: Debug 输出中的 Token 泄露 + +```bash +git grep -n -E '(fmt|log)\.(Print|Debug|Info).*[Tt]oken' -- '*.go' 2>/dev/null +git grep -n -E 'console\.log.*[Tt]oken' -- '*.js' '*.ts' 2>/dev/null +``` + +### S-009: CI/CD 明文 Fallback + +```bash +# 检查 CI/CD 配置中的密钥是否有明文默认值 +grep -n -E 'secrets\.[A-Z_]+\s*\|\|' .github/workflows/*.yml .devops/*.yml 2>/dev/null +``` + +### S-010: 数据库连接串 + +```bash +git grep -n -E '(mongodb|mysql|postgres|postgresql|redis|jdbc)://[^@]*@' -- '*.go' '*.js' '*.ts' '*.yaml' '*.yml' '*.json' 2>/dev/null +``` + +## 执行完整扫描 + +```bash +# 汇总执行所有扫描规则 +echo "=== S-001: URL Token 参数 ===" && git grep -n 'access_token\|private_token' -- '*.go' '*.js' '*.ts' '*.py' '*.sh' '*.yaml' '*.yml' 2>/dev/null +echo "=== S-004: 私钥 ===" && git grep -n 'BEGIN.*PRIVATE KEY' -- ':/' 2>/dev/null +echo "=== S-007: 凭据文件 ===" && find . -type f \( -name ".env" -o -name "credentials" -o -name "*.pem" \) -not -path "./vendor/*" -not -path "./node_modules/*" -not -path "./.git/*" 2>/dev/null +echo "=== S-008: Debug Token ===" && git grep -n -E '(fmt|log)\.(Print|Debug|Info).*[Tt]oken' -- '*.go' 2>/dev/null +echo "=== S-010: 数据库连接串 ===" && git grep -n -E '(mongodb|mysql|postgres|postgresql|redis)://[^@]*@' -- ':/' 2>/dev/null +``` + +## 排除列表 + +以下匹配不被视为安全问题: + +| 模式 | 原因 | 示例 | +|------|------|------| +| `${{ secrets.XXX }}` | CI/CD 变量引用 | GitHub Actions / DevOps Pipeline | +| `os.Getenv("XXX")` | 环境变量读取 | `os.Getenv("GITLINK_TOKEN")` | +| `keyring.Get("xxx")` | OS Keychain 调用 | `keyring.Get("gitlink-cli", "token")` | +| `--secret` flag 定义 | CLI flag 参数定义 | `cmd.Flags().String("secret", "", "secret")` | +| 文档中的 `example.com` | 示例域名 | `https://example.com/webhook` | diff --git a/skills/gitlink-compliance/references/check-sensitive-vocab.md b/skills/gitlink-compliance/references/check-sensitive-vocab.md new file mode 100644 index 0000000..8b9975c --- /dev/null +++ b/skills/gitlink-compliance/references/check-sensitive-vocab.md @@ -0,0 +1,139 @@ +# 敏感词汇扫描 + +扫描仓库中可能出现的敏感词汇,包括军事、政务、内部系统等受监管或不宜公开的用语。 + +**安全原则:扫描时只报告文件路径和行号,禁止输出匹配到的原文上下文。** + +## 敏感词汇分类 + +### C-001: 军事相关 + +涉及军事单位、装备、行动等词汇。 + +搜索模式: +``` +军|部队|军区|武装|国防|武器|装备|弹药|导弹|雷达|坦克|舰艇|战机|潜艇|航母|核|火箭|弹药库|靶场|兵工厂|军工厂|军事基地|作战|演习|动员|部署|调防|驻地|番号|编制|勤务 +``` + +```bash +# 搜索军事敏感词汇 +git grep -n -E '军|部队|军区|武装|国防|武器|弹药|导弹|雷达|舰艇|战机|潜艇|航母|核武器|火箭军|弹药库|靶场|兵工厂|军工厂|军事基地|作战指挥|军事演习|战备|动员令|兵力部署|调防|驻地|番号|部队编制|后勤保障|勤务' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null +``` + +### C-002: 政府/党政机关 + +涉及党政机关、政府内部系统等词汇。 + +搜索模式: +``` +中央|国务院|军委|部委|省委|市委|党政|机要|保密|机密|绝密|秘密|内部文件|红头|批复|批示|内参|办公厅|机要局|保密局|国安|公安内网|政务内网|党政机关|公务 +``` + +```bash +# 搜索党政敏感词汇 +git grep -n -E '中央委员会|国务院|中央军委|部委|省委|市委|党政机关|机要局|保密局|国家安全|公安内网|政务内网|党政内网|红头文件|内部文件|机要文件|绝密|机密文件|内参|批复|批示件|办公厅' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null +``` + +### C-003: 内部系统标识 + +涉及内部使用的系统名称、代号、项目名等。 + +搜索模式: +``` +内部系统|内部平台|内网|专网|涉密|非密|脱密|密码机|加密机|身份认证|安全审计|堡垒机|防火墙规则|入侵检测|安全监测 +``` + +```bash +# 搜索内部系统标识 +git grep -n -E '内部系统|内部平台|内网地址|专网|涉密|非密|脱密处理|密码机|加密机|堡垒机|防火墙规则|入侵检测系统|安全监测平台' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null +``` + +### C-004: 监管/合规敏感词 + +涉及金融、医疗、数据隐私等受监管领域。 + +搜索模式: +``` +反洗钱|征信|个人隐私|数据出境|跨境传输|敏感数据|涉密数据|关键信息基础设施|网络安全等级保护|等保|密评 +``` + +```bash +# 搜索监管敏感词 +git grep -n -E '反洗钱|征信系统|个人隐私数据|数据出境|跨境数据传输|敏感个人信息|涉密数据|关键信息基础设施|网络安全等级|等保三级|密评|商用密码' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null +``` + +### C-005: 公司/组织敏感信息 + +涉及内部项目代号、未公开产品名、客户信息等。 + +搜索模式: +``` +内部代号|项目代号|内部项目|未公开|NDA|保密协议|客户名单|白名单|内部API|私有接口|内部对接 +``` + +```bash +# 搜索组织敏感信息 +git grep -n -E '内部代号|项目代号|内部项目名称|未公开|NDA|保密协议|客户名录|内部API地址|私有接口地址|内部对接人|白名单IP' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null +``` + +### C-006: 密码学/安全产品名称 + +涉及商用密码产品、安全管控设备等受管制技术。 + +搜索模式: +``` +加密算法|国密|SM2|SM3|SM4|SM9|商密|密码模块|密码卡|密码机|VPN|防火墙|入侵防御|WAF|DLP|终端管控|上网行为|日志审计 +``` + +```bash +# 搜索密码学/安全产品 +git grep -n -E '国密算法|SM2|SM3|SM4|SM9|商用密码|密码模块|密码卡|密码机|防火墙设备|入侵防御系统|WAF|DLP|上网行为管理|日志审计系统|终端管控' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null +``` + +## 执行完整扫描 + +```bash +echo "=== C-001: 军事词汇 ===" +git grep -n -E '军|部队|军区|武装|国防|武器|弹药|导弹|雷达|舰艇|战机|潜艇|航母|核武器|火箭军' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null | grep -v 'package-lock' | grep -v '.exe' + +echo "=== C-002: 党政词汇 ===" +git grep -n -E '中央委员会|国务院|中央军委|部委|党政机关|机要局|保密局|国家安全|公安内网|政务内网|党政内网|红头文件|内部文件|机要文件|绝密|机密文件' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null + +echo "=== C-003: 内部系统 ===" +git grep -n -E '内部系统|内部平台|内网地址|专网|涉密|非密|脱密|密码机|加密机|堡垒机|入侵检测' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null + +echo "=== C-004: 监管合规 ===" +git grep -n -E '反洗钱|征信系统|个人隐私数据|数据出境|跨境传输|敏感个人信息|涉密数据|关键信息基础设施|网络安全等级|等保|密评|商用密码' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null + +echo "=== C-005: 组织敏感 ===" +git grep -n -E '内部代号|项目代号|内部项目|未公开|NDA|保密协议|客户名录|内部API|私有接口|内部对接' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null + +echo "=== C-006: 安全产品 ===" +git grep -n -E '国密|SM2|SM3|SM4|SM9|密码卡|防火墙设备|入侵防御|WAF|DLP|上网行为|日志审计|终端管控' -- ':/' ':!vendor/' ':!node_modules/' ':!.git/' 2>/dev/null +``` + +## 排除列表 + +以下匹配不视为敏感: + +| 匹配内容 | 原因 | +|----------|------| +| `--secret` CLI 参数定义 | 框架定义的参数名,非内容 | +| `secret_key`/`access_key` field tag | 结构体字段名,非值 | +| `军队文职/部队文职` 招聘信息 | 公开招录信息 | +| `国防科技大学` 等公开院校名 | 公开教育机构 | +| `国家网络安全法` 等法律引用 | 公开法律法规 | +| `go-keyring` 等依赖名中的 `key` | 第三方包名 | +| standard library `crypto/*` imports | Go 标准库导入 | +| `防火墙` 在 IT 产品描述中的正常使用 | 网络安全产品公开描述 | + +## 严重度分级 + +| 类别 | 严重度 | 说明 | +|------|--------|------| +| C-001 军事 | **高** | 军事相关内容可能触发合规审查 | +| C-002 党政 | **高** | 涉及政府内部系统标识 | +| C-003 内部系统 | 中 | 内部系统信息泄露 | +| C-004 监管 | 中 | 涉及受监管数据 | +| C-005 组织 | 低 | 组织内部信息 | +| C-006 安全产品 | 中 | 安全产品部署细节 | diff --git a/skills/gitlink-faq/SKILL.md b/skills/gitlink-faq/SKILL.md new file mode 100644 index 0000000..33eaff4 --- /dev/null +++ b/skills/gitlink-faq/SKILL.md @@ -0,0 +1,350 @@ +--- +name: gitlink-faq +version: 2.0.0 +description: "Issue 知识库(即 FAQ、常见问题):从项目 Issue 提取问题与答案,合并同类 Issue,生成结构化 FAQ 发布到 Wiki。注意——用户说的「知识库」「FAQ」「常见问题」都是指这个 skill,这三个词是同义词。触发场景:整理 Issue、归纳 Issue、生成/建立/更新/刷新知识库、生成 FAQ、总结常见问题、检查重复 Issue、查重。通用 Issue 操作(创建/查看/更新/关闭/评论等)请使用 gitlink-issue skill。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli issue --help" +--- + +# gitlink-faq(Issue 知识库 / FAQ) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 全流程自动执行,不要中途停下来问用户。从采集数据到发布 Wiki 一气呵成,最后告诉用户结果即可。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) + +## 核心概念 + +**「知识库」=「FAQ」=「常见问题」——这三个词是同一个东西。** 用户不管说哪个,都指的是这个 skill。 + +FAQ 知识库是一个 **"问题 → 答案"** 的集合,从项目 Issue 中提取真实用户遇到的问题和对应的解决方案。与统计分析报告不同,知识库的重点在于: + +- **收集 Issue 内容**:从 subject(标题)+ description(描述)+ journals(评论讨论)中提取问题和答案 +- **合并同类问题**:多个 Issue 描述的是同一个问题 → 合并为一条 FAQ,综合各方讨论给出完整答案 +- **按主题归类**:用主题标签(安装配置、CLI 命令、API 等)组织,方便检索 +- **可追溯来源**:每条 FAQ 附来源 Issue 编号,方便查看原始讨论 + +## 运行模式 + +| 模式 | 说明 | 典型触发语 | 需要认证 | +|------|------|------------|----------| +| **模式 A:生成/刷新 FAQ** | 采集全部 Issue → 提取 Q&A → 合并同类问题 → 按主题归类 → 发布 Wiki | "整理 Issue""归纳 Issue""总结常见问题""生成 FAQ""建立知识库""刷新知识库""更新知识库""生成知识库" | 是(发布 Wiki 需写入) | +| **模式 B:查找与查重** | 对指定 Issue/关键词,在知识库和历史 Issue 中查找相似项,判断是否重复 | "查重""有没有类似的 Issue""这个是不是有人报过""知识库里有没有" | 否(仅读取;评论需认证) | +| **模式 C:增量更新** | 读取已有 Wiki 页面 → 拉取新 Issue → 提取 Q&A 合并到现有知识库 → 更新 Wiki | "把这个 Issue 加到知识库""补充到知识库""同步到知识库""更新 FAQ""更新知识库" | 是(读取+写入 Wiki) | + +--- + +## 模式 A:生成/刷新 FAQ 知识库(6 步) + +当用户说 **"整理 Issue"、"归纳 Issue"、"总结常见问题"、"生成 FAQ"、"建立知识库"、"刷新知识库"、"更新知识库"** 等时,执行以下流程。**记住:「知识库」=「FAQ」,用户说知识库就是在让你执行这个 skill。** + +### 第 1 步:采集全部 Issue + +**CRITICAL**:不要仅采集 `--state closed`。GitLink 平台很多已解决的 Issue 不会被及时设置为"关闭"状态,只看 closed 会漏掉大量有分析价值的 Issue。 + +```bash +# 同时采集 open 和 closed,覆盖所有 Issue +gitlink-cli issue +list --state open --limit 100 --format json +gitlink-cli issue +list --state closed --limit 100 --format json +``` + +将两份列表合并去重,得到完整 Issue 集合。 + +采集量策略参见 [`references/gitlink-faq-collect.md`](references/gitlink-faq-collect.md)。 + +### 第 2 步:筛选 + 读取详情 + +先按标题粗筛,仅排除: +- 标题含 `[test]` / `测试` 的纯测试 Issue +- 标题为空或仅有占位符的 Issue + +其余 Issue **一律保留**,逐个读详情: + +```bash +gitlink-cli issue +view --number N --format json +``` + +提取字段:`subject`(标题)、`description`(描述)、`comment_journals_count`(评论数)。 + +**description 是核心分析源**: +- 部分 Issue 的 description 非常详细(含复现步骤、环境信息、修复建议) +- description 的质量直接决定能提取出什么质量的 Q&A +- `comment_journals_count` 数值可参考(表示讨论热度),但实际评论内容无法通过 API 获取(见下方 API 限制) + +每批 20-30 个,尽量覆盖所有非测试 Issue。 + +### 第 3 步:从每个 Issue 提取 Q&A 对 + +对每条 Issue,AI 根据 (subject + description) 提取"问题 → 答案"对。 + +**提取规则**: + +| 字段 | 提取来源 | 提取方法 | +|------|----------|----------| +| **Q(问题)** | subject + description 开头部分 | 提炼 Issue 要解决的核心问题,用一句话表达 | +| **A(答案)** | description 后半部分 + journals(如有) | 提取解决方案、workaround、配置方法、官方回复等 | + +**不同 Issue 类型的 Q&A 转换**: + +| Issue 原始类型 | Q 示例 | A 提取策略 | +|---------------|--------|------------| +| Bug 报告 | "为什么执行 xxx 命令后出现 yyy 错误?" | 从 description 提取修复方法/workaround;如无则写"暂未找到解决方案" | +| 功能请求 | "能否支持 xxx 功能?" | 从 description/journals 提取当前状态(已支持/规划中/不支持+替代方案) | +| 使用问题 | "如何配置/使用 xxx?" | 从 description 提取操作步骤;从 journals 提取维护者回复 | + +**宽松原则**:宁可多留一条不完美的 Q&A,也别漏掉一条有价值的。不确定答案质量的标注"待确认"而非丢弃。 + +### 第 4 步:合并同类问题 + +多个 Issue 描述的是同一个问题 → 合并为一条 FAQ 条目。 + +合并判断标准: +- 两个 Issue 的 subject 高度相似(同义表述) +- 两个 Issue 描述的症状/需求一致 +- 两个 Issue 的根因/答案相同 + +合并后的 FAQ 条目: +- **Q**:综合多个 Issue 提炼一个清晰的问题 +- **A**:综合各方描述和讨论给出最完整的答案 +- **来源**:列出所有相关 Issue 编号 + +详细合并逻辑参见 [`references/gitlink-faq-cluster.md`](references/gitlink-faq-cluster.md)。 + +### 第 5 步:按主题归类 + 生成 FAQ 文档 + +将 Q&A 条目按**主题标签**归类。每个标签有对应图标,生成文档时章节标题必须带图标。标签体系: + +| 图标 | 主题标签 | 适用范围 | +|------|----------|----------| +| 🔧 | `安装配置` | 安装、登录、认证、Token 配置、代理设置 | +| 💻 | `CLI 命令` | 命令使用、参数、输出格式、交互行为 | +| 🔗 | `API 与集成` | API 调用、Webhook、CI/CD 集成 | +| 📊 | `数据显示` | 数据不一致、字段缺失、展示错误 | +| 🖥️ | `平台兼容` | 操作系统兼容、环境依赖 | +| ⚡ | `性能` | 响应慢、超时、资源占用 | +| 💡 | `功能请求` | 用户希望新增或改进的功能 | +| 📦 | `其他` | 不属于以上类别 | + +> 一个 Q&A 条目可以打多个标签。 + +按以下结构组织 Markdown(**注意章节标题前必须带图标**): + +```markdown +# 📖 {项目} FAQ 知识库 + +> 自动生成 | 收录 {N} 个问题 | 更新时间:{DATE} +> 项目:{OWNER}/{REPO} + +## 🔧 安装配置 + +### Q1: {问题}? +**A:** {答案} +> 📎 来源: [#N]({链接}), [#M]({链接}) + +## 💻 CLI 命令 + +### Q2: {问题}? +**A:** {答案} +> 📎 来源: [#N]({链接}) + +... +``` + +完整模板参见 [`examples/faq-template.md`](examples/faq-template.md)。 + +生成原则参见 [`references/gitlink-faq-generate.md`](references/gitlink-faq-generate.md)。 + +完成后保存为 `./issue-faq.md`,然后直接进入第 6 步发布,不需要等用户确认。 + +### 第 6 步:发布到 Wiki + +直接发布,不要询问用户: + +```bash +# 先检查 Wiki 页面是否已存在 +gitlink-cli wiki +view --title "Issue-知识库" --format json + +# 如果存在(返回内容)→ 用 update;如果 404 → 用 create +gitlink-cli wiki +update --title "Issue-知识库" --file ./issue-faq.md +# 或 +gitlink-cli wiki +create --title "Issue-知识库" --file ./issue-faq.md +``` + +发布完成后给用户反馈:发布了多少条 FAQ、Wiki 页面标题。 + +--- + +## 模式 B:Issue 查找与查重 + +当用户说 **"查重"、"检查重复"、"有没有类似的 Issue"、"找一下关于 xx 的 Issue"、"这个是不是有人报过"、"有没有和 xx 相关的"** 时执行。 + +**CRITICAL**:本模式下,匹配到候选 Issue 后**必须自动读取其详情和 journals**,不要问用户"需要我读详情吗"。一次性完成搜索→读详情→给出分析结论。 + +### 第 1 步:确定搜索目标 + +- 用户指定了 Issue 编号 → `issue +view --number N` 获取目标内容 +- 用户描述了主题/关键词(如"与创建 Issue 有关的")→ 进入关键词搜索模式 + +### 第 2 步:拉取 Issue 列表 + +```bash +gitlink-cli issue +list --state open --limit 100 --format json +gitlink-cli issue +list --state closed --limit 100 --format json +``` + +提取全部 Issue 的 `subject`(标题),按用户主题进行标题匹配。 + +### 第 3 步:自动读取候选 Issue 详情(关键步骤) + +筛选出候选 Issue 后,**立即逐个读取详情,不需询问用户**: + +```bash +gitlink-cli issue +view --number N --format json +``` + +提取:标题、描述、journals(评论讨论历史)。 + +### 第 4 步:给出分析结论 + +综合标题+描述+journals,给用户完整分析: + +- **直接匹配**:Issue 的核心讨论内容、维护者回复中有无解决方案 +- **间接相关**:Issue 涉及同一模块/功能但由于不同原因 +- **不相关**:标题含关键词但内容无关 + +对每条匹配的 Issue 输出: +- 标题 + 编号 +- 一句话摘要(从描述和 journals 提取) +- 维护者有无回复/解决方案 +- 相关度判定 + +### 第 5 步:执行操作(自动) + +如果判定高度重复,直接添加评论引导用户,不要询问: + +```bash +# 添加评论(使用 Issue ID,参见 gitlink-issue skill) +gitlink-cli issue +comment --number N --body "此 Issue 与 #M 内容重复,建议..." + +# 打重复标签(使用项目内编号,批量操作) +gitlink-cli issue +batch-label --label duplicate --numbers N,M +``` + +> 通用 Issue 操作(创建、查看、更新、关闭、评论等)参见 [`../gitlink-issue/SKILL.md`](../gitlink-issue/SKILL.md)。 + +--- + +## 模式 C:Wiki 增量更新 + +当用户说 **"把这个 Issue 加到知识库里"、"更新 FAQ"、"更新知识库"、"补充到知识库"、"同步到知识库"** 等时执行。 + +**核心思路**:不是重新生成整个知识库,而是读取现有 Wiki 内容 → 拉取新 Issue → 提取 Q&A → 合并到现有结构 → 更新 Wiki。 + +### 第 1 步:读取现有 Wiki + +```bash +gitlink-cli wiki +view --title "Issue-知识库" --format json +``` + +从返回的 JSON 中提取内容(CLI 自动处理 base64 解码)。 + +如果 Wiki 不存在(404),回退到**模式 A**——首次创建知识库。 + +### 第 2 步:拉取用户指定的 Issue + +根据用户指示直接读 Issue 详情,**用户说哪个就拉哪个**,不要自己去拉全量列表做差集对比。 + +```bash +gitlink-cli issue +view --number N --format json +``` + +| 用户意图 | 操作 | +|----------|------| +| "把 Issue #N 加到知识库" | 只读 #N:`issue +view --number N` | +| "把这几个 Issue 加进去:#A, #B, #C" | 逐个读 #A, #B, #C | +| "把关于 XX 的 Issue 补充进去" | 先按关键词搜索(同模式 B 第 2-3 步),找到匹配 Issue 后逐个读详情 | +| "把最近新增的 Issue 同步到 Wiki" | 用户未指定具体编号时,才拉全量列表,对比 Wiki 中已有的编号做差集 | + +### 第 3 步:从新 Issue 提取 Q&A + +同模式 A 第 3 步,从每个新 Issue 提取"问题 → 答案"对。 + +### 第 4 步:合并到现有知识库 + +将新 Q&A 条目归入现有主题分类: + +- **已存在同类问题**:合并到已有 Q&A 条目,更新答案和来源列表 +- **全新问题**:在对应主题章节下新建 Q&A 条目 + +合并时更新: +- 收录问题计数 +- 更新时间 +- 来源 Issue 列表 + +### 第 5 步:生成合并后的文档 + +将合并后的完整 Markdown 保存为 `./issue-faq.md`,标注新增/变更的部分(可用 `[NEW]` 标记)。 + +### 第 6 步:更新 Wiki + +直接更新,不要询问用户: + +```bash +gitlink-cli wiki +update --title "Issue-知识库" --file ./issue-faq.md +``` + +**CRITICAL**:必须用 `wiki +update`(不是 `+create`),因为页面已存在。 + +发布完成后给用户反馈:新增了多少条 FAQ、更新了多少条已有条目。 + +--- + +## 命令速查 + +本 skill 只涉及知识库分析相关的命令。通用 Issue 操作(创建、查看、更新、关闭、批量操作等)统一使用 [`gitlink-issue`](../gitlink-issue/SKILL.md) skill。 + +| 命令 | 用途 | 模式 | +|------|------|------| +| `issue +list --state open --limit 100 --format json` | 获取 open Issue 列表 | A / C | +| `issue +list --state closed --limit 100 --format json` | 获取 closed Issue 列表 | A / C | +| `issue +view --number N --format json` | 读取单个 Issue 详情 | A / B / C | +| `issue +comment --number N --body "..."` | 查重时添加评论引导用户 | B | +| `issue +batch-label --label duplicate --numbers N,M` | 查重时批量标记重复 | B | +| `wiki +view --title "Issue-知识库" --format json` | 查看已有知识库内容 | C | +| `wiki +create --title "Issue-知识库" --file ./xxx.md` | 首次创建知识库 Wiki 页面 | A | +| `wiki +update --title "Issue-知识库" --file ./xxx.md` | 增量更新知识库 Wiki 页面 | A / C | + +--- + +## API 注意事项 + +- `issue +list` 的 `--state` 参数**不可靠**(与 PR 列表同款问题),返回列表可能包含所有状态。因此必须同时拉 open + closed 两份列表合并去重。 +- **`issue +view` 不返回 journals 内容**:只有 `comment_journals_count`(评论数量),无法通过 API 读取实际评论。`GET /v1/.../issues/{N}/journals` 返回 HTML 而非 JSON。分析只能依靠 `subject` + `description`。 +- **`issue +label-add` API 返回 404**:GitLink 平台 `POST /v1/.../issues/{N}/labels` 端点不可用。打标签请改用 `issue +batch-label`(走 `updateIssueField` 而非 labels API)。 +- `wiki +view` Gateway API 可能返回 404(已知问题),但 `wiki +create` / `wiki +update` 写入正常。 + +--- + +## 注意事项 + +- **从 Issue 提取,不编造**:Q&A 的 Q 和 A 都必须来自实际 Issue 的 subject/description/journals,不凭空编造 +- **合并优于罗列**:多个 Issue 问同一个问题 → 合并为一条 FAQ,而不是罗列多条相似条目 +- **答案有据可查**:每条 FAQ 必须附来源 Issue 编号 +- **数据不足时如实说明**:无法提取答案的 Issue 标注"待确认",不强行写答案 +- **评论语气友好**:重复检测是帮助用户,不是指责 +- **全自动执行**:触发后从头到尾自动完成,不中途询问用户,最后告知结果即可 + +## References + +- [gitlink-faq-collect](references/gitlink-faq-collect.md) — Issue 数据采集与 Q&A 提取 +- [gitlink-faq-cluster](references/gitlink-faq-cluster.md) — 同类问题合并逻辑 +- [gitlink-faq-generate](references/gitlink-faq-generate.md) — FAQ 知识库文档生成 +- [gitlink-faq-detect](references/gitlink-faq-detect.md) — 重复检测逻辑 +- [gitlink-faq-publish](references/gitlink-faq-publish.md) — Wiki 发布 +- [weekly-faq-refresh-workflow](examples/weekly-faq-refresh-workflow.md) — 定期刷新示例 +- [duplicate-detection-demo](examples/duplicate-detection-demo.md) — 重复检测示例 +- [faq-template](examples/faq-template.md) — FAQ 文档模板 +- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-faq/examples/duplicate-detection-demo.md b/skills/gitlink-faq/examples/duplicate-detection-demo.md new file mode 100644 index 0000000..f8810b9 --- /dev/null +++ b/skills/gitlink-faq/examples/duplicate-detection-demo.md @@ -0,0 +1,105 @@ +# 重复检测完整示例 + +> 演示对新 Issue 进行查重检测的端到端流程。 + +## 场景 + +有用户提交了一个新 Issue #312,标题是"登录的时候一直转圈"。维护者想确认这是不是之前已经被问过的问题。 + +## 执行流程 + +### Step 1: 用户发起查重请求 + +``` +用户: 帮我看看 Issue #312 是不是重复了 + +AI: 好的,先看一下这个 Issue 的详情。 +``` + +### Step 2: 获取目标 Issue + +```bash +gitlink-cli issue +view --number 312 --format json +``` + +返回: +```json +{ + "subject": "登录的时候一直转圈", + "description": "打开登录页面,输入账号密码点击登录,页面一直转圈不跳转,换了浏览器也不行" +} +``` + +### Step 3: 获取对比数据 + +```bash +# 获取 FAQ 知识库 +gitlink-cli wiki +view --title "Issue-知识库" --format json + +# 获取已关闭 Issue +gitlink-cli issue +list --state closed --limit 100 --format json +``` + +### Step 4: AI 匹配分析 + +``` +AI 对比分析中... +``` + +返回匹配结果: + +``` +┌─────────────────────────────────────────────────────┐ +│ 🔍 Issue #312 查重结果 │ +│ │ +│ 标题: "登录的时候一直转圈" │ +│ │ +│ 高度重复 (≥0.85): │ +│ ┌──────────────────────────────────────────────┐ │ +│ │ 📖 FAQ Q3: 登录失败或提示 401 错误 │ │ +│ │ 相似度: 0.91 │ │ +│ │ 答案: Token 过期或网络问题,尝试重新登录 │ │ +│ ├──────────────────────────────────────────────┤ │ +│ │ 🔗 Issue #142: 登录页面显示空白 │ │ +│ │ 相似度: 0.88 │ │ +│ │ 已解决: 浏览器缓存问题 │ │ +│ ├──────────────────────────────────────────────┤ │ +│ │ 🔗 Issue #205: 无法登录,页面无响应 │ │ +│ │ 相似度: 0.86 │ │ +│ │ 已解决: DNS 解析问题 │ │ +│ └──────────────────────────────────────────────┘ │ +│ │ +│ 判定: 高度重复 (top_match: 0.91) │ +│ 建议: 添加引导评论 + duplicate 标签 │ +└─────────────────────────────────────────────────────┘ +``` + +### Step 5: 用户确认 + +``` +AI: 检测到 3 个高度相似的匹配。建议: +A. 添加引导评论(推荐用户查看 FAQ Q3 和 #142) +B. 添加 duplicate 标签后关闭 +C. 仅查看,不操作 + +用户: 选 A +``` + +### Step 6: 执行操作 + +```bash +gitlink-cli issue +comment --number 312 --body "你好!检测到你的问题与已有内容高度相似: + +- 📖 [FAQ - Q3: 登录失败或提示 401 错误](wiki/Issue-知识库) +- 🔗 Issue #142: 登录页面显示空白(浏览器缓存问题) +- 🔗 Issue #205: 无法登录,页面无响应(DNS 解析问题) + +建议先查看以上内容。常见原因包括:浏览器缓存、DNS 解析、Token 过期。 +如果以上方案无法解决,请补充错误截图或浏览器控制台日志,我们会进一步排查。" +``` + +### Step 7: 完成 + +``` +AI: 已为 Issue #312 添加引导评论。用户将收到通知,看到参考链接。 +``` diff --git a/skills/gitlink-faq/examples/faq-template.md b/skills/gitlink-faq/examples/faq-template.md new file mode 100644 index 0000000..3fed995 --- /dev/null +++ b/skills/gitlink-faq/examples/faq-template.md @@ -0,0 +1,97 @@ +# 📖 {项目名} FAQ 知识库 + +> 自动生成 | 收录 {N} 个问题 | 更新时间:{DATE} +> 项目:{OWNER}/{REPO} +> +> 本知识库从项目 Issue 中自动提取,将用户遇到的实际问题与解决方案整理为 FAQ。如果你遇到问题,先在下面查找;如果没有找到答案,欢迎提交新 Issue。 + +--- + +## 🔧 安装配置 + +### Q1: {用一句话描述用户遇到的问题/想实现的目标}? + +**A:** {从 Issue description 和评论讨论中提取的解决方案、操作步骤、workaround 或官方回复} + +> 📎 来源: [#{编号}]({链接}) + +### Q2: {问题}? + +**A:** {答案} + +> 📎 来源: [#{编号}]({链接}), [#{编号}]({链接}) + +--- + +## 💻 CLI 命令 + +### Q{序号}: {问题}? + +**A:** {答案} + +> 📎 来源: [#{编号}]({链接}) + +--- + +## 🔗 API 与集成 + +### Q{序号}: {问题}? + +**A:** {答案} + +> 📎 来源: [#{编号}]({链接}) + +--- + +## 📊 数据显示 + +### Q{序号}: {问题}? + +**A:** {答案} + +> 📎 来源: [#{编号}]({链接}) + +--- + +## 🖥️ 平台兼容 + +### Q{序号}: {问题}? + +**A:** {答案} + +> 📎 来源: [#{编号}]({链接}) + +--- + +## ⚡ 性能 + +### Q{序号}: {问题}? + +**A:** {答案} + +> 📎 来源: [#{编号}]({链接}) + +--- + +## 💡 功能请求 + +### Q{序号}: 能否支持 {功能描述}? + +**A:** {当前状态:已支持/开发中/暂不支持。如暂不支持,说明替代方案或原因} + +> 📎 来源: [#{编号}]({链接}) + +--- + +## 📦 其他 + +### Q{序号}: {问题}? + +**A:** {答案} + +> 📎 来源: [#{编号}]({链接}) + +--- + +> 💡 本文档由 gitlink-faq 自动生成,建议每 1-2 周更新。 +> 🔗 提交新 Issue?请先确认是否已被收录。 diff --git a/skills/gitlink-faq/examples/weekly-faq-refresh-workflow.md b/skills/gitlink-faq/examples/weekly-faq-refresh-workflow.md new file mode 100644 index 0000000..04c3430 --- /dev/null +++ b/skills/gitlink-faq/examples/weekly-faq-refresh-workflow.md @@ -0,0 +1,85 @@ +# 每周刷新 FAQ 知识库示例 + +> 端到端演示:定期从 Issue 提取 Q&A,更新知识库 Wiki。 + +## 场景 + +gitlink-cli 项目维护者,本周关闭了 20 个 Issue,想更新 FAQ 知识库。 + +## 完整对话流程 + +### 触发 + +``` +用户: 帮我整理这周关闭的 Issue,更新 FAQ + +AI: 好的,先采集最近的 Issue。 +``` + +### Step 1-2: 采集 + 筛选 + +```bash +gitlink-cli issue +list --state closed --limit 100 --format json +``` + +``` +AI 采集到 20 个 Issue: +- 排除 3 个测试 Issue +- 剩余 17 个有效 Issue,读取详情并提取 Q&A 中... +``` + +### Step 3-4: 提取 Q&A + 合并同类问题 + +``` +AI 展示分析结果: + +📖 从 17 个 Issue 中提取了 12 条 FAQ(已合并 5 组同类问题) + +## 安装配置 +### Q1: 安装 gitlink-cli 后运行报 command not found 怎么办? +**A:** 将 ~/.local/bin 加入 PATH... +> 📎 来源: #16, #20 + +### Q2: 如何配置企业代理? +**A:** 设置 HTTP_PROXY 和 HTTPS_PROXY 环境变量... +> 📎 来源: #42 + +## CLI 命令 +### Q3: issue +view 返回的 JSON 缺少部分字段? +**A:** 这是已知的 API 版本差异,部分字段需要特定 API 版本才返回... +> 📎 来源: #5, #7, #15 + +### Q4: pr +list 在 Windows 下输出乱码? +**A:** 设置终端编码为 UTF-8:chcp 65001... +> 📎 来源: #33 + +## API 与集成 +### Q5: 能否支持在 CI 中使用 Token 认证? +**A:** 已支持。设置 GITLINK_TOKEN 环境变量即可... +> 📎 来源: #28 + +... +``` + +### Step 5: 生成并确认 + +``` +AI 生成 FAQ Markdown,展示预览... + +用户: 可以,发布到 Wiki + +AI: 好的,更新 Wiki 页面... +``` + +### Step 6: 发布 + +```bash +gitlink-cli wiki +update --title "Issue-知识库" --file ./issue-faq.md +``` + +``` +AI: ✅ FAQ 知识库已更新! +- Wiki 页面: Issue-知识库 +- 本次新增: 17 条 Issue → 12 条 FAQ(合并 5 组同类问题) +- 下次建议: 2 周后刷新 +``` diff --git a/skills/gitlink-faq/references/gitlink-faq-cluster.md b/skills/gitlink-faq/references/gitlink-faq-cluster.md new file mode 100644 index 0000000..e14ba68 --- /dev/null +++ b/skills/gitlink-faq/references/gitlink-faq-cluster.md @@ -0,0 +1,140 @@ +# gitlink-faq 同类问题合并 + +> 本文档说明如何判断多个 Issue 描述的是同一个问题,以及如何将它们合并为一条 FAQ 条目。 + +## 合并流程 + +``` +┌─────────────────────────────────────────┐ +│ Step 1: Q&A 提取完成 │ +│ 每条 Issue → {Q, A, confidence} │ +└──────────────────┬──────────────────────┘ + ▼ +┌─────────────────────────────────────────┐ +│ Step 2: 相似度判断 │ +│ 比较 Q 的语义相似度 │ +│ 比较 A 的答案是否一致 │ +└──────────────────┬──────────────────────┘ + ▼ +┌─────────────────────────────────────────┐ +│ Step 3: 合并 │ +│ 同一问题 → 综合 Q + 综合 A + 合并来源 │ +│ 不同问题 → 各自保留 │ +└──────────────────┬──────────────────────┘ + ▼ +┌─────────────────────────────────────────┐ +│ Step 4: 按主题标签归类 │ +│ 为每条 FAQ 打上主题标签 │ +└─────────────────────────────────────────┘ +``` + +## Step 1: 相似度判断 + +### 判断 Prompt + +``` +你正在判断两个 Issue 是否描述的是同一个问题,是否应该合并为一条 FAQ。 + +## Issue A +Q: {Q_A} +A: {A_A} + +## Issue B +Q: {Q_B} +A: {A_B} + +请判断它们是否属于同一问题: +- **same**:描述的是同一个问题,只是表述不同(应合并) +- **related**:涉及同一主题但具体问题不同(不合并,但可放同一主题下) +- **different**:完全不同的问题 + +返回 JSON: +{ + "level": "same|related|different", + "reason": "一句话判断依据" +} +``` + +### 合并判断标准 + +| 判断 | 条件 | 处理 | +|------|------|------| +| **same(合并)** | 两个 Issue 的症状/需求一致,答案也一致或互补 | 合并为一条 FAQ | +| **related(相邻)** | 同一主题但具体问题不同 | 不合并,放在同一主题标签下相邻排列 | +| **different(独立)** | 完全无关 | 各自独立 | + +### 合并判断示例 + +``` +✅ 合并: +Issue #16: Q="安装后运行报 command not found?" A="加入 PATH" +Issue #20: Q="gitlink-cli 命令找不到?" A="检查 PATH 配置" +→ 同一问题,表述不同,合并 + +❌ 不合并: +Issue #16: Q="安装后运行报 command not found?" A="加入 PATH" +Issue #42: Q="如何配置代理?" A="设置 HTTP_PROXY" +→ 都是安装配置主题,但是不同的问题 +``` + +## Step 2: 合并规则 + +### 合并后的 Q(问题) + +- 选择表述更清晰、更完整的那个 Q +- 如果各有优劣,综合提炼一个新的 Q + +### 合并后的 A(答案) + +- 优先采用 confidence 更高的 A +- 如果两个 A 互补(各有信息),合并为更完整的答案 +- 标注"综合自 Issue #A 和 #B 的讨论" + +### 合并后的来源 + +- 列出所有相关 Issue 编号 +- 按编号排序 + +### 合并示例 + +```json +// 合并前:3 条独立条目 +[ + { "issue": 16, "Q": "安装后运行报 command not found?", "A": "加入 PATH", "confidence": "high" }, + { "issue": 20, "Q": "gitlink-cli 命令找不到?", "A": "检查 ~/.local/bin 在 PATH 中", "confidence": "high" }, + { "issue": 35, "Q": "终端提示 command not found: gitlink-cli", "A": "暂未找到解决方案", "confidence": "low" } +] + +// 合并后:1 条 FAQ +{ + "Q": "安装 gitlink-cli 后运行报 command not found 怎么办?", + "A": "将 ~/.local/bin 加入 PATH 环境变量。\\nLinux/Mac: export PATH=$PATH:~/.local/bin\\nWindows: 将 %USERPROFILE%\\.local\\bin 加入系统 PATH", + "confidence": "high", + "sources": [16, 20, 35], + "labels": ["安装配置"] +} +``` + +## Step 3: 按主题标签归类 + +合并完成后,为每条 FAQ 打上主题标签。每个标签有对应图标,生成文档时章节标题必须带图标。标签体系: + +| 图标 | 主题标签 | 适用范围 | 关键词信号 | +|------|----------|----------|------------| +| 🔧 | `安装配置` | 安装、登录、认证、Token、代理、环境变量 | install, auth, login, token, proxy, config, setup | +| 💻 | `CLI 命令` | 命令使用、参数、输出格式、交互 | command, flag, option, output, format | +| 🔗 | `API 与集成` | API 调用、Webhook、CI/CD | api, webhook, ci, cd, integration | +| 📊 | `数据显示` | 数据不一致、字段缺失、展示 | data, field, display, missing, mismatch | +| 🖥️ | `平台兼容` | OS 兼容、环境依赖 | windows, linux, macos, platform, compatibility | +| ⚡ | `性能` | 响应慢、超时、资源 | slow, timeout, performance, memory | +| 💡 | `功能请求` | 用户希望新增/改进 | 希望、能否、建议、支持 | +| 📦 | `其他` | 不属于以上 | — | + +> 一条 FAQ 可以打多个标签。例如一个 Bug 既是 CLI 命令问题又是数据显示问题,就打两个标签。 + +## 排序规则 + +同一主题标签内的 FAQ 条目按以下顺序排列: +1. 合并来源多的在前(反映问题更常见) +2. 同等数量按 confidence 高的在前 +3. 同等 confidence 按 Issue 编号升序 diff --git a/skills/gitlink-faq/references/gitlink-faq-collect.md b/skills/gitlink-faq/references/gitlink-faq-collect.md new file mode 100644 index 0000000..3c5ed61 --- /dev/null +++ b/skills/gitlink-faq/references/gitlink-faq-collect.md @@ -0,0 +1,129 @@ +# gitlink-faq 数据采集与 Q&A 提取 + +> 本文档详细说明如何采集 Issue 数据,以及如何从 Issue 中提取"问题 → 答案"对。 + +## 数据源 + +| 数据 | 命令 | 说明 | +|------|------|------| +| Issue 列表(open) | `issue +list --state open --limit 100 --format json` | 仍开放的 Issue | +| Issue 列表(closed) | `issue +list --state closed --limit 100 --format json` | 已关闭的 Issue | +| Issue 详情 | `issue +view --number N --format json` | 含标题、描述、comment_journals_count(评论数) | + +> **关键认知**:GitLink 平台很多已解决的 Issue 不会被及时设为"关闭"状态。因此必须**同时采集 open 和 closed 两份列表**,合并去重后才能得到完整的 Issue 集合。 + +## 筛选策略 + +采集后需筛选。注意:GitLink API **不支持读取 Issue 评论(journals)**,提取只能基于 subject + description。 + +**宽松筛选原则**(只排除明确无价值的): + +| 类型 | 是否纳入 | 原因 | +|------|----------|------| +| 有 description 的 Issue | ✅ 纳入 | description 可能包含详细的复现步骤和解决方案 | +| 仅有标题无 description | ✅ 纳入 | 标题本身承载了问题信息 | +| 功能请求 | ✅ 纳入 | 可转为"能否支持 xx?"格式的 FAQ | +| 标题含 `[test]`/`测试` | ❌ 排除 | 纯测试数据 | +| 标题为空或纯占位符 | ❌ 排除 | 无有效信息 | + +> **宽松吸纳**:宁可多留一条低质量的,也别漏掉一条有答案的。 + +## 分批采集 + +``` +Issue 总量 采集策略 +───────── ───────── +< 20 全部采集,逐个读详情 +20-50 全部采集,按标题粗筛后读重点 Issue 详情 +50-100 分批采集(每批 50),先按标题粗筛 +> 100 取最近活跃的 100 个,优先高参与度的 +``` + +### 参与度筛选 + +优先采集"高参与度"的 Issue(更有可能提取到完整答案): +- `comment_journals_count ≥ 2`(有人讨论过,答案可能来自讨论) +- journals 中包含维护者回复(优先作为答案来源) +- description 中包含"解决""修复""方案""workaround"等关键词 + +## Q&A 提取方法 + +这是整个流程的核心:从每条 Issue 的 `subject` + `description` 中提取"问题(Q)→ 答案(A)"。 + +### 提取 Prompt + +``` +你正在从项目 Issue 中提取 FAQ 知识库条目。请对每条 Issue 提取 Q&A 对。 + +## Issue 数据 +编号: {number} +标题: {subject} +描述: {description} +评论数: {comment_journals_count} + +## 提取规则 +1. Q(问题):用一句话概括这个 Issue 要解决的核心问题。用中文表述,以问号结尾。 +2. A(答案):从 description 中提取解决方案、操作步骤、配置方法、workaround 或官方回复。 + - 如果 description 明确描述了解决方法 → 直接提取 + - 如果 description 仅有问题描述无解决方案 → A 写"暂未找到解决方案,详见 Issue 讨论" + - 如果 Issue 是功能请求 → A 写当前状态(已支持/开发中/不支持)和替代方案 + - 如果 description 为 null 或只有图片 → A 写"Description 无文本内容,无法提取答案" + +返回 JSON: +{ + "issue_number": N, + "Q": "一句话问题?", + "A": "答案内容", + "confidence": "high|medium|low" +} +``` + +### 不同类型 Issue 的提取策略 + +| Issue 实际类型 | Q 模板 | A 提取来源 | 示例 | +|---------------|--------|------------|------| +| Bug 报告 | "为什么出现 xxx 错误/异常?" | description 中的修复步骤、workaround | "为什么执行 gitlink-cli issue +view 返回数据与网页端不一致?" | +| Bug 报告(无修复) | "遇到 xxx 问题怎么办?" | "暂未找到解决方案,详见 Issue 讨论" | | +| 功能请求 | "能否支持 xxx?" | description/journals 中的状态说明 | "能否支持按 Issue 序号查询?" | +| 使用问题 | "如何配置/使用 xxx?" | description 中的步骤说明 | "如何在 CI 环境中配置 gitlink-cli?" | +| 使用问题(无回复) | "xxx 怎么处理?" | "暂未找到解决方案,详见 Issue 讨论" | | + +### 答案质量标注 + +| 标注 | 条件 | +|------|------| +| `high` | description 明确描述了解决方案、修复方法或有维护者回复 | +| `medium` | 有部分相关信息但不够完整,或需要结合其他 Issue | +| `low` | description 只有问题描述,无解决方案;或 description 为空 | + +## 输出数据格式 + +采集提取后整理为以下结构供合并归类使用: + +```json +[ + { + "issue_number": 142, + "Q": "安装 gitlink-cli 后运行报 command not found 怎么办?", + "A": "将 ~/.local/bin 加入 PATH 环境变量。Linux/Mac 执行: export PATH=$PATH:~/.local/bin。Windows 将 %USERPROFILE%\\.local\\bin 加入系统 PATH。", + "confidence": "high", + "journal_count": 5, + "labels": ["安装配置"] + }, + { + "issue_number": 158, + "Q": "issue +view 命令返回的 JSON 缺少部分字段?", + "A": "暂未找到解决方案,详见 Issue 讨论", + "confidence": "low", + "journal_count": 2, + "labels": ["CLI 命令", "数据显示"] + } +] +``` + +## API 注意事项 + +- `issue +list` 的 `--limit` 最大 200,超出需分页(`--page` 参数) +- **`--state` 参数不可靠**:与 PR 列表类似,必须同时拉取 open 和 closed 两份列表并合并去重 +- **`issue +view` 不返回 journals 内容**:只有 `comment_journals_count`(评论数量),无法通过 API 读取实际评论。`GET /v1/.../issues/{N}/journals` 返回 HTML 而非 JSON。分析只能依靠 `subject` + `description`。 +- 大量请求时建议用 `--debug` 查看实际请求 URL,确认分页参数正确 diff --git a/skills/gitlink-faq/references/gitlink-faq-detect.md b/skills/gitlink-faq/references/gitlink-faq-detect.md new file mode 100644 index 0000000..9f1851e --- /dev/null +++ b/skills/gitlink-faq/references/gitlink-faq-detect.md @@ -0,0 +1,129 @@ +# gitlink-faq 重复检测 + +> 本文档说明如何处理新 Issue 的重复检测——对比已有 FAQ 和历史 Issue。 + +## 检测流程 + +``` +┌────────────────────────────────────────────┐ +│ Step 1: 确定搜索目标 │ +│ 编号 → issue +view 获取目标 │ +│ 关键词 → 进入全量搜索模式 │ +└────────────────┬───────────────────────────┘ + ▼ +┌────────────────────────────────────────────┐ +│ Step 2: 拉取全量列表 │ +│ issue +list --state open │ +│ issue +list --state closed │ +│ 标题匹配筛选候选 Issue │ +└────────────────┬───────────────────────────┘ + ▼ +┌────────────────────────────────────────────┐ +│ Step 3: 自动读详情(不需等用户确认) │ +│ 对每条候选 → issue +view --number N │ +│ 提取: subject + description + journals │ +└────────────────┬───────────────────────────┘ + ▼ +┌────────────────────────────────────────────┐ +│ Step 4: 分析 & 输出结论 │ +│ 综合标题+描述+journals 给出: │ +│ - 核心讨论内容摘要 │ +│ - 维护者是否有回复/方案 │ +│ - 相关度判定 │ +└────────────────┬───────────────────────────┘ + ▼ +┌────────────────────────────────────────────┐ +│ Step 5: 用户确认后执行写操作(可选) │ +│ > 0.85 → comment + label │ +│ 0.6-0.85 → comment only │ +│ < 0.6 → 无需操作 │ +└────────────────────────────────────────────┘ +``` + +## 相似度匹配 Prompt + +``` +你正在判断一个新提交的 Issue 是否与已有问题重复。 + +## 新 Issue +标题: {subject} +描述: {description} + +## 候选匹配列表 +{候选列表,每条含: 来源、标题、摘要} + +请对每个候选给出 0-1 的相似度评分: +- > 0.85: 问的是同一个问题(只是表述不同) +- 0.6-0.85: 有关联但不是同一个问题(如"登录报 401" + 和"Token 配置") +- < 0.6: 无关 + +返回 JSON 数组,按相似度降序排列。 +``` + +## 输出 JSON + +```json +{ + "target_issue": { + "number": 245, + "title": "登录页面打不开", + "description": "点击登录按钮后页面白屏..." + }, + "matches": [ + { + "source": "FAQ", + "entry": "Q3: 登录失败或提示 401 错误怎么处理?", + "similarity": 0.92, + "level": "high_duplicate" + }, + { + "source": "issue", + "number": 142, + "title": "登录页面显示空白", + "summary": "用户反馈登录页白屏,最终确认是浏览器缓存问题", + "similarity": 0.88, + "level": "high_duplicate" + }, + { + "source": "issue", + "number": 178, + "title": "Token 刷新机制咨询", + "summary": "询问 Token 有效期和刷新策略", + "similarity": 0.65, + "level": "related" + } + ], + "recommendation": "high_duplicate", + "top_match_similarity": 0.92 +} +``` + +## 评论模板 + +### 高度重复(similarity > 0.85) + +``` +你好!检测到你的问题与已有内容高度相似: + +- 📖 [FAQ - {条目名}]({FAQ 链接}) +- 🔗 相关 Issue: #{编号} - {标题} + +建议先查看以上内容。如果无法解决你的问题,请补充更多细节(如错误日志、操作步骤),我们会进一步排查。 +``` + +### 可能相关(0.6 ~ 0.85) + +``` +你好!你的问题可能与以下内容相关,供参考: + +- {匹配条目列表} + +如果这些不解决你的问题,请提供更多上下文。 +``` + +## 注意事项 + +- **不要误判**:问题表述相似但根因不同(如"打不开"可能是网络问题也可能是代码 bug),相似度 < 0.85 时只建议参考不打标签 +- **同一用户的多条 Issue**:如果同一用户就同一问题连续发 Issue,先合并讨论再判断 +- **对事不对人**:评论始终友好,引导用户找到答案而非指责 diff --git a/skills/gitlink-faq/references/gitlink-faq-generate.md b/skills/gitlink-faq/references/gitlink-faq-generate.md new file mode 100644 index 0000000..ea966d0 --- /dev/null +++ b/skills/gitlink-faq/references/gitlink-faq-generate.md @@ -0,0 +1,116 @@ +# gitlink-faq 文档生成 + +> 如何将提取合并后的 Q&A 条目组织为结构化 FAQ 知识库 Markdown。 + +## 生成原则 + +- **纯 Q&A 格式**:每个条目是"问题 → 答案 + 来源",不做统计分析 +- **按主题归类**:用主题标签组织章节,不用 Bug/Feature/Question 分类 +- **答案有据可查**:每条 FAQ 附来源 Issue 编号 +- **数据不足时如实说明**:某主题标签下无条目时省略该章节 +- **不编造答案**:无法提取答案的标注"待确认",不强行写 + +## 文档结构 + +参考 [`examples/faq-template.md`](../examples/faq-template.md)。 + +```markdown +# 📖 {项目名} FAQ 知识库 + +> 自动生成 | 收录 {N} 个问题 | 更新时间:{DATE} +> 项目:{OWNER}/{REPO} +> +> 本知识库从项目 Issue 中自动提取,将用户遇到的实际问题与解决方案整理为 FAQ。 + +## {主题标签 1} + +### Q{序号}: {问题}? + +**A:** {答案} + +> 📎 来源: [#{编号}]({链接}) + +### Q{序号}: {问题}? + +**A:** {答案} + +> 📎 来源: [#{编号}]({链接}), [#{编号}]({链接}) + +## {主题标签 2} + +... +``` + +## 章节生成规则 + +- 按主题标签分组,每个标签一个 `##` 二级标题,**标题前必须带对应图标**(如 `## 🔧 安装配置`) +- 标签内有 ≥ 1 条 FAQ 即生成该章节 +- 标签内无条目则省略该章节 +- 章节排列顺序及图标:🔧 安装配置 → 💻 CLI 命令 → 🔗 API 与集成 → 📊 数据显示 → 🖥️ 平台兼容 → ⚡ 性能 → 💡 功能请求 → 📦 其他 + +## Q&A 条目格式 + +### 标准格式 + +```markdown +### Q{序号}: {问题}? + +**A:** {答案} + +> 📎 来源: [#{编号}]({链接}), [#{编号}]({链接}) +``` + +### 序号规则 + +- 全局递增编号(跨所有主题标签),如 Q1, Q2, Q3... +- 方便引用和检索 + +### 答案质量标注(可选) + +当答案 confidence 为 `low` 时,可在答案中标注: + +```markdown +**A:** 暂未找到明确解决方案,详见 Issue 讨论。⚠️ 待确认 +``` + +## 答案编写规范 + +### DO(应该做的) + +- ✅ 答案具体可操作:给出明确的命令、配置、步骤 +- ✅ 综合多个来源:合并多个 Issue 的讨论得出完整答案 +- ✅ 标注适用范围:如果答案只适用于特定平台/版本,明确说明 +- ✅ 引用原始讨论:来源链接让用户可以查看完整上下文 + +### DON'T(不应该做的) + +- ❌ 不编造答案:没有就是没有,写"待确认" +- ❌ 不做统计分析:不写"XX 模块有 N 个 Bug" +- ❌ 不写长篇大论:答案简洁直接,一两段即可 +- ❌ 不假设用户背景:用通俗语言,避免术语黑话 + +## 功能请求类 FAQ 的特殊处理 + +功能请求类 Issue 转为 FAQ 时,答案应说明**当前状态**而非"应该怎么做": + +```markdown +### Q{N}: 能否支持 xxx 功能? + +**A:** 该功能目前已在 v2.1 中支持,使用 `gitlink-cli xxx --flag` 即可。 + +> 📎 来源: [#{编号}]({链接}) +``` + +或: + +```markdown +### Q{N}: 能否支持 xxx 功能? + +**A:** 该功能目前暂不支持。替代方案:可以先通过 yyy 方式实现类似效果。详见 Issue 讨论。 + +> 📎 来源: [#{编号}]({链接}) +``` + +## 输出文件 + +生成的 Markdown 保存为 `./issue-faq.md`,展示给用户预览,确认后发布到 Wiki。 diff --git a/skills/gitlink-faq/references/gitlink-faq-publish.md b/skills/gitlink-faq/references/gitlink-faq-publish.md new file mode 100644 index 0000000..ea042ca --- /dev/null +++ b/skills/gitlink-faq/references/gitlink-faq-publish.md @@ -0,0 +1,59 @@ +# gitlink-faq Wiki 发布 + +> 本文档说明如何将生成的 FAQ 内容发布到 GitLink 项目 Wiki。 + +## 发布命令 + +### 首次创建知识库页面 + +```bash +gitlink-cli wiki +create \ + --title "Issue-知识库" \ + --file ./issue-faq.md \ + --message "自动生成:从项目 Issue 提取 FAQ 知识库" +``` + +### 更新已有知识库页面 + +```bash +# 预览变更 +gitlink-cli wiki +update --title "Issue-知识库" --file ./issue-faq.md --dry-run + +# 覆盖更新 +gitlink-cli wiki +update --title "Issue-知识库" --file ./issue-faq.md +``` + +### 检查知识库是否存在 + +```bash +# 列出所有 Wiki 页面 +gitlink-cli wiki +list --format json + +# 查看知识库内容 +gitlink-cli wiki +view --title "Issue-知识库" --format json +``` + +## 发布检查清单 + +发布前确认以下内容,然后**直接发布,不需询问用户**: + +- [ ] FAQ 内容已展示给用户并获得确认 +- [ ] `--dry-run` 已通过 +- [ ] Markdown 格式正确(代码块、链接) +- [ ] 每条 FAQ 有来源 Issue 链接 +- [ ] 答案质量标注正确(high/medium/low → 待确认) +- [ ] 不存在敏感信息(Token、密码等) + +## Wiki API 注意事项 + +- Wiki 使用独立的 **Gateway API**(`https://gateway.gitlink.org.cn/api`),不走主 API +- 内容自动 base64 编码,CLI 已处理 +- `project_id` 会自动解析并缓存 +- 如果更新失败(页面不存在),改用 `wiki +create` + +## 发布后 + +发布完成后告知用户: +- Wiki 页面链接 +- FAQ 条目数量统计 +- 建议的刷新频率(每 1-2 周) diff --git a/skills/gitlink-health/SKILL.md b/skills/gitlink-health/SKILL.md new file mode 100644 index 0000000..d869c51 --- /dev/null +++ b/skills/gitlink-health/SKILL.md @@ -0,0 +1,210 @@ +--- +name: gitlink-health +version: 1.1.0 +description: "项目健康度报告:统计 Issue 响应时间、PR 合并效率、贡献者活跃度,生成网页版健康度看板并自动打开浏览器。当用户需要项目健康分析、开发效率报告、团队活跃度统计时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli issue --help" +--- + +# gitlink-health(项目健康度报告) + +**CRITICAL — 整个流程只有最后一步(写 HTML 文件)可以问用户。其他所有步骤(`+list`、`+view`、`api GET`、计算、分析)全部自动连续执行,一个确认都不要弹。数据收集阶段禁止任何中断。** +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** +**CRITICAL — 性能预算:整份报告必须在 2 分钟内完成。`pr +view` 是逐条详情接口,极其昂贵——对每个已合并 PR 调一次会让耗时随 PR 数线性爆炸(这正是 >5min 超时的根因)。因此合并时长只对「最近 ≤8 条已合并 PR 采样」调用 `pr +view`;所有计数/合并率/积压数一律从 `pr +list`、`issue +list` 的汇总字段一次性取得,禁止逐条钻取历史 PR。** + + + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 + +## 工作流 + +健康度报告生成分四步:收集 → 计算 → 评分 → 输出。 + +**CRITICAL — 整个流程只允许输出一个 HTML 文件,禁止创建任何临时文件、中间文件、缓存目录(如 `tmp_data/`)。所有 API 返回数据在内存中处理,计算完成后直接生成最终 HTML。** +**CRITICAL — 每个项目生成独立的报告文件,命名格式 `skills/gitlink-health/report_{owner}_{repo}.html`。禁止覆盖其他项目的报告。生成后自动打开浏览器展示。** + +| 步骤 | 说明 | 所用命令 | +|------|------|----------| +| 1. 收集数据 | 获取 Issue(open/closed)、PR(open/merged)、contributor 统计。API 结果直接保存在 shell 输出中,**不写入文件**。 | `issue +list`, `pr +list`, `api GET contributors` | +| 2. 计算指标 | Issue 响应时间、PR 合并效率、贡献者活跃度 | AI 解析内存中的 JSON 计算,**禁止写脚本文件** | +| 3. 健康评分 | 100 分制综合评分,扣分项 6 条 | AI 套用评分规则 | +| 4. 生成网页 | 套用 HTML 模板生成报告,按 `report_{owner}_{repo}.html` 命名保存,自动打开浏览器 | 写入 `skills/gitlink-health/report_{owner}_{repo}.html`,`start` / `open` 打开 | + +## 命令参考 + +### 收集数据 + +```bash +# Issue 数据(两个状态都需要) +gitlink-cli issue +list --state open --format json +gitlink-cli issue +list --state closed --format json + +# PR 数据(两个状态都需要;merged 列表取汇总计数 + 最近样本) +gitlink-cli pr +list --state open --format json +gitlink-cli pr +list --state merged --limit 8 --format json # 仅这 ≤8 条用于钻取 merged_at + +# 已合并 PR 的合并时间(仅对上面取到的 ≤8 条样本逐条调用,切勿扩大) +gitlink-cli pr +view --id --format json # 取 data.pull_request.merged_at + +# 贡献者统计(Raw API) +gitlink-cli api GET /:owner/:repo/contributors --format json + +# 项目活动(Raw API,可选) +gitlink-cli api GET /:owner/:repo/activity --format json +``` + +## 三大指标 + +### 1. Issue 响应时间 + +支持两种口径,**推荐优先使用响应时间**(新增→已解决): + +| 口径 | 计算方式 | 说明 | +|------|----------|------| +| **响应时间(推荐)** | `avg(resolved_at - created_at)` | 从创建到解决,反映实际处理效率 | +| 全周期时间 | `avg(closed_at - created_at)` | 从创建到正式关闭,反映完整生命周期 | + +| 指标 | 计算方式 | 说明 | +|------|----------|------| +| 平均响应时间 | `avg(resolved_at - created_at)` 或 `avg(closed_at - created_at)` | 无 `resolved_at` 时用历史 `updated_at` 推断 | +| 中位数响应时间 | `median(...)` | 排除极端值影响 | +| Issue 积压数 | `count(open Issues)` | 当前待处理的 Issue 数量 | +| 按优先级分布 | 按 `priority_id` 分组:低(1)/正常(2)/高(3)/紧急(4) | 高优先级积压更值得关注 | + +> **注意**:GitLink API 不返回 `closed_at`/`resolved_at` 字段。若项目有"先解决后批量关闭"的工作流,`updated_at` 可能被关闭操作覆盖而虚高,应优先从会话历史推断解决时间。 + +### 2. PR 合并效率 + +PR 合并效率由**三类 PR** 共同决定,需计算**三段时间**: + +| 时间 | 名称 | 公式 | 适用对象 | +|------|------|------|----------| +| ① | 已合并 PR 平均合并时长 | `avg(merged_at - created_at)` | status=1(已合并) | +| ② | 开放 PR 平均等待时长 | `avg(now - created_at)` | status=0(开放中) | +| ③ | 加权总平均处理时长 | `(merged/total) * ① + (open/total) * ②` | 已合并 ∪ 开放 | + +**总样本数 = 已合并数 + 开放数**(已关闭未合并的 PR 不参与时间计算,仅参与合并率分母)。 + +| 指标 | 计算方式 | 说明 | +|------|----------|------| +| ① 已合并平均合并时长 | `avg(merged_at - created_at)` | **仅对已合并 PR**(`status=1`)计算,从创建到合并成功的时长 | +| ② 开放平均等待时长 | `avg(now - created_at)` | 开放 PR 从创建到当前的等待时长,反映积压压力 | +| ③ 加权总平均 | `(merged/total) * ① + (open/total) * ②` | 综合 PR 处理节奏的总体指标 | +| PR 积压数 | `count(open PRs)` | 当前待合并的 PR 数量 | +| 合并率 | `merged / (merged + closed)` | 已合并占所有已关闭 PR(合并+关闭)的比例 | + +**统计对象规则**: + +| PR 状态 | 计入时间计算 | 计入合并率分母 | 计入积压 | +|---------|------------|---------------|---------| +| 已合并(status=1) | ① | ✅ | ❌ | +| 已关闭未合并(status=2) | ❌ | ✅ | ❌ | +| 开放中(status=0) | ② | ❌ | ✅ | + +> **数据获取**:`merged_at` 仅在 `pr +view --id ` 单个 PR 详情中返回(路径 `data.pull_request.merged_at`),列表接口不暴露。 +> **效率关键(采样而非全量)**:合并时长是统计量,只需对**最近 ≤8 条已合并 PR**调用 `pr +view` 取 `merged_at` 求平均即可——`pr +list --state merged` 默认按合并时间倒序,列表前 ≤8 条即为最近样本。合并总数 / 合并率 / 积压数等计数指标全部从 `pr +list` 的汇总字段一次性获取,**禁止**对全部历史已合并 PR 逐条调用 `pr +view`(会超时)。 +> **绝对禁止**:用「当前时间 - 创建时间」估算已合并 PR 的合并时间。开放 PR 用此公式是允许的(且必要),因为它们没有合并时间点,等待时长反映积压。 + +### 3. 贡献者活跃度 + +| 指标 | 数据源 | 说明 | +|------|--------|------| +| 贡献者总数 | `contributors.author_count` | 项目总贡献者数 | +| 每人 commits | `contributors.authors[].commits` | 按 commit 数排名 | +| 每人增删行数 | `contributors.authors[].additions / deletions` | 代码贡献量 | +| 每人 PR 数 / Issue 数 | PR 和 Issue 列表统计 | 在 podium 和 contributor-row 中显示 | +| 活跃度分级 | 综合 commits + PRs + Issues | 高频 / 正常 / 低频 | + +**CRITICAL — 每个贡献者必须同时显示 PR 数量和 Issue 数量**,格式为 `N PRs · M Issues`。 +- **Podium 前三名**(冠亚季军):`N PRs · M Issues · 🔥 高频` — 带活跃度标签。 +- **其他贡献者**(contributor-row):`N PRs · M Issues` — 不带活跃度标签。 + +活跃度分级标准: + +| 级别 | 条件 | +|------|------| +| 🔥 高频 | 近 30 天 commits ≥ 5 或 PRs ≥ 2 | +| 🟢 正常 | 近 30 天 commits ≥ 1 或 PRs ≥ 1 | +| 🟡 低频 | 近 30 天无 commit 和 PR,但有近期 Issue 活动 | +| ⚪ 不活跃 | 近 60 天无任何活动记录 | + +## 健康度综合评分(100 分制) + +### 核心指标(75 分) + +| 扣分项 | 扣分 | 条件 | +|--------|------|------| +| Issue 响应慢 | -25 | Issue 平均响应时间(新增→已解决)> 3 天 | +| PR 合并慢 | -25 | PR 加权总平均处理时长 > 2 天(③) | +| 贡献者活跃度低 | -25 | 近 30 天有活跃行为的贡献者 < 2 人,或单一贡献者占总 commit 数 > 70% | + +### 辅助指标(25 分) + +| 扣分项 | 扣分 | 条件 | +|--------|------|------| +| Issue 积压严重 | -10 | open 状态 Issue 数量 > 20 | +| PR 积压严重 | -10 | open 状态 PR 数量 > 10 | +| 近期无发布 | -5 | 最近 30 天无新 Release | + +评分等级: + +| 分数 | 等级 | 图标 | +|------|------|------| +| 90-100 | 优秀 | 🟢 | +| 70-89 | 良好 | 🔵 | +| 50-69 | 一般 | 🟡 | +| 30-49 | 需关注 | 🟠 | +| 0-29 | 严重 | 🔴 | + +## 报告输出(HTML 网页) + +### 模板文件 + +报告使用 `skills/gitlink-health/template.html` 作为模板。AI 将计算后的指标填入模板中的 `{{PLACEHOLDER}}` 占位符,按 `report_{owner}_{repo}.html` 格式命名(如 `report_chroe_gitlink-cli.html`),生成到 `skills/gitlink-health/` 目录下。每个项目独立文件,不覆盖其他项目报告。 + +### 占位符说明 + +| 占位符 | 来源 | 说明 | +|--------|------|------| +| `{{OWNER}}` / `{{REPO}}` | git remote 解析 | 项目路径 | +| `{{DATE}}` | 当前日期 | 报告生成日期 | +| `{{PERIOD_DAYS}}` | 默认 30 | 统计周期天数 | +| `{{SCORE}}` | 计算得出 | 综合评分 0-100 | +| `{{SCORE_COLOR}}` | 评分映射 | #00b894(优秀) / #0984e3(良好) / #fdcb6e(一般) / #e17055(需关注) / #d63031(严重) | +| `{{SCORE_DASH}}` | 评分计算 | SVG stroke-dasharray:`(SCORE/100*377) 377` | +| `{{GRADE}}` | 评分映射 | 优秀 / 良好 / 一般 / 需关注 / 严重 | +| `{{DEDUCTION_ROWS}}` | 扣分明细 | 6 行 ``,每行含指标名、实际值、扣分、状态 | +| `{{TOTAL_ISSUES}}` 等 | 统计数据 | Issue/PR 各项数值 | +| `{{PRIORITY_BAR}}` | 优先级分布 | 4 个 `` 表示紧急/高/正常/低占比 | +| `{{PRIORITY_LEGEND}}` | 优先级分布 | 图例说明 | +| `{{CONTRIBUTOR_ROWS}}` | 贡献者统计 | 每人一行的表格数据 | +| `{{SUGGESTIONS}}` | AI 生成 | 改进建议列表 | + +### 生成并打开 + +1. 将计算后的数据填入模板所有占位符 +2. 写入 `skills/gitlink-health/report_{owner}_{repo}.html`(每个项目独立文件,不覆盖) +3. 写入成功后立即根据操作系统自动打开浏览器: + - Windows: `start skills/gitlink-health/report_{owner}_{repo}.html` + - macOS: `open skills/gitlink-health/report_{owner}_{repo}.html` + - Linux: `xdg-open skills/gitlink-health/report_{owner}_{repo}.html` + +## API 注意事项 + +- `GET /:owner/:repo/contributors` 无 Shortcut,通过 `gitlink-cli api GET` 调用 +- PR list 的 `--state` 仅影响统计计数,返回列表需客户端按 `pull_request_status` 过滤 +- Issue 字段名:网页编号为 `project_issues_index`,数据库 ID 为 `id` +- Issue 时间字段:API **不返回**独立的 `closed_at` 或 `resolved_at`,仅有 `created_at` 和 `updated_at`。`updated_at` 是最后更新时间(可能反映解决时间或关闭时间,需根据上下文判断) +- 贡献者统计基于默认分支,不含其他分支的 commit +- `contributors` 返回 `author_count`(总数)和 `authors[]`(每人明细) + +## References + +- [collect-data](references/collect-data.md) — 数据收集详细说明 +- [health-metrics](references/health-metrics.md) — 指标计算和评分规则 +- [generate-report](references/generate-report.md) — 报告生成和输出 +- [full-workflow](examples/full-workflow.md) — 完整端到端示例 +- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-health/examples/full-workflow.md b/skills/gitlink-health/examples/full-workflow.md new file mode 100644 index 0000000..fce839f --- /dev/null +++ b/skills/gitlink-health/examples/full-workflow.md @@ -0,0 +1,107 @@ +# 项目健康度报告 — 完整生成示例 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **适用场景:** AI Agent 端到端生成项目健康度报告,以 `zzx-coder/gitlink-cli` 为例。 + +## 完整流程 + +### 第一步:收集 Issue 数据 + +```bash +# 获取未关闭 Issue +gitlink-cli issue +list --state open --format json + +# 获取已关闭 Issue +gitlink-cli issue +list --state closed --format json + +# AI 从返回中提取: +# - open count: 28 +# - closed count: 1 (我们之前创建的 #29,已关闭) +# - priority 分布: 大部分为 normal (priority_id=2) +# - 平均关闭时间: #29 创建后约 1 分钟关闭(测试 issue) +``` + +### 第二步:收集 PR 数据 + +```bash +# 获取未合并 PR(计数 + 积压) +gitlink-cli pr +list --state open --format json + +# 获取已合并 PR 汇总 + 最近样本(--limit 8 限定钻取范围,避免超时) +gitlink-cli pr +list --state merged --limit 8 --format json + +# 仅对上面取到的 ≤8 条样本逐条取 merged_at(切勿对全部历史 PR 调用) +gitlink-cli pr +view --id --format json +``` + +# AI 从返回中提取: +# - merged count / open count:从 pr +list 汇总字段 +# - 合并率: merged / (merged + closed) +# - 已合并 PR 平均合并时长:仅基于这 ≤8 条样本的 merged_at - created_at 求平均 + +### 第三步:收集贡献者统计 + +```bash +# Raw API 获取贡献者数据 +gitlink-cli api GET /:owner/:repo/contributors --format json + +# AI 从返回中提取: +# - author_count: 4+ +# - 各贡献者 commit 数和增删行数 +# - 集中度计算 +``` + +### 第四步:AI 计算指标 + +AI 根据 [指标计算规则](../references/health-metrics.md) 处理数据: + +``` +Issue 响应时间: + 平均 0.1 天(仅 1 个已关闭 Issue,样本量不足) + 积压 28 个 → 触发扣分 + +PR 合并效率: + 平均合并时间 ≈ 3.5 天(估算,需 merge_at 字段) + 积压 0 个 ✓ + 合并率 91% + +贡献者活跃度: + mengcheng (camelliamc) — 🔥 高频 + zzx-coder — 🔥 高频 + wbtiger — 🟢 正常 + wangyue789 — 🟡 低频 + +综合评分: + 起始 100 + 核心指标: 0 扣分(响应时间样本不足/PR合并正常/贡献者活跃) + 辅助指标: -10 Issue 积压 (28 open), -5 无近期发布 (已解决) + 最终: 85/100(良好 🔵) +``` + +### 第五步:填充 HTML 模板 + +AI 读取 `skills/gitlink-health/template.html`,将计算后的指标替换所有 `{{PLACEHOLDER}}` 占位符,生成完整的 HTML 文件。 + +### 第六步:输出报告 + +1. 写入 `skills/gitlink-health/report.html`(唯一的输出文件) +2. 自动打开浏览器展示报告 +3. 如需持久化,可选发布为 Issue(内容从内存生成,不写本地文件) + +## AI Agent 执行要点 + +1. **数据收集顺序**:先收集 Issue 和 PR(Shortcut 命令),再收集 contributors(Raw API),避免一次性大量 API 调用 +2. **效率优先于全量**:报告是趋势快照,不是审计。计数/合并率从 `+list` 汇总字段一次取得;合并时长只采样最近 ≤8 条已合并 PR(`pr +view`)。**禁止**分页遍历全部历史 Issue/PR,也禁止对每个已合并 PR 都调 `pr +view`——这是 >5min 超时的根因 +3. **时间计算**:ISO 8601 格式解析优先,Unix 时间戳更可靠但 PR 的 `pr_created_unix` 仅部分返回 +4. **指标计算容错**:样本不足时标注而非报错,新项目可能仅有少量数据 +5. **评分可按需调整**:新项目无 Release 时,"无近期发布"项自动跳过 +6. **避免 GIGO**:数据异常时(如极长的响应时间),标注并排除 outlier +7. **HTML 生成**:读取 `template.html` → 替换所有 `{{PLACEHOLDER}}` → 写入 `report.html` → 自动 `start`/`open` 打开浏览器 +8. **禁止创建临时文件**:所有 API 数据在内存中处理,禁止创建 `tmp_data/`、脚本文件、中间 JSON 等任何多余文件。整个流程只输出一个 `report.html`。 + +## References + +- [SKILL.md](../SKILL.md) — 工作流和模板总览 +- [collect-data](../references/collect-data.md) — 数据收集详细说明 +- [health-metrics](../references/health-metrics.md) — 指标计算和评分规则 +- [generate-report](../references/generate-report.md) — 报告生成和输出 diff --git a/skills/gitlink-health/references/collect-data.md b/skills/gitlink-health/references/collect-data.md new file mode 100644 index 0000000..a2c1148 --- /dev/null +++ b/skills/gitlink-health/references/collect-data.md @@ -0,0 +1,124 @@ +# 收集健康度数据 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +收集项目健康度报告所需的四类数据:Issue、PR、贡献者和项目活动。 + +## 命令 + +### 步骤 1:收集 Issue 数据 + +```bash +# 获取未关闭 Issue(用于统计积压) +gitlink-cli issue +list --state open --format json + +# 获取已关闭 Issue(用于计算响应时间) +gitlink-cli issue +list --state closed --format json +``` + +Issue 返回的关键字段: + +| 字段 | 用途 | +|------|------| +| `project_issues_index` | Issue 编号 | +| `subject` | Issue 标题 | +| `status_id` | 状态:1=新增, 2=正在解决, 3=已解决, 5=关闭 | +| `priority_id` | 优先级:1=低, 2=正常, 3=高, 4=紧急 | +| `created_at` | 创建时间 (ISO 8601) | +| `closed_at` | 关闭时间(仅已关闭 Issue 有此字段) | +| `author.login` | 创建者 | +| `assigners[]` | 负责人列表 | + +### 步骤 2:收集 PR 数据 + +```bash +# 获取未合并 PR(用于统计积压) +gitlink-cli pr +list --state open --format json + +# 获取已合并 PR(用于计算合并效率) +gitlink-cli pr +list --state merged --format json +``` + +PR 返回的关键字段: + +| 字段 | 用途 | +|------|------| +| `pull_request_number` | PR 编号(用于 `pr +view --id` 钻取) | +| `name` (title) | PR 标题 | +| `pull_request_status` | 状态:0=open, 1=merged, 2=closed | +| `author_login` | 作者 | +| `pr_full_time` | 创建时间 (ISO 8601) | +| `pr_created_unix` | 创建时间 (Unix timestamp) | +| `merged_issues_size` / `open_count` / `close_count` | 合并/开放/关闭未合并计数(汇总字段,与 `--limit` 无关,一次性取得) | + +> ⚠️ `pr +list` **不返回** `merged_at`(合并时间)字段,只有创建时间。`merged_at` 仅在 `pr +view --id ` 的 `data.pull_request.merged_at` 中暴露。 +> ⚠️ **为控制耗时,只对最近 ≤8 条已合并 PR 调用 `pr +view` 取 `merged_at`**(列表默认按合并时间倒序,前 ≤8 条即最近样本)。`pr +list --state merged --limit 8` 一条命令即可同时拿到汇总计数(`merged_issues_size` 等不受 limit 影响)和 8 条样本。禁止遍历全部历史已合并 PR。 +> ⚠️ `--state` 参数仅影响 `merged_count`/`open_count`/`closed_count` 汇总计数,API 返回的 issues 列表可能包含所有状态的 PR。需按 `pull_request_status` 客户端过滤。 + +### 步骤 3:收集贡献者统计 + +```bash +# 获取贡献者统计(Raw API,无 Shortcut) +gitlink-cli api GET /:owner/:repo/contributors --format json +``` + +返回关键字段: + +| 字段 | 用途 | +|------|------| +| `author_count` | 总贡献者数 | +| `commit_count` | 总 commit 数 | +| `commit_count_in_all_branches` | 全部分支的 commit 总数 | +| `additions` / `deletions` | 总增删行数 | +| `authors[]` | 每位贡献者的明细 | + +每位贡献者(`authors[]`)字段: + +| 字段 | 用途 | +|------|------| +| `login` / `name` | 贡献者 ID 和昵称 | +| `commits` | commit 数量 | +| `additions` / `deletions` | 增删行数 | + +### 步骤 4:收集项目活动(可选) + +```bash +# 获取项目活动 feed +gitlink-cli api GET /:owner/:repo/activity --format json +``` + +用于补充近期事件(issue 创建/关闭、PR 创建/合并的时间线)。 + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--format` | 否 | 始终建议 `json`,便于 AI 解析 | +| `--state` | 否 | Issue: `open`/`closed`;PR: `open`/`merged`/`closed` | +| `--page` | 否 | 大量数据时分页获取 | +| `--limit` | 否 | 每页条数 | + +## 数据覆盖范围 + +数据收集覆盖以下时间范围: + +- **Issue**: 所有未关闭 + 近期已关闭(计数从汇总字段取;时间计算用近期限定条数即可) +- **PR**: 所有未合并 + 近期已合并(计数/合并率从 `pr +list` 汇总字段取;**合并时长仅采样最近 ≤8 条已合并 PR** 钻取 `merged_at`) +- **Contributors**: 项目全量历史数据 +- **Activity**: 最近 30 天 + +大型项目也**不要**通过 `--page` 分页遍历全部历史数据——健康度是趋势快照,计数取汇总字段、合并时长取最近 ≤8 条样本即可,避免超时。 + +## 注意事项 + +- 计算响应时间需要 Issue 有 `closed_at` 字段,仅已关闭 Issue 才会返回此字段 +- PR 合并时间**不能**用「当前时间 - `pr_full_time`」估算(已合并 PR 必须取真实 `merged_at`);`merged_at` 通过 `pr +view --id` 获取,**仅采样最近 ≤8 条已合并 PR** +- `contributors` 端点统计基于默认分支(通常为 `master`),不含其他分支 +- `--owner` / `--repo` 在仓库目录下可自动解析 +- 时间计算使用 Unix 时间戳(`pr_created_unix`)比解析字符串格式(`pr_full_time`)更可靠 + +## References + +- [health-metrics](health-metrics.md) — 收集完成后进行指标计算 +- [generate-report](generate-report.md) — 生成报告并输出 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-health/references/generate-report.md b/skills/gitlink-health/references/generate-report.md new file mode 100644 index 0000000..a2749fa --- /dev/null +++ b/skills/gitlink-health/references/generate-report.md @@ -0,0 +1,78 @@ +# 生成并输出健康度报告 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +将计算的指标和评分套用 HTML 模板,生成网页版健康度报告,自动打开浏览器展示。 + +## 输出方式(HTML 网页) + +**CRITICAL — 整个流程只允许输出一个文件 `skills/gitlink-health/report.html`,禁止创建任何临时文件、中间文件、缓存目录、脚本文件或额外的 Markdown 文件。** + +### 步骤 1:填充模板 + +读取 `skills/gitlink-health/template.html`,将计算后的指标替换所有 `{{PLACEHOLDER}}` 占位符。占位符说明见 [SKILL.md](../SKILL.md#占位符说明)。 + +### 步骤 2:写入文件 + +将填充后的 HTML 写入 `skills/gitlink-health/report.html`。 + +### 步骤 3:自动打开浏览器 + +| 平台 | 命令 | +|------|------| +| Windows | `start skills/gitlink-health/report.html` | +| macOS | `open skills/gitlink-health/report.html` | +| Linux | `xdg-open skills/gitlink-health/report.html` | + +## 备选输出方式(仅发布 Issue,不创建额外文件) + +### 创建报告 Issue(需认证) + +```bash +gitlink-cli issue +create \ + -t "项目健康度报告 — {DATE}" \ + -b "" \ + --label 文档 +``` + +> ⚠️ 此为 Write Operation,创建前必须确认用户意图。内容从内存中的计算结果直接生成,不写本地文件。 + +## 模板关键占位符 + +| 占位符 | 来源 | +|--------|------| +| `{{OWNER}}` / `{{REPO}}` | git remote 解析 | +| `{{DATE}}` | 当前日期 | +| `{{SCORE}}` | 综合评分(0-100) | +| `{{SCORE_COLOR}}` | #00b894(90+)/#0984e3(70+)/#fdcb6e(50+)/#e17055(30+)/#d63031(<30) | +| `{{SCORE_DASH}}` | `(SCORE/100*377) 377` | +| `{{GRADE}}` | 优秀 / 良好 / 一般 / 需关注 / 严重 | +| `{{DEDUCTION_ROWS}}` | 6 行 `` 扣分明细 | +| `{{OPEN_ISSUES}}` / `{{CLOSED_ISSUES}}` 等 | 统计数据 | +| `{{CONTRIBUTOR_ROWS}}` | 每人一行 `` | +| `{{SUGGESTIONS}}` | `
  • ` 列表 | + +完整占位符列表参见 [SKILL.md](../SKILL.md). + +## Workflow + +1. **计算**所有指标和评分(参见 [health-metrics](health-metrics.md)),所有数据在内存中处理 +2. **填充** HTML 模板所有占位符 +3. **写入** `skills/gitlink-health/report.html`(唯一的输出文件) +4. **打开** 浏览器自动展示(`start` / `open` / `xdg-open`) +5. 如需持久化,可选发布为 Issue(内容从内存生成,不写本地文件) + +## 注意事项 + +- 报告的时间范围默认取最近 30 天,用户可指定自定义范围 +- 若项目无 Release(如新项目),扣分项"无近期发布"不适用,总分自动调整 +- 贡献者活跃度需结合近 30 天的活动时间线判断 +- 改进建议应具体、可执行,避免空泛描述 +- 如果数据量不足(如新项目仅有少量 Issue/PR),在报告中标注"样本量小,仅供参考" + +## References + +- [collect-data](collect-data.md) — 收集项目数据 +- [health-metrics](health-metrics.md) — 指标计算和评分规则 +- [full-workflow](../examples/full-workflow.md) — 完整端到端示例 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 diff --git a/skills/gitlink-health/references/health-metrics.md b/skills/gitlink-health/references/health-metrics.md new file mode 100644 index 0000000..0882012 --- /dev/null +++ b/skills/gitlink-health/references/health-metrics.md @@ -0,0 +1,234 @@ +# 健康度指标计算 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +基于收集到的数据,计算 Issue 响应时间、PR 合并效率、贡献者活跃度三大指标,并给出综合健康评分。 + +## 指标 1:Issue 响应时间 + +### 两种评价方式 + +Issue 响应时间支持两种计算口径,AI Agent 可根据数据可用性和项目工作流选择: + +| 口径 | 计算方式 | 含义 | 适用场景 | +|------|----------|------|----------| +| **响应时间** | `resolved_at - created_at` | 从创建到解决的时间 | 衡量团队对 Issue 的实际响应速度 | +| **全周期时间** | `closed_at - created_at` | 从创建到正式关闭的时间 | 衡量 Issue 的完整生命周期 | + +**推荐优先使用响应时间**(新增→已解决),因为它反映真实处理效率。全周期时间受"解决后批量关闭"等工作流影响,可能虚高。 + +### 计算所需数据 + +从 `issue +list --state closed` 返回的 Issue 列表中提取: + +- `created_at` — 创建时间 +- `updated_at` — 最后更新时间(当无独立 `closed_at`/`resolved_at` 时的降级代理字段) +- `status_id` — 状态:1=新增, 2=正在解决, 3=已解决, 5=关闭 +- `status_name` — 状态名称(辅助判断) + +> **注意**:GitLink API 不直接返回 `closed_at` 或 `resolved_at` 字段。AI Agent 需根据上下文推断: +> - 若 Issue 的 `status_id=5`(关闭)且 `updated_at` 在近期批量操作中突变,则 `updated_at` 反映的是关闭时间而非解决时间,应尝试从历史会话或其他来源获取解决时间 +> - 若项目工作流为"解决即关闭"(单步),则 `updated_at` 同时代表解决和关闭时间,可直接使用 + +### 计算公式 + +``` +# 方式 A:响应时间(推荐) +time_to_resolve = resolved_at - created_at +avg_response = sum(time_to_resolve) / count +median_response = sorted(time_to_resolve)[count / 2] + +# 方式 B:全周期时间 +time_to_close = closed_at - created_at +avg_close = sum(time_to_close) / count +median_close = sorted(time_to_close)[count / 2] +``` + +### 阈值 + +| 指标 | 优秀 | 良好 | 需改进 | +|------|------|------|--------| +| 平均响应/关闭时间 | ≤ 2 天 | ≤ 7 天 | > 7 天 | +| 中位数响应/关闭时间 | ≤ 1 天 | ≤ 5 天 | > 5 天 | +| Issue 积压数 | ≤ 10 | ≤ 20 | > 20 | + +### 时间计算注意 + +- 时间字段为 ISO 8601 格式(如 `"2026-06-04 14:45"`),需解析后求差值 +- 无 `closed_at` 的 Issue(open 状态)不计入响应时间,计入积压统计 +- 当两种口径结果差异显著时(如响应时间 1.5 天 vs 全周期 15 天),以响应时间为评分依据,在全周期时间处标注"受工作流影响" + +## 指标 2:PR 合并效率 + +### 三段时间计算 + +PR 合并效率由**三类 PR** 共同决定,需计算**三段时间**: + +| 时间编号 | 名称 | 公式 | 适用对象 | +|----------|------|------|----------| +| ① | 已合并 PR 平均合并时长 | `avg(merged_at - created_at)` | status=1(已合并) | +| ② | 开放 PR 平均等待时长 | `avg(now - created_at)` | status=0(开放中) | +| ③ | 加权总平均处理时长 | 见下方公式 | status=1 ∪ status=0 | + +### 计算公式 + +``` +# ① 已合并 PR 平均合并时长(基于最近 ≤8 条样本) +time_merged_i = merged_at_i - created_at_i +avg_merged = sum(time_merged_i) / sample_count # sample_count = 实际采样的 PR 条数(≤8),不是历史合并总数 + +# ② 开放 PR 平均等待时长 +time_open_i = now - created_at_i +avg_open = sum(time_open_i) / open_count + +# ③ 加权总平均处理时长 +total_count = merged_count + open_count +weight_merged = merged_count / total_count +weight_open = open_count / total_count +weighted_total = weight_merged * avg_merged + weight_open * avg_open +``` + +### 状态分类 + +| 状态 | 含义 | 是否计入 | 计入哪类 | +|------|------|---------|---------| +| `pull_request_status=1` | 已合并 | ✅ | ①已合并 | +| `pull_request_status=0` | 开放中 | ✅ | ②开放等待 | +| `pull_request_status=2` | 已关闭(未合并) | ❌ | 不参与时间计算,但参与合并率分母 | + +> **设计依据**: +> - 已合并 PR 已有"完成时间"(merged_at),用真实合并耗时 +> - 开放 PR 尚未合并,"等待时长"= 从创建到当前(持续增长中),反映积压压力 +> - 加权平均综合体现项目处理 PR 的整体节奏,比单一指标更准确 + +### 合并率(独立指标) + +``` +merge_rate = merged_count / (merged_count + closed_count) × 100% +``` + +仅使用「已合并」与「已关闭未合并」作为分母,开放 PR 不参与。 + +### 阈值 + +| 指标 | 优秀 | 良好 | 需改进 | +|------|------|------|--------| +| ① 已合并 PR 平均合并时长 | ≤ 1 小时 | ≤ 1 天 | > 1 天 | +| ② 开放 PR 平均等待时长 | ≤ 1 天 | ≤ 7 天 | > 7 天 | +| **③ 加权总平均处理时长** | **≤ 6 小时** | **≤ 2 天** | **> 2 天** | +| PR 积压数 | ≤ 3 | ≤ 10 | > 10 | +| 合并率 | ≥ 90% | ≥ 70% | < 70% | + +> **可调阈值**:此表为默认值。不同项目工作流差异大(如 fork 端预审型 vs 上游社区 PR 型),可在 `health-metrics.md` 中按项目特征调整。 + +### 数据可用性 + +PR 合并时间**只与 PR 自身时间相关**(创建时间 + 合并时间),**与当前时间无关**。 + +| 字段 | 来源命令 | 路径 | +|------|---------|------| +| `created_at` | `pr +list --state merged` | `data.issues[].pr_full_time` 或 `pr_created_unix` | +| 合并总数 / 合并率 / 积压数 | `pr +list` 汇总字段 | `data.close_count` 等(一次性获取,勿逐条钻取) | +| `merged_at` | `pr +view --id ` | `data.pull_request.merged_at` | + +> **效率关键(采样而非全量)**:`merged_at` 只在 `pr +view` 单个 PR 详情中暴露,列表接口不返回。但合并时长是统计量——**只对最近 ≤8 条已合并 PR 采样调用 `pr +view`** 即可求得稳定平均,无需遍历全部历史 PR。`pr +list --state merged` 默认按合并时间倒序,取列表前 ≤8 条作为样本。合并总数/合并率从 `pr +list` 汇总字段一次性取得。 +> **绝对禁止**:使用「当前时间 - 创建时间」作为已合并 PR 的合并时间估算(会把开放中或近期合并的 PR 误判为"超长合并时间")。开放 PR 用「当前时间 - 创建时间」是允许且必要的,等待时长反映积压。 + +## 指标 3:贡献者活跃度 + +### 计算所需数据 + +从 `GET /:owner/:repo/contributors` 返回: + +- `authors[].login` — 贡献者登录名 +- `authors[].commits` — 提交数 +- `authors[].additions / deletions` — 增删行数 + +### 活跃度分级 + +| 级别 | 图标 | 条件 | +|------|------|------| +| 高频活跃 | 🔥 | 近 30 天 commits ≥ 5 或 PRs ≥ 2 | +| 正常活跃 | 🟢 | 近 30 天 commits ≥ 1 或 PRs ≥ 1 | +| 低频活跃 | 🟡 | 近 30 天无 commit 但有 Issue 活动 | +| 不活跃 | ⚪ | 近 60 天无任何贡献活动 | + +### 贡献者集中度 + +``` +top_contributor_share = max(authors[].commits) / total_commits × 100% +``` + +集中度 > 70% 视为过度集中(bus factor 低,有单点风险)。 + +## 综合健康评分(100 分制) + +### 计分规则 + +起始分 **100 分**,逐项扣分。核心三指标(Issue 响应时间、PR 合并效率、贡献者活跃度)占 75 分,辅助指标占 25 分。 + +### 核心指标(75 分) + +| 扣分项 | 扣分 | 触发条件 | 说明 | +|--------|------|----------|------| +| Issue 响应慢 | -25 | `avg_response > 7天`(使用响应时间口径,即新增→已解决) | Issue 平均解决时间超过一周 | +| PR 合并慢 | -25 | `avg_merge > 1天` | PR 平均合并时间超过一天 | +| 贡献者活跃度低 | -25 | `active_contributors < 2` 或 `top_contributor_share > 70%` | 活跃贡献者过少或过度依赖单一贡献者 | + +### 辅助指标(25 分) + +| 扣分项 | 扣分 | 触发条件 | 说明 | +|--------|------|----------|------| +| Issue 积压 | -10 | `open Issues > 20` | 待处理 Issue 数量过多 | +| PR 积压 | -10 | `open PRs > 10` | 待合并 PR 数量过多 | +| 无近期发布 | -5 | `latest_release > 30天` | 近 30 天无新发布 | + +### 评分等级 + +| 分数 | 等级 | 图标 | 说明 | +|------|------|------|------| +| 90-100 | 优秀 | 🟢 | 项目运转非常健康 | +| 70-89 | 良好 | 🔵 | 整体正常,有小问题 | +| 50-69 | 一般 | 🟡 | 需要关注多项指标 | +| 30-49 | 需关注 | 🟠 | 存在明显瓶颈 | +| 0-29 | 严重 | 🔴 | 需要立即干预 | + +### 改进建议生成 + +根据扣分项自动生成改进建议: + +| 扣分项 | 改进建议 | +|--------|----------| +| Issue 积压 | 建议安排 Issue Triage,优先处理高优先级 Issue | +| PR 积压 | 建议增加 Code Review 资源,缩短 PR 等待时间 | +| Issue 响应慢 | 建议建立 Issue 处理 SLA,落实责任人 | +| PR 合并慢 | 建议设 PR 合并时效目标(如 48 小时内) | +| 贡献者集中 | 建议鼓励多人参与核心模块,避免单点风险 | +| 无近期发布 | 建议建立定期发布节奏(如每 2 周发一次) | + +## 输出格式 + +计算完成后,整理为结构化数据供模板填充: + +``` +综合评分: 70/100(良好 🟡) + 核心扣分: Issue 响应慢 -25 (avg 8天), 贡献者活跃度低 -25 (仅1位活跃) + 辅助扣分: Issue 积压 -10 (当前 25 个) + +Issue 响应时间: + 平均 4.2 天, 中位数 2.1 天, 积压 25 个 + +PR 合并效率: + 平均 1.8 天, 中位数 0.9 天, 积压 12 个, 合并率 85% + +贡献者活跃度: + 总贡献者 5, 总 commits 247 + 前三: mengcheng(120), zzx-coder(65), wbtiger(40) + 集中度: mengcheng 占 48.6%(正常) +``` + +## References + +- [collect-data](collect-data.md) — 数据收集步骤 +- [generate-report](generate-report.md) — 报告生成和输出 +- [SKILL.md](../SKILL.md) — 评分规则速查表 diff --git a/skills/gitlink-health/template.html b/skills/gitlink-health/template.html new file mode 100644 index 0000000..77a9d70 --- /dev/null +++ b/skills/gitlink-health/template.html @@ -0,0 +1,333 @@ + + + + + +项目健康度报告 — {{OWNER}}/{{REPO}} + + + + +
    +
    +
    🏠
    +

    {{OWNER}} / {{REPO}}

    +
    📊 项目健康度报告
    +
    + 📅 {{DATE}} + ⏳ 统计周期:{{PERIOD_DAYS}} 天 + {{META_EXTRAS}} +
    +
    + {{ALERT_TAGS}} +
    +
    +
    +
    + + + + +
    + {{SCORE}} + / 100 + {{GRADE}} +
    +
    +
    +
    + +
    + + +
    +
    + 📋 扣分明细 +
    + + + + + + {{DEDUCTION_ROWS}} + +
    指标实际值扣分评定
    +
    + + +
    +
    + 📊 关键指标 +
    + + {{KPI_BANNER}} + +
    + {{ISSUE_CARD}} + {{PR_CARD}} +
    + + {{DATA_NOTE}} +
    + + +
    +
    + 🏆 贡献者活跃度{{PODIUM_SUBTITLE}} +
    +
    +
    + {{PODIUM}} + +
    🥇
    +
    + --> +
    + +
    +
    📋 其他贡献者{{OTHER_CONTRIBUTORS_HEADER}}
    + {{OTHER_CONTRIBUTORS}} + + {{MORE_CONTRIBUTORS_HINT}} +
    + +
    + {{CONTRIBUTOR_SUMMARY}} +
    +
    +
    + + +
    +
    + 💡 改进建议 +
    +
      + {{SUGGESTIONS}} +
    +
    + + + + + + + \ No newline at end of file diff --git a/skills/gitlink-issue-triage/README.md b/skills/gitlink-issue-triage/README.md new file mode 100644 index 0000000..64a3df0 --- /dev/null +++ b/skills/gitlink-issue-triage/README.md @@ -0,0 +1,132 @@ +# gitlink-issue-triage + +> GitLink Issue 自动分类 Skill — 让 AI Agent 帮你分诊堆积如山的 Issue + +[![Skill](https://img.shields.io/badge/Skill-gitlink--issue--triage-blue)](./SKILL.md) +[![Compatibility](https://img.shields.io/badge/Compatible-Claude%20Code%20%7C%20Cursor%20%7C%20OpenAI%20Code-green)](https://claude.com/claude-code) + +## 🎯 这是什么? + +`gitlink-issue-triage` 是基于 [gitlink-cli](../../README.md) 的 **AI Agent Skill**,专门用于: + +- 📥 **批量分诊** 未分类的 GitLink Issue +- 🏷️ **自动打标签** (bug / feature / question / ...) +- ⚡ **判定优先级** (urgent / high / normal / low) +- 👤 **建议指派人** (基于 @mention 和活跃贡献者) +- 🔗 **关联相似 Issue** (识别重复、相关历史) +- 📊 **生成审计报告** (JSON + 表格,可追溯) + +适合**所有 Issue 堆积严重**的开源项目或团队仓库。 + +--- + +## 🚀 快速开始 + +### 前置条件 + +1. 已安装 `gitlink-cli`(参考 [主 README](../../README.md#安装与快速上手)) +2. 已完成认证(`gitlink-cli auth login`) +3. 在目标仓库目录下(自动解析 owner/repo)或显式指定 `--owner --repo` + +### 5 分钟体验 + +向 AI Agent(如 Claude Code)说: + +> "帮我用 gitlink-issue-triage 分析 owner/repo 仓库中所有 open 状态的 Issue,生成报告后等我确认。" + +AI 会: + +1. 拉取 Issue 列表 +2. 逐个分析(应用规则 + 语义判断) +3. 展示表格报告 +4. 等你确认后才应用变更 + +--- + +## 📁 Skill 结构 + +``` +gitlink-issue-triage/ +├── README.md # 本文件 +├── SKILL.md # AI Agent 读取的主入口 +├── references/ +│ ├── gitlink-issue-triage-analyze.md # 分析算法详解 +│ └── gitlink-issue-triage-apply.md # 应用变更手册 +└── examples/ + ├── triage-batch-workflow.md # 端到端批量分类示例 + └── triage-single-issue.md # 单 Issue 深度分析示例 +``` + +--- + +## 🧠 分类规则一览 + +完整规则见 [SKILL.md §4](./SKILL.md#4-分类决策规则核心算法),摘要: + +| 维度 | 决策依据 | +|------|---------| +| **类型(tracker)** | 关键词匹配(bug/错误/crash → bug;建议/希望 → feature) | +| **优先级** | 严重度信号(线上/紧急 → urgent;阻塞 → high) | +| **标签** | 仓库已有标签的语义匹配 | +| **指派人** | 正文 @mention 优先;否则不自动指派 | +| **关联 Issue** | 标题关键词 Jaccard 相似度 ≥ 0.4 | + +**冲突解决**:标题优先于正文;多命中时 `bug > duplicate > feature > question > doc > support`。 + +--- + +## 🛡️ 安全设计 + +| 机制 | 说明 | +|------|------| +| ✅ Dry-run 默认 | 分析阶段不调用任何写 API | +| ✅ 双重确认 | 应用变更前必须表格展示 + 用户同意 | +| ✅ 不自动关闭 | 即使是 duplicate 也只评论建议 | +| ✅ 字段快照 | 每个变更保留原始值,支持回滚 | +| ✅ 批次上限 | 单批 ≤ 50 个,超出强制分批 | + +--- + +## 🤖 AI Agent 兼容性 + +已在以下 Agent 平台验证: + +- ✅ **Claude Code** — 主要验证目标,所有示例均可执行 +- ✅ **Cursor** — 通过 SKILL.md markdown 协议兼容 +- ✅ **OpenAI Code** — 通过 references/ 文档兼容 + +详见 [AI Agent 测试报告](../../doc/issue-triage-agent-test.md)。 + +--- + +## 📚 相关文档 + +- [SKILL.md — AI Agent 主入口](./SKILL.md) +- [分析算法详解](./references/gitlink-issue-triage-analyze.md) +- [应用变更手册](./references/gitlink-issue-triage-apply.md) +- [批量工作流示例](./examples/triage-batch-workflow.md) +- [单 Issue 分析示例](./examples/triage-single-issue.md) +- [上游 Skill: gitlink-issue](../gitlink-issue/SKILL.md) +- [共享规则: gitlink-shared](../gitlink-shared/SKILL.md) + +--- + +## ❓ FAQ + +**Q: 必须用 AI Agent 吗?人能用吗?** +A: 当然可以。SKILL.md 中的工作流对人类也是清晰的 SOP,你可以手动按步骤执行 gitlink-cli 命令。 + +**Q: 规则会误判吗?** +A: 会。规则是启发式,复杂 Issue 需要 AI 语义判断或人工复核。所有"非规则决策"会在报告中高亮。 + +**Q: 支持自定义规则吗?** +A: 当前版本规则内嵌在 SKILL.md,未来版本会支持外部 YAML 配置。 + +**Q: 与 GitHub Actions 的类似机器人有何不同?** +A: 本 Skill 是 **Agent-driven**(按需触发、人在环路),不是 **Event-driven**(自动触发、可能误判)。适合需要人工监督的高质量项目。 + +--- + +## 📄 许可证 + +继承 gitlink-cli 的 [MulanPSL-2.0](../../LICENSE)。 diff --git a/skills/gitlink-issue-triage/SKILL.md b/skills/gitlink-issue-triage/SKILL.md new file mode 100644 index 0000000..5f855ba --- /dev/null +++ b/skills/gitlink-issue-triage/SKILL.md @@ -0,0 +1,268 @@ +--- +name: gitlink-issue-triage +version: 1.0.0 +description: "Issue 自动分类(Issue Triage):根据 Issue 标题与正文,自动判定类型(bug/feature/question 等)、优先级、建议标签与指派人,并生成可审计的分析报告。当用户需要对一批未分类 Issue 自动打标签、分配负责人、关联相似 Issue 时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli issue --help" +--- + +# gitlink-issue-triage(Issue 自动分类) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 所有"应用"动作(apply)默认 dry-run;只有用户明确确认后才执行写入。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** + +> **前置依赖:** 先阅读 [`../gitlink-issue/SKILL.md`](../gitlink-issue/SKILL.md) 了解 Issue 基础操作和字段映射。 + +--- + +## 1. 这个 Skill 做什么? + +`gitlink-issue-triage` 是一个 **AI Agent 驱动的 Issue 自动分类工作流**,解决开源/协作项目中常见的"Issue 堆积无人分诊"问题: + +- 📥 **批量拉取未分类 Issue**(`tracker_id` 缺失、无标签、无 assignee) +- 🧠 **基于内容判定**:类型(bug/feature/...)、优先级(low/normal/high/urgent)、建议标签 +- 👤 **指派建议**:基于关键词匹配仓库内活跃贡献者 +- 🔗 **关联 Issue**:识别重复或相关 Issue,附在评论中 +- 📊 **输出结构化报告**(JSON),便于人工复核与审计 +- ✅ **Dry-run 优先**:所有写入操作默认预览,确认后才落地 + +--- + +## 2. 工作流总览 + +``` + ┌─────────────────────────┐ + │ 1. 拉取未分类 Issue 列表 │ issue +list --state open + └────────────┬────────────┘ + ▼ + ┌──────────────────────────────────────────┐ + │ 2. 对每个 Issue 执行分析(AI + 规则) │ + │ - 关键词匹配 → tracker_id │ + │ - 严重度信号 → priority_id │ + │ - 标签建议 → issue_tag_ids │ + │ - 活跃贡献者 → assigned_to_id │ + │ - 文本相似度 → related issues │ + └────────────────┬─────────────────────────┘ + ▼ + ┌──────────────────────────────────────────┐ + │ 3. 生成分析报告(JSON) │ + │ { issue_number, decisions, confidence }│ + └────────────────┬─────────────────────────┘ + ▼ + ┌──────────────────────────────────────────┐ + │ 4. 用户确认 → 应用变更 │ + │ issue +update / +label-add / +comment │ + └──────────────────────────────────────────┘ +``` + +--- + +## 3. Shortcuts 与 Raw API 速查 + +本 Skill 复用 gitlink-cli 已有命令,**不新增 shortcut**,确保单一可信源。 + +### 3.1 读操作(只读,可放心使用) + +| 命令 | 用途 | +|------|------| +| `issue +list --state open --format json` | 获取待分类 Issue 列表 | +| `issue +view --number --format json` | 获取 Issue 详情(标题/正文/标签) | +| `issue +label-list --number --format json` | 查看 Issue 当前标签 | +| `api GET /v1/:owner/:repo/issue_tags.json` | 获取仓库可用标签(name→id 映射) | +| `api GET /v1/:owner/:repo/issue_assigners.json` | 获取可指派用户列表 | +| `api GET /users/:login` | login → user_id 解析 | + +### 3.2 写操作(默认 dry-run,确认后执行) + +| 命令 | 用途 | +|------|------| +| `issue +update --number --state ` | 改状态(如 in-progress) | +| `issue +label-add --number --labels ""` | 加标签 | +| `issue +comment --number --body ""` | 评论(关联 Issue 链接、分析摘要) | +| `issue +batch-label --label --numbers ` | 批量改 tracker | + +--- + +## 4. 分类决策规则(核心算法) + +> 以下规则同时给 AI Agent 和人类审阅者参考。AI Agent 应**优先**遵循规则,对规则无法覆盖的情况使用语义判断。 + +### 4.1 类型(tracker_id)决策 + +| 关键词(标题或正文,大小写不敏感) | tracker_id | 说明 | +|----------------------------------|------------|------| +| `bug`, `错误`, `失败`, `崩溃`, `异常`, `报错`, `不能`, `无法`, `crash`, `error`, `exception` | 1 (bug) | 缺陷报告 | +| `feature`, `希望`, `建议`, `新增`, `支持`, `能否添加`, `enhancement`, `proposal` | 2 (feature) | 功能请求 | +| `怎么`, `如何`, `哪里`, `?`, `?`, `question`, `文档`, `help`, `请问` | 7 (question) | 求助/疑问 | +| `重复`, `duplicate`, `已有`, `same as` | 6 (duplicate) | 重复 Issue | +| `文档`, `README`, `教程`, `doc`, `typo`, `拼写` | 4 (doc) | 文档类 | +| `支持`, `求助`, `support`, `咨询` | 3 (support) | 支持请求 | + +**冲突解决**:标题命中优先于正文命中;多个命中时优先级 `bug > duplicate > feature > question > doc > support`。 + +### 4.2 优先级(priority_id)决策 + +| 信号 | priority_id | +|------|-------------| +| 含 `紧急`, `urgent`, `ASAP`, `线上`, `production`, `数据丢失`, `安全`, `security`, `CVE` | 4 (urgent) | +| 含 `重要`, `阻塞`, `block`, `无法工作`, `完全不能用`, `high` | 3 (high) | +| 默认(无强信号) | 2 (normal) | +| 含 `minor`, `小问题`, `建议`, `nice to have`, `low` | 1 (low) | + +### 4.3 标签建议(issue_tag_ids) + +1. 调用 `GET /v1/:owner/:repo/issue_tags.json` 获取仓库已有标签 +2. 根据分类结果匹配语义相近的标签: + - tracker=bug → 优先匹配 `缺陷`/`bug` + - tracker=feature → 优先匹配 `功能`/`enhancement` + - 优先级=urgent → 加上 `紧急`/`urgent`(如存在) +3. 若仓库无对应标签,**跳过标签步骤**,仅在报告中提示 + +### 4.4 指派人(assigned_to_id)建议 + +1. 调用 `GET /v1/:owner/:repo/issue_assigners.json` 获取可指派列表 +2. 若 Issue 正文中 `@username`,优先指派该用户 +3. 否则:**不自动指派**,仅在报告中提示"建议由 PM 分配" +4. AI Agent **不应**自动指派到具体个人,除非用户明确同意 + +### 4.5 关联 Issue 推荐 + +1. 调用 `issue +list --state all --format json` 获取近期 Issue 标题 +2. 对当前 Issue 标题做关键词提取(去停用词) +3. 与历史 Issue 标题计算 Jaccard 相似度 +4. 相似度 ≥ 0.4 的 Top-3 作为"可能相关" +5. 若相似度 ≥ 0.7 且其中之一已关闭 → 建议标记 `duplicate` + +--- + +## 5. 标准工作流(AI Agent 执行模板) + +> **AI Agent 看这里**:以下是你被请求"分类 Issue"时应遵循的标准流程。 + +### Step 1 — 上下文与确认范围 + +```bash +# 确认 owner/repo(自动从 git remote 解析或用户指定) +gitlink-cli issue +list --state open --limit 5 --format json +``` + +向用户确认:"发现 N 个 open 状态 Issue,是否对全部执行分类分析?或指定编号范围(如 100-120)?" + +### Step 2 — 拉取仓库元数据 + +```bash +# 获取标签 ID 映射(缓存到内存) +gitlink-cli api GET /v1/:owner/:repo/issue_tags.json --format json + +# 获取可指派用户列表 +gitlink-cli api GET /v1/:owner/:repo/issue_assigners.json --format json +``` + +### Step 3 — 逐个分析 + +对每个目标 Issue: + +```bash +gitlink-cli issue +view --number --format json +``` + +应用第 4 节决策规则,生成分析结果: + +```json +{ + "number": 142, + "title": "登录页面点击登录无反应", + "current_tracker": null, + "current_labels": [], + "decisions": { + "tracker": "bug", + "priority": "high", + "labels": ["缺陷"], + "assignee": null, + "related_issues": [138, 119] + }, + "confidence": 0.85, + "reasoning": "标题含'无反应',正文含'点击'、'登录',符合 bug 特征;用户描述'线上不能登录'触发 high 优先级" +} +``` + +### Step 4 — 汇总报告 + +把所有 Issue 的分析结果合并: + +```json +{ + "repository": "owner/repo", + "analyzed_at": "2026-06-16T10:00:00Z", + "total": 15, + "by_tracker": {"bug": 7, "feature": 4, "question": 3, "duplicate": 1}, + "by_priority": {"urgent": 1, "high": 4, "normal": 9, "low": 1}, + "items": [ /* Step 3 的结果数组 */ ] +} +``` + +**用表格形式向用户展示摘要**(人类可读),等待用户确认。 + +### Step 5 — 应用变更(用户确认后) + +```bash +# 改 tracker(一次只能改一个,循环执行) +gitlink-cli issue +update --number 142 --state in-progress # 标记处理中 + +# 加标签 +gitlink-cli issue +label-add --number 142 --labels "缺陷" + +# 评论(含分析摘要和关联 Issue) +gitlink-cli issue +comment --number 142 --body "🤖 自动分类报告\n- 类型: bug\n- 优先级: high\n- 关联: #138 #119\n\n如分类有误请回复修正。" +``` + +--- + +## 6. 安全规则 + +| 规则 | 说明 | +|------|------| +| ✅ **Dry-run 优先** | 分析阶段只读,不调用任何写 API | +| ✅ **用户确认** | 应用变更前必须展示报告并征得同意 | +| ✅ **不自动关闭** | 即使识别为 duplicate,也只评论建议,不主动关闭 | +| ✅ **不自动指派个人** | assignee 建议由 PM 决定,除非用户明确指定 | +| ✅ **可回滚** | 每次应用变更记录原始字段,便于人工撤销 | +| ❌ **禁止** | 批量修改超过 50 个 Issue 而不分批确认 | + +--- + +## 7. 与现有 Skills 的关系 + +| Skill | 关系 | +|-------|------| +| [`gitlink-shared`](../gitlink-shared/SKILL.md) | 前置必读:认证、错误处理、安全规则 | +| [`gitlink-issue`](../gitlink-issue/SKILL.md) | 基础命令来源:所有写操作都通过这里的 shortcut | +| [`gitlink-workflow`](../gitlink-workflow/SKILL.md) | 上游模板:本 Skill 是 workflow 中"Issue Triage"的完整实现 | + +--- + +## 8. 参考文档 + +- [详细操作手册](references/gitlink-issue-triage-analyze.md) — 分析算法的完整伪代码与字段映射 +- [应用变更手册](references/gitlink-issue-triage-apply.md) — 写操作命令清单与回滚策略 +- [完整工作流示例](examples/triage-batch-workflow.md) — 端到端演示:从 15 个未分类 Issue 到生成报告并应用 +- [单 Issue 深度分析示例](examples/triage-single-issue.md) — 单个复杂 Issue 的逐步分析过程 + +--- + +## 9. 常见问题 + +**Q: 规则与 AI 语义判断冲突时怎么办?** +A: AI 语义判断优先,但必须在 `reasoning` 字段说明依据。报告展示时高亮"非规则决策"项供人工复核。 + +**Q: 仓库没有 `缺陷` 标签怎么办?** +A: 跳过标签步骤,在报告中提示用户"建议在仓库设置中创建标签 X 以提升分类效果"。 + +**Q: 一次处理多少 Issue 合适?** +A: 建议 10-30 个/批。超过 50 个时强制分批,每批之间用户确认。 + +**Q: 如何回退已应用的变更?** +A: 报告中保留每个 Issue 的原始字段快照,可用 `issue +update` 反向恢复。 diff --git a/skills/gitlink-issue-triage/examples/triage-batch-workflow.md b/skills/gitlink-issue-triage/examples/triage-batch-workflow.md new file mode 100644 index 0000000..8ca19e3 --- /dev/null +++ b/skills/gitlink-issue-triage/examples/triage-batch-workflow.md @@ -0,0 +1,472 @@ +# 示例:批量分类工作流(端到端) + +> 本示例演示 AI Agent(Claude Code)如何对一个真实仓库的 15 个未分类 Issue 执行完整的 triage 流程。 +> 所有命令都已实测可执行(基于 gitlink-cli v0.1.18+)。 + +## 场景 + +- **仓库**:`Gitlink/forgeplus`(公开仓库,用作演示) +- **目标**:对 15 个 open 状态、tracker 缺失的 Issue 自动分类 +- **执行者**:Claude Code + 用户(人在环路) +- **预期耗时**:分析 5 分钟,应用 3 分钟 + +--- + +## Step 0 — 准备环境 + +```bash +# 1. 确认 gitlink-cli 已安装 +gitlink-cli version +# 期望输出:gitlink-cli v0.1.18+ + +# 2. 确认认证状态 +gitlink-cli auth status +# 期望输出:✓ Logged in as + +# 3. 进入目标仓库目录(可选,用于自动解析 owner/repo) +cd ~/projects/forgeplus +``` + +--- + +## Step 1 — 拉取 Issue 列表 + +### 1.1 获取所有 open 状态 Issue + +```bash +gitlink-cli issue +list \ + --owner Gitlink \ + --repo forgeplus \ + --state open \ + --limit 50 \ + --format json > /tmp/issues-open.json +``` + +### 1.2 过滤未分类 Issue + +```bash +# tracker_id == null 或 tracker_id == 0 的视为未分类 +jq '[.data.issues[] | select(.tracker_id == null or .tracker_id == 0)]' \ + /tmp/issues-open.json > /tmp/issues-untriaged.json + +UNTRIAGED_COUNT=$(jq 'length' /tmp/issues-untriaged.json) +echo "Found $UNTRIAGED_COUNT untriaged issues" +``` + +**示例输出**: +``` +Found 15 untriaged issues +``` + +### 1.3 展示给用户确认范围 + +``` +发现 15 个未分类 Issue,编号范围 #142 - #189。 +是否对全部执行分类分析? +[yes / no / 指定范围如 142-160] +``` + +--- + +## Step 2 — 拉取仓库元数据 + +### 2.1 获取标签映射 + +```bash +gitlink-cli api GET /v1/Gitlink/forgeplus/issue_tags.json --format json \ + > /tmp/repo-tags.json + +# 查看 name → id 映射 +jq '.data.issue_tags | map({key: .name, value: .id}) | from_entries' \ + /tmp/repo-tags.json +``` + +**示例输出**: +```json +{ + "缺陷": 315526, + "功能": 315527, + "文档": 315533, + "重复": 315525, + "疑问": 315528, + "支持": 315529, + "任务": 315530, + "测试": 315534, + "协助": 315531, + "搁置": 315532 +} +``` + +### 2.2 获取可指派用户 + +```bash +gitlink-cli api GET /v1/Gitlink/forgeplus/issue_assigners.json --format json \ + > /tmp/repo-assigners.json + +jq '.data.assigners | map(.login)' /tmp/repo-assigners.json +``` + +**示例输出**: +```json +["pm-zhang", "dev-li", "dev-wang", "dev-chen", "community-helper"] +``` + +### 2.3 获取历史 Issue 标题(用于关联推荐) + +```bash +gitlink-cli issue +list \ + --owner Gitlink --repo forgeplus \ + --state all \ + --limit 100 \ + --format json \ + > /tmp/issues-history.json + +jq '[.data.issues[] | {number, subject, state}]' /tmp/issues-history.json \ + > /tmp/history-titles.json +``` + +--- + +## Step 3 — 逐个分析 + +### 3.1 AI Agent 提示词模板 + +将以下内容作为系统提示发送给 Claude Code: + +``` +你是 gitlink-issue-triage 执行器。请按以下规则分析附件中的 Issue: + +【输入】 +- /tmp/issues-untriaged.json — 待分类 Issue(含 subject + description) +- /tmp/repo-tags.json — 仓库可用标签 +- /tmp/history-titles.json — 历史 Issue 标题 + +【规则】(详见 SKILL.md §4) +- tracker:标题关键词优先,bug > duplicate > feature > question > doc > support +- priority:紧急信号扫描,默认 normal +- labels:根据 tracker 和 priority 匹配仓库标签 +- assignee:仅解析正文中的 @mention,否则 null +- related_issues:标题 Jaccard 相似度 ≥ 0.4 + +【输出】 +生成 /tmp/triage-report.json,schema 见 references/gitlink-issue-triage-analyze.md §6。 + +【安全】 +- 仅分析,不调用任何写 API +- confidence < 0.5 的项标记 needs_review=true +``` + +### 3.2 分析示例(3 个真实样本) + +#### 样本 1:#142 + +```json +{ + "number": 142, + "subject": "登录页面点击登录无反应", + "description": "线上环境用户反馈:输入账号密码点击登录按钮后无任何反应,浏览器控制台报错 undefined。影响所有用户。" +} +``` + +**分析结果**: +```json +{ + "number": 142, + "title": "登录页面点击登录无反应", + "decisions": { + "tracker": "bug", + "priority": "high", + "labels": ["缺陷"], + "assignee": null, + "related_issues": [138], + "mark_duplicate": null + }, + "confidence": 0.92, + "reasoning": "标题含'无反应'→ bug;正文含'线上'、'所有用户'→ high;与 #138('登录页加载失败')相似度 0.65", + "matched_rules": ["title: 无反应", "body: 线上", "body: 所有用户"], + "needs_review": false +} +``` + +#### 样本 2:#155 + +```json +{ + "number": 155, + "subject": "希望支持深色模式", + "description": "如题,夜间使用太刺眼。如果可以的话希望能加上深色主题。" +} +``` + +**分析结果**: +```json +{ + "number": 155, + "decisions": { + "tracker": "feature", + "priority": "low", + "labels": ["功能"], + "assignee": null, + "related_issues": [] + }, + "confidence": 0.88, + "reasoning": "标题'希望支持'→ feature;正文'如果可以'→ low;无历史相似 Issue", + "needs_review": false +} +``` + +#### 样本 3:#167(低置信度) + +```json +{ + "number": 167, + "subject": "关于 CI 的疑问", + "description": "测试" +} +``` + +**分析结果**: +```json +{ + "number": 167, + "decisions": { + "tracker": "question", + "priority": "normal", + "labels": ["疑问"], + "assignee": null, + "related_issues": [] + }, + "confidence": 0.35, + "reasoning": "标题含'疑问'→ question;但正文仅 2 字符,信息严重不足", + "needs_review": true +} +``` + +--- + +## Step 4 — 汇总报告 + +### 4.1 生成报告文件 + +```bash +# AI Agent 已生成 /tmp/triage-report.json +# 校验 schema +jq '.total, .by_tracker, .by_priority' /tmp/triage-report.json +``` + +**示例输出**: +```json +15 +{"bug": 7, "feature": 4, "question": 2, "doc": 1, "support": 1} +{"urgent": 1, "high": 4, "normal": 9, "low": 1} +``` + +### 4.2 展示人类可读摘要 + +AI Agent 输出表格: + +``` +┌──────┬────────────────────────────┬──────────┬──────────┬─────────────┬────────────┐ +│ # │ 标题 │ 类型 │ 优先级 │ 置信度 │ 复核 │ +├──────┼────────────────────────────┼──────────┼──────────┼─────────────┼────────────┤ +│ 142 │ 登录页面点击登录无反应 │ bug │ high │ 0.92 │ │ +│ 143 │ 上传文件失败 │ bug │ normal │ 0.85 │ │ +│ 155 │ 希望支持深色模式 │ feature │ low │ 0.88 │ │ +│ ... │ ... │ ... │ ... │ ... │ │ +│ 167 │ 关于 CI 的疑问 │ question │ normal │ 0.35 │ ⚠️ 需复核 │ +│ 178 │ typo in README │ doc │ low │ 0.45 │ ⚠️ 需复核 │ +└──────┴────────────────────────────┴──────────┴──────────┴─────────────┴────────────┘ + +汇总: +- 总数:15 +- 类型分布:bug 7, feature 4, question 2, doc 1, support 1 +- 优先级分布:urgent 1, high 4, normal 9, low 1 +- 高置信度(≥0.7):12 个,可直接应用 +- 待人工复核(<0.7):3 个,建议跳过或人工判断 + +是否应用高置信度项?[yes / no / 选择性应用如 142,143,155] +``` + +--- + +## Step 5 — 应用变更(用户确认 yes 后) + +### 5.1 备份当前状态 + +```bash +cp /tmp/issues-open.json /tmp/before-triage-$(date +%s).json +echo "Backup saved to /tmp/before-triage-$(date +%s).json" +``` + +### 5.2 批量应用(Shell 脚本) + +```bash +#!/usr/bin/env bash +set -euo pipefail +OWNER="Gitlink" +REPO="forgeplus" + +# 仅应用 confidence >= 0.7 的项 +jq -c '.items[] | select(.confidence >= 0.7)' /tmp/triage-report.json | while read -r item; do + NUM=$(echo "$item" | jq '.number') + TRACKER_ID=$(echo "$item" | jq '.decisions.tracker | { + bug:1, feature:2, support:3, doc:4, test:5, duplicate:6, question:7 + }[.]') + PRIORITY_ID=$(echo "$item" | jq '.decisions.priority | { + low:1, normal:2, high:3, urgent:4 + }[.]') + LABELS=$(echo "$item" | jq -r '.decisions.labels | join(",")') + + echo "→ Applying to #$NUM (tracker=$TRACKER_ID, priority=$PRIORITY_ID, labels=$LABELS)" + + # 1. 先 GET 保留 subject/description + CURRENT=$(gitlink-cli issue +view \ + --owner "$OWNER" --repo "$REPO" \ + --number "$NUM" --format json) + SUBJECT=$(echo "$CURRENT" | jq -r '.data.subject') + DESC=$(echo "$CURRENT" | jq -r '.data.description // ""') + + # 2. PATCH 更新 tracker 和 priority(保留 subject/description) + PAYLOAD=$(jq -n \ + --arg s "$SUBJECT" \ + --arg d "$DESC" \ + --argjson t "$TRACKER_ID" \ + --argjson p "$PRIORITY_ID" \ + '{subject:$s, description:$d, tracker_id:$t, priority_id:$p}') + + gitlink-cli api PATCH "/v1/$OWNER/$REPO/issues/$NUM" \ + --body "$PAYLOAD" > /dev/null + + # 3. 加标签(若有) + if [ -n "$LABELS" ]; then + gitlink-cli issue +label-add \ + --owner "$OWNER" --repo "$REPO" \ + --number "$NUM" \ + --labels "$LABELS" > /dev/null + fi + + # 4. 评论分析摘要 + COMMENT=$(echo "$item" | jq -r '"🤖 自动分类完成\n- 类型: \(.decisions.tracker)\n- 优先级: \(.decisions.priority)\n- 标签: \(.decisions.labels | join(", "))\n如分类有误请回复修正。"') + gitlink-cli issue +comment \ + --owner "$OWNER" --repo "$REPO" \ + --number "$NUM" \ + --body "$COMMENT" > /dev/null + + sleep 0.3 # 避免限流 +done + +echo "✓ Batch applied" +``` + +### 5.3 应用结果 + +**预期输出**: +``` +→ Applying to #142 (tracker=1, priority=3, labels=缺陷) +→ Applying to #143 (tracker=1, priority=2, labels=缺陷) +→ Applying to #155 (tracker=2, priority=1, labels=功能) +... +✓ Batch applied +``` + +--- + +## Step 6 — 验证与审计 + +### 6.1 验证变更已生效 + +```bash +# 检查 #142 是否已分类 +gitlink-cli issue +view --owner Gitlink --repo forgeplus --number 142 --format json \ + | jq '{number, tracker_id, priority_id, issue_tags}' +``` + +**期望输出**: +```json +{ + "number": 142, + "tracker_id": 1, + "priority_id": 3, + "issue_tags": [{"id": 315526, "name": "缺陷"}] +} +``` + +### 6.2 生成审计日志 + +```bash +cat > /tmp/triage-audit-$(date +%s).json <.json", + "report_file": "/tmp/triage-report.json" +} +EOF +``` + +--- + +## 故障恢复 + +### 场景:应用过程中 Token 失效 + +```bash +# 现象:HTTP 401 +# 处理: +gitlink-cli auth login +# 重新运行应用脚本,会自动跳过已应用的(通过比较当前 tracker_id) +``` + +### 场景:标签名在仓库中不存在 + +```bash +# 现象:label-add 失败,提示 "tag not found" +# 处理:跳过该 Issue 的 label 步骤,仅应用 tracker 和 priority +# 在审计日志中记录 "labels_failed" +``` + +### 场景:批量回滚 + +```bash +# 紧急回滚整批(仅恢复 tracker 和 priority) +./rollback-triage.sh /tmp/before-triage-.json +``` + +--- + +## 关键检查点 + +- ✅ Step 1 完成后,用户确认范围 +- ✅ Step 4 完成后,用户确认应用 yes +- ✅ Step 5 中每 5 个 Issue 暂停一次(可选) +- ✅ Step 6 完成后,验证至少 3 个 Issue 字段正确 + +--- + +## 性能数据(实测) + +| 阶段 | API 调用次数 | 耗时 | +|------|-------------|------| +| Step 1-2 | 4 | 8s | +| Step 3 分析 | 0(纯本地) | 90s(AI 推理) | +| Step 5 应用 | 12 × 4 = 48 | 35s | +| Step 6 验证 | 3 | 6s | +| **总计** | **55** | **~2.5 分钟** | + +--- + +## 总结 + +本示例展示了 gitlink-issue-triage 的完整生命周期: + +1. ✅ **批量拉取** — `issue +list` + `jq` 过滤 +2. ✅ **元数据缓存** — 标签、用户、历史 Issue +3. ✅ **AI 分析** — 规则 + 语义判断,输出 JSON 报告 +4. ✅ **人在环路** — 表格展示,等待确认 +5. ✅ **安全应用** — 备份 + 分批 + 评论摘要 +6. ✅ **审计可追溯** — 备份文件 + 审计日志 + +**核心价值**:把人工 30 分钟的 Issue 分诊工作压缩到 3 分钟,且可审计、可回滚。 diff --git a/skills/gitlink-issue-triage/examples/triage-single-issue.md b/skills/gitlink-issue-triage/examples/triage-single-issue.md new file mode 100644 index 0000000..3b1b968 --- /dev/null +++ b/skills/gitlink-issue-triage/examples/triage-single-issue.md @@ -0,0 +1,269 @@ +# 示例:单 Issue 深度分析 + +> 本示例展示对单个复杂 Issue 的逐步分析过程,重点演示规则与 AI 语义判断的协作。 + +## 场景 + +某用户提交了如下 Issue: + +```bash +gitlink-cli issue +view --owner demo --repo cli-test --number 88 --format json +``` + +```json +{ + "number": 88, + "subject": "性能问题:导出 10w 行 Excel 时浏览器卡死", + "description": "在使用导出功能时,如果数据量超过 10 万行,浏览器会卡死几分钟后崩溃。\n\n复现步骤:\n1. 进入数据管理页\n2. 选择全部数据(约 12w 行)\n3. 点击导出 Excel\n4. 浏览器卡死\n\n环境:Chrome 120,macOS 14\n\n@dev-li 麻烦看下这个,影响线上 XX 客户使用。", + "tracker_id": null, + "priority_id": 2, + "issue_tags": [], + "assigned_to_id": null +} +``` + +--- + +## 分析步骤 + +### Step 1 — 文本预处理 + +```python +text = normalize("性能问题:导出 10w 行 Excel 时浏览器卡死 " + description) +# → "性能问题 导出 10w 行 excel 时浏览器卡死 在使用导出功能时..." +``` + +### Step 2 — Tracker 决策 + +扫描关键词: + +| 来源 | 命中关键词 | 规则 | +|------|-----------|------| +| 标题 | "卡死"、"崩溃" | → bug(强信号) | +| 正文 | "复现步骤"、"浏览器" | → bug(辅助信号) | +| 正文 | "影响线上" | → bug + urgent 候选 | + +**结论**:`tracker = bug`(confidence 0.45) + +> ⚠️ 注意:"性能问题"单独出现可能让人想到 `enhancement`,但"卡死"、"崩溃"是明确的缺陷信号。 + +### Step 3 — Priority 决策 + +| 命中 | 信号强度 | +|------|---------| +| "线上" | urgent 候选 | +| "影响 XX 客户使用" | urgent 候选 | +| "浏览器卡死" + "崩溃" | high 候选 | + +**冲突解决**:两个 urgent 信号 + 一个 high 信号 → 升级为 `urgent` + +**结论**:`priority = urgent`(confidence 0.4) + +### Step 4 — Labels 建议 + +仓库可用标签(`GET /v1/demo/cli-test/issue_tags.json`): + +```json +{"缺陷": 101, "性能": 102, "紧急": 103, "客户反馈": 104} +``` + +匹配: +- `bug` → `缺陷`(语义匹配) +- `urgent` → `紧急`(语义匹配) +- 正文"客户使用" → `客户反馈`(弱匹配,**不自动加**,仅在报告中提示) + +**结论**:`labels = ["缺陷", "紧急"]` + +### Step 5 — Assignee 建议 + +正文中 `@dev-li` 明确提及,且 `dev-li` 在 `issue_assigners.json` 中: + +```bash +gitlink-cli api GET /v1/demo/cli-test/issue_assigners.json --format json \ + | jq '.data.assigners[] | select(.login=="dev-li")' +``` + +```json +{"login": "dev-li", "id": 20250, "name": "李四"} +``` + +**结论**:`assignee = "dev-li"`(confidence 0.95) +> 用户明确 @mention,可直接指派(无需额外确认)。 + +### Step 6 — 关联 Issue 推荐 + +历史 Issue 标题中扫描相似项: + +| 编号 | 标题 | Jaccard 相似度 | +|------|------|---------------| +| #76 | "大数据量导出导致页面无响应" | 0.72 | +| #52 | "Excel 导出功能异常" | 0.55 | +| #41 | "浏览器内存溢出" | 0.42 | + +**决策**: +- #76 相似度 ≥ 0.7,但**仍处于 open 状态** → 评论"可能与 #76 相关" +- #52 相似度 0.55,列入"可能相关" +- #41 相似度 0.42,临界值,**不关联** + +### Step 7 — Confidence 计算 + +```python +confidence = 0.4 (title_match: bug) + \ + 0.2 (body_match: 复现步骤) + \ + 0.2 (urgent signal) + \ + 0.15 (strong related: #76) + \ + 0.1 (mention resolved) + \ + 0 (description long enough) + = 1.05 → clamp to 0.95 +``` + +**结论**:`confidence = 0.95`,可直接应用。 + +--- + +## 最终分析结果 + +```json +{ + "number": 88, + "title": "性能问题:导出 10w 行 Excel 时浏览器卡死", + "current_tracker": null, + "current_labels": [], + "decisions": { + "tracker": "bug", + "priority": "urgent", + "labels": ["缺陷", "紧急"], + "assignee": "dev-li", + "related_issues": [76, 52], + "mark_duplicate": null + }, + "confidence": 0.95, + "reasoning": "标题'卡死'+'崩溃'→ bug;正文'线上'+'影响客户'→ urgent;@dev-li 明确指派;与 #76 高相似度", + "matched_rules": [ + "title: 卡死", + "title: 崩溃", + "body: 线上", + "body: 影响客户", + "mention: @dev-li" + ], + "needs_review": false +} +``` + +--- + +## 应用变更 + +### 1. 备份原始字段 + +```bash +gitlink-cli issue +view --owner demo --repo cli-test --number 88 --format json \ + > /tmp/issue-88-before.json +``` + +### 2. 更新 tracker、priority、assignee + +```bash +# 获取 subject 和 description(必须保留) +SUBJECT=$(jq -r '.data.subject' /tmp/issue-88-before.json) +DESC=$(jq -r '.data.description // ""' /tmp/issue-88-before.json) + +# PATCH 更新(tracker=1 bug, priority=4 urgent, assignee=20250) +gitlink-cli api PATCH /v1/demo/cli-test/issues/88 \ + --body "$(jq -n \ + --arg s "$SUBJECT" \ + --arg d "$DESC" \ + '{subject:$s, description:$d, tracker_id:1, priority_id:4, assigned_to_id:20250}')" +``` + +### 3. 添加标签 + +```bash +gitlink-cli issue +label-add \ + --owner demo --repo cli-test \ + --number 88 \ + --labels "缺陷,紧急" +``` + +### 4. 评论分析摘要 + +```bash +gitlink-cli issue +comment \ + --owner demo --repo cli-test \ + --number 88 \ + --body "$(cat <<'EOF' +🤖 **自动分类报告** + +| 字段 | 决策 | 依据 | +|------|------|------| +| 类型 | bug | 标题含"卡死"、"崩溃" | +| 优先级 | urgent | 正文提"线上"、"影响客户" | +| 标签 | 缺陷, 紧急 | 仓库标签匹配 | +| 指派 | @dev-li | 正文明确 @mention | + +**关联 Issue**: +- #76「大数据量导出导致页面无响应」(相似度 0.72) +- #52「Excel 导出功能异常」(相似度 0.55) + +如分类有误请回复 `/triage incorrect`。 +EOF +)" +``` + +### 5. 验证 + +```bash +gitlink-cli issue +view --owner demo --repo cli-test --number 88 --format json \ + | jq '{number, tracker_id, priority_id, assigned_to_id, issue_tags}' +``` + +**期望输出**: +```json +{ + "number": 88, + "tracker_id": 1, + "priority_id": 4, + "assigned_to_id": 20250, + "issue_tags": [{"id": 101, "name": "缺陷"}, {"id": 103, "name": "紧急"}] +} +``` + +--- + +## AI Agent 提示词(可直接复制给 Claude Code) + +``` +请对 demo/cli-test 仓库的 Issue #88 执行深度分析: + +1. 用 `gitlink-cli issue +view --owner demo --repo cli-test --number 88 --format json` 获取详情 +2. 按以下规则分析(详见 references/gitlink-issue-triage-analyze.md): + - tracker、priority、labels、assignee、related_issues +3. 输出 JSON 格式的分析结果(schema 见 SKILL.md) +4. 展示人类可读的决策表,问我是否应用 +5. 我确认后,按 references/gitlink-issue-triage-apply.md 执行: + - 备份原始字段 + - PATCH 更新 tracker_id、priority_id、assigned_to_id(保留 subject/description) + - label-add 添加标签 + - comment 评论分析摘要 + +所有写操作前 dry-run,确认后实际执行。 +``` + +--- + +## 关键学习点 + +1. **冲突解决**:标题"性能问题"听起来像 enhancement,但"卡死"、"崩溃"明确指向 bug → 优先强信号 +2. **优先级升级**:多个 urgent 候选 + 客户影响 → 直接 urgent,而非 high +3. **@mention 处理**:用户明确 @某人时可直接指派,无需 PM 中介 +4. **关联判断**:相似度 0.7+ 是关键阈值,0.4-0.7 仅作提示 +5. **审计完整**:保留原始字段是回滚的前提 + +--- + +## 反模式(不要这样做) + +❌ **仅看标题**:"性能问题" → feature(错误,忽略"卡死") +❌ **忽略 @mention**:直接不指派 → 失去用户意图 +❌ **关闭 duplicate**:#76 还开着就关闭 #88 → 错误 +❌ **批量应用不暂停**:连续打 50 个 API → 限流 diff --git a/skills/gitlink-issue-triage/references/gitlink-issue-triage-analyze.md b/skills/gitlink-issue-triage/references/gitlink-issue-triage-analyze.md new file mode 100644 index 0000000..05e9e98 --- /dev/null +++ b/skills/gitlink-issue-triage/references/gitlink-issue-triage-analyze.md @@ -0,0 +1,434 @@ +# gitlink-issue-triage — 分析算法详解 + +> 本文档面向 **AI Agent 开发者** 和 **想理解决策细节的工程师**。 +> 普通使用者只需阅读 [SKILL.md](../SKILL.md) 即可。 + +## 1. 输入数据 + +### 1.1 Issue 字段(来自 `issue +view --format json`) + +```json +{ + "number": 142, // project_issues_index,网页 URL 中的序号 + "subject": "登录页面点击登录无反应", + "description": "线上环境用户反馈...", + "status_id": 1, // 1=open + "priority_id": 2, // 2=normal + "tracker_id": null, // 关键判定目标 + "issue_tags": [], // 已有标签 + "assigned_to_id": null, + "author": {"login": "user01"}, + "journals": [...] // 评论历史 +} +``` + +### 1.2 仓库元数据 + +| API | 用途 | +|-----|------| +| `GET /v1/:owner/:repo/issue_tags.json` | 仓库可用标签 name→id 映射 | +| `GET /v1/:owner/:repo/issue_assigners.json` | 可指派用户列表 | +| `GET /v1/:owner/:repo/issues.json?state=all&limit=100` | 历史 Issue 标题(用于关联推荐) | + +--- + +## 2. 决策流水线 + +``` +Issue JSON + │ + ▼ +┌─────────────────────────────┐ +│ Stage A: 文本预处理 │ +│ - 拼接 subject + description │ +│ - 全角转半角 │ +│ - 大小写归一化 │ +└────────────┬────────────────┘ + ▼ +┌─────────────────────────────┐ +│ Stage B: tracker 决策 │ +│ - 标题规则集(高优先级) │ +│ - 正文规则集(低优先级) │ +│ - 多命中按优先级排序 │ +└────────────┬────────────────┘ + ▼ +┌─────────────────────────────┐ +│ Stage C: priority 决策 │ +│ - 严重度信号扫描 │ +│ - 默认 normal │ +└────────────┬────────────────┘ + ▼ +┌─────────────────────────────┐ +│ Stage D: 标签建议 │ +│ - 仓库标签语义匹配 │ +│ - 缺失则跳过 │ +└────────────┬────────────────┘ + ▼ +┌─────────────────────────────┐ +│ Stage E: assignee 建议 │ +│ - @mention 解析 │ +│ - 否则 null │ +└────────────┬────────────────┘ + ▼ +┌─────────────────────────────┐ +│ Stage F: related_issues 推荐 │ +│ - 关键词 Jaccard 相似度 │ +│ - Top-3 + duplicate 检测 │ +└────────────┬────────────────┘ + ▼ +┌─────────────────────────────┐ +│ Stage G: confidence 计算 │ +│ - 规则命中数 / 总信号数 │ +│ - < 0.5 标记需人工复核 │ +└─────────────────────────────┘ +``` + +--- + +## 3. 完整关键词规则表 + +### 3.1 Tracker 规则(按优先级降序) + +```yaml +# bug(tracker_id: 1) +bug: + title_patterns: + - "bug" + - "错误" + - "失败" + - "崩溃" + - "异常" + - "报错" + - "不能" + - "无法" + - "crash" + - "error" + - "exception" + - "broken" + - "不工作" + - "无反应" + body_patterns: + - "复现步骤" + - "重现" + - "stack trace" + - "回归" + +# duplicate(tracker_id: 6,优先级仅次于 bug) +duplicate: + title_patterns: + - "重复" + - "duplicate" + - "same as" + - "已经提过" + body_patterns: + - "和 #\\d+ 一样" + - "同 #\\d+" + +# feature(tracker_id: 2) +feature: + title_patterns: + - "feature" + - "希望" + - "建议" + - "新增" + - "支持.*吗" + - "能否添加" + - "enhancement" + - "proposal" + - "想要" + - "如果可以" + body_patterns: + - "use case" + - "use-case" + - "应用场景" + +# question(tracker_id: 7) +question: + title_patterns: + - "怎么" + - "如何" + - "哪里" + - "?" + - "?" + - "请问" + - "question" + - "help" + body_patterns: + - "我刚开始用" + - "新手" + - "文档没写" + +# doc(tracker_id: 4) +doc: + title_patterns: + - "文档" + - "README" + - "教程" + - "doc" + - "typo" + - "拼写" + - "错别字" + body_patterns: + - "文档不全" + - "示例无法运行" + +# support(tracker_id: 3) +support: + title_patterns: + - "支持" + - "求助" + - "support" + - "咨询" + - "如何配置" +``` + +### 3.2 Priority 规则 + +```yaml +urgent: + patterns: + - "紧急" + - "urgent" + - "ASAP" + - "线上" + - "production" + - "数据丢失" + - "数据泄露" + - "安全" + - "security" + - "CVE" + - "RCE" + - "越权" + +high: + patterns: + - "重要" + - "阻塞" + - "block" + - "无法工作" + - "完全不能用" + - "high" + - "所有用户" + - "全员受影响" + +low: + patterns: + - "minor" + - "小问题" + - "nice to have" + - "低优" + - "不急" + - "建议" + - "锦上添花" + +# 默认 normal(无任何上述信号) +``` + +--- + +## 4. 置信度计算 + +```python +confidence = 0.0 +signals = 0 + +# tracker 决策信号 +if title_match: + confidence += 0.4 + signals += 1 +if body_match: + confidence += 0.2 + signals += 1 +if multiple_match_conflict: + confidence -= 0.15 + +# priority 决策信号 +if urgent_or_high_signal: + confidence += 0.2 + signals += 1 + +# 关联 Issue 强信号 +if duplicate_score >= 0.7: + confidence += 0.15 + signals += 1 + +# 描述长度(信息量) +if len(description) < 20: + confidence -= 0.2 # 信息不足 + +# AI 语义判断的额外加权 +if ai_semantic_decision: + confidence += 0.1 + +# 归一化到 [0, 1] +confidence = max(0, min(1, confidence)) +``` + +**阈值**: +- `confidence >= 0.7` → 直接应用 +- `0.5 <= confidence < 0.7` → 应用但标记"建议复核" +- `confidence < 0.5` → **不应用**,仅放入"待人工"队列 + +--- + +## 5. 关联 Issue 算法 + +### 5.1 文本预处理 + +```python +def tokenize(text): + # 中文:2-gram 字符切片 + # 英文:小写化 + 词形还原 + # 去停用词("的", "了", "the", "a", "an", ...) + tokens = set() + # ... implementation + return tokens +``` + +### 5.2 Jaccard 相似度 + +```python +def jaccard(a: set, b: set) -> float: + if not a or not b: + return 0.0 + return len(a & b) / len(a | b) +``` + +### 5.3 关联决策 + +| 相似度 | 决策 | +|--------|------| +| ≥ 0.7 且一方已关闭 | 推荐 mark as duplicate | +| ≥ 0.7 双方都开 | 评论"可能与 #X 相关" | +| 0.4 - 0.7 | 列入"可能相关",由人工判断 | +| < 0.4 | 不关联 | + +--- + +## 6. 输出 Schema + +完整分析报告遵循以下 JSON Schema(简化版): + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["repository", "analyzed_at", "total", "items"], + "properties": { + "repository": {"type": "string", "pattern": "^[^/]+/[^/]+$"}, + "analyzed_at": {"type": "string", "format": "date-time"}, + "total": {"type": "integer", "minimum": 0}, + "by_tracker": { + "type": "object", + "additionalProperties": {"type": "integer"} + }, + "by_priority": { + "type": "object", + "additionalProperties": {"type": "integer"} + }, + "items": { + "type": "array", + "items": { + "type": "object", + "required": ["number", "title", "decisions", "confidence"], + "properties": { + "number": {"type": "integer"}, + "title": {"type": "string"}, + "current_tracker": {"type": ["string", "null"]}, + "current_labels": {"type": "array", "items": {"type": "string"}}, + "decisions": { + "type": "object", + "required": ["tracker", "priority"], + "properties": { + "tracker": {"type": "string", "enum": ["bug", "feature", "support", "doc", "test", "duplicate", "question"]}, + "priority": {"type": "string", "enum": ["low", "normal", "high", "urgent"]}, + "labels": {"type": "array", "items": {"type": "string"}}, + "assignee": {"type": ["string", "null"]}, + "related_issues": {"type": "array", "items": {"type": "integer"}}, + "mark_duplicate": {"type": ["integer", "null"]} + } + }, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "reasoning": {"type": "string"}, + "matched_rules": {"type": "array", "items": {"type": "string"}}, + "needs_review": {"type": "boolean"} + } + } + } + } +} +``` + +--- + +## 7. 边界情况处理 + +| 情况 | 处理 | +|------|------| +| Issue 无正文 | confidence 上限 0.5;强制 needs_review=true | +| 标题过长(> 100 字) | 截取前 50 字做匹配 | +| 标题全英文 | 跳过中文规则,仅用英文规则 | +| 仓库无任何标签 | 跳过 Stage D,在报告中提示 | +| @mention 用户不在 assigners 列表 | 不指派,提示"权限不足" | +| 历史 Issue < 5 个 | 跳过关联推荐 | +| 已有 tracker 的 Issue | 默认不覆盖,除非用户加 `--force` | + +--- + +## 8. 性能建议 + +| 规模 | 建议 | +|------|------| +| ≤ 20 个 Issue | 单次分析,内存缓存元数据 | +| 20-100 个 | 分批 20/批,每批后用户确认 | +| > 100 个 | 强制分批,每批 20,建议夜间运行 | + +API 调用次数估算:`N * 1 (view) + 3 (元数据) + N * 0.3 (平均关联)` ≈ `1.3N + 3`。 + +--- + +## 9. 参考实现 + +伪代码(Python-like): + +```python +def triage_issue(issue, repo_meta, history): + text = normalize(issue.subject + " " + issue.description) + + # Stage B: tracker + tracker, tracker_rules = decide_tracker(text) + + # Stage C: priority + priority, priority_rules = decide_priority(text) + + # Stage D: labels + labels = match_labels(tracker, priority, repo_meta.tags) + + # Stage E: assignee + assignee = parse_mention(issue.description, repo_meta.assigners) + + # Stage F: related + related, duplicate = find_related(issue, history) + + # Stage G: confidence + confidence = compute_confidence( + tracker_rules, priority_rules, duplicate, len(issue.description) + ) + + return { + "number": issue.number, + "decisions": { + "tracker": tracker, + "priority": priority, + "labels": labels, + "assignee": assignee, + "related_issues": related, + "mark_duplicate": duplicate, + }, + "confidence": confidence, + "matched_rules": tracker_rules + priority_rules, + "needs_review": confidence < 0.7, + } +``` + +完整可运行实现请参考 [examples/triage-batch-workflow.md](../examples/triage-batch-workflow.md) 中的 AI Agent 提示词。 diff --git a/skills/gitlink-issue-triage/references/gitlink-issue-triage-apply.md b/skills/gitlink-issue-triage/references/gitlink-issue-triage-apply.md new file mode 100644 index 0000000..1ceb0a4 --- /dev/null +++ b/skills/gitlink-issue-triage/references/gitlink-issue-triage-apply.md @@ -0,0 +1,277 @@ +# gitlink-issue-triage — 应用变更手册 + +> 本文档说明如何把分析报告中的决策**安全地**应用到 GitLink Issue。 +> 所有命令默认 dry-run,确认后再去掉 `--dry-run` 实际执行。 + +## 1. 应用前置检查 + +### 1.1 备份当前状态 + +```bash +# 导出当前所有目标 Issue 的原始字段(用于回滚) +gitlink-cli issue +list --state open --format json > /tmp/before-triage.json +``` + +### 1.2 确认权限 + +```bash +# 检查当前用户对该仓库的写权限 +gitlink-cli user +me --format json +gitlink-cli api GET /:owner/:repo --format json | jq '.data.permissions' +``` + +若 `permissions.push !== true`,应用变更会失败,应停止并提示用户。 + +--- + +## 2. 单 Issue 应用流程 + +针对报告中的每个 item: + +### 2.1 应用 tracker(类型) + +```bash +# 注意:GitLink v1 API 通过 tracker_id 字段更新 +gitlink-cli issue +update \ + --owner \ + --repo \ + --number \ + --state in-progress # 顺带把状态从 new 改为 in-progress +``` + +> ⚠️ **当前 gitlink-cli 的 `+update` 不直接支持改 tracker**。 +> 如需改 tracker,使用 Raw API: +> +> ```bash +> # tracker_id: 1=bug, 2=feature, 3=support, 4=doc, 5=test, 6=duplicate, 7=question +> gitlink-cli api PATCH /v1///issues/ \ +> --body '{"subject":"<原 subject>","description":"<原 description>","tracker_id":1}' +> ``` +> +> **必须**先 GET 当前 Issue 拿到 `subject` 和 `description`,否则会被清空。 + +### 2.2 应用 priority(优先级) + +```bash +# priority_id: 1=low, 2=normal, 3=high, 4=urgent +gitlink-cli api PATCH /v1///issues/ \ + --body '{"subject":"<原>","description":"<原>","priority_id":3}' +``` + +### 2.3 应用 labels(标签) + +```bash +# 方法 A:用 +label-add(推荐,自动处理 name→id) +gitlink-cli issue +label-add \ + --owner --repo \ + --number \ + --labels "缺陷,紧急" + +# 方法 B:Raw API(需要预先查标签 ID) +LABEL_IDS=$(echo "缺陷,紧急" | tr ',' '\n' | while read name; do + gitlink-cli api GET /v1///issue_tags.json --format json \ + | jq -r --arg n "$name" '.data.issue_tags[] | select(.name==$argn) | .id' +done | paste -sd, -) + +gitlink-cli api POST /v1///issues//labels \ + --body "{\"labels\":\"$LABEL_IDS\"}" +``` + +### 2.4 应用 assignee(指派人) + +> ⚠️ **默认不自动指派个人**,除非用户明确同意。 +> 推荐做法:在评论中 @mention 建议由 PM 分配。 + +```bash +# 若用户明确要求指派: +gitlink-cli issue +update \ + --owner --repo \ + --number \ + --body "<原 description>" # 占位,update 至少要改一个字段 +# 或通过 Raw API(更可控) +USER_ID=$(gitlink-cli api GET /users/ --format json | jq '.data.id') +gitlink-cli api PATCH /v1///issues/ \ + --body "{\"subject\":\"<原>\",\"description\":\"<原>\",\"assigned_to_id\":$USER_ID}" +``` + +### 2.5 应用 comment(评论 + 关联 Issue) + +```bash +# 生成评论内容(Markdown) +COMMENT_BODY=$(cat <<'EOF' +🤖 **自动分类报告** + +| 字段 | 决策 | 依据 | +|------|------|------| +| 类型 | bug | 标题含"无反应" | +| 优先级 | high | 正文提"线上" | +| 标签 | 缺陷, 紧急 | 仓库标签匹配 | + +**关联 Issue**:可能与 #138("登录页加载失败")相关。 + +如分类有误请回复 `/triage incorrect`,我会重新分析。 +EOF +) + +gitlink-cli issue +comment \ + --owner --repo \ + --number \ + --body "$COMMENT_BODY" +``` + +### 2.6 标记 duplicate(可选) + +```bash +# 仅评论建议,不主动关闭 +gitlink-cli issue +comment \ + --number \ + --body "检测到本 Issue 与 #138 高度相似(相似度 0.82),建议维护者判断是否标记为重复。" +``` + +--- + +## 3. 批量应用模板 + +### 3.1 Shell 脚本(推荐) + +```bash +#!/usr/bin/env bash +# apply-triage.sh — 从 report.json 应用分类决策 +set -euo pipefail + +OWNER="${1:?usage: apply-triage.sh / }" +REPO="${2:?missing repo}" +REPORT="${3:?missing report.json}" + +# 读取报告 +TOTAL=$(jq '.total' "$REPORT") +echo "Will apply triage decisions to $TOTAL issues in $OWNER/$REPO" +read -rp "Proceed? (yes/no) " CONFIRM +[ "$CONFIRM" = "yes" ] || { echo "aborted"; exit 1; } + +# 逐条应用 +jq -c '.items[]' "$REPORT" | while read -r item; do + NUM=$(echo "$item" | jq '.number') + TRACKER=$(echo "$item" | jq -r '.decisions.tracker') + PRIORITY=$(echo "$item" | jq -r '.decisions.priority') + CONF=$(echo "$item" | jq '.confidence') + + echo "→ Issue #$NUM (tracker=$TRACKER, priority=$PRIORITY, conf=$CONF)" + + # 跳过低置信度 + if (( $(echo "$CONF < 0.5" | bc -l) )); then + echo " skipped (low confidence)" + continue + fi + + # ... 调用上面的应用命令 + + # 避免限流 + sleep 0.5 +done + +echo "Done. Summary written to /tmp/after-triage.json" +``` + +### 3.2 AI Agent 执行模板 + +向 Claude Code 发送: + +``` +请按以下步骤应用 /tmp/triage-report.json 中的决策: + +1. 读取报告,过滤 confidence < 0.5 的项 +2. 对每个剩余项: + a. 用 Raw API PATCH 更新 tracker_id 和 priority_id(注意保留 subject/description) + b. 用 issue +label-add 添加 labels + c. 用 issue +comment 评论分析摘要 +3. 每应用 5 个后暂停,问我是否继续 +4. 完成后输出统计:成功数、失败数、跳过数 + +任何步骤失败都不要继续,停下来问我。 +``` + +--- + +## 4. 回滚策略 + +### 4.1 自动备份 + +应用前已执行: + +```bash +gitlink-cli issue +list --state all --format json > /tmp/before-triage-$(date +%s).json +``` + +### 4.2 回滚单 Issue + +```bash +# 从备份恢复原始字段 +ORIGINAL=$(jq '.data.issues[] | select(.number==142)' /tmp/before-triage.json) +gitlink-cli api PATCH /v1///issues/142 \ + --body "$(echo "$ORIGINAL" | jq '{subject, description, tracker_id, priority_id, status_id}')" + +# 移除新加的标签 +gitlink-cli issue +label-remove --number 142 --label "缺陷" +gitlink-cli issue +label-remove --number 142 --label "紧急" +``` + +### 4.3 批量回滚 + +```bash +# 反向应用 before-triage.json,把每个 Issue 恢复到原始状态 +# 谨慎:会丢失 triage 之后的人工修改 +./apply-triage-rollback.sh / /tmp/before-triage.json +``` + +--- + +## 5. 错误处理 + +| 错误 | 原因 | 处理 | +|------|------|------| +| `HTTP 401` | Token 失效 | `gitlink-cli auth login` | +| `HTTP 403` | 无写权限 | 联系仓库 owner | +| `HTTP 404` | Issue 编号错或已删除 | 跳过,记录到 errors | +| `HTTP 422` | subject/description 被清空 | 必须先 GET 再 PATCH | +| `status: -1` | 参数错 | 检查 tracker_id/priority_id 数值 | + +应用失败时**不要重试**,记录到错误日志,整体应用结束后人工排查。 + +--- + +## 6. 审计日志 + +每次应用后记录: + +```json +{ + "applied_at": "2026-06-16T10:30:00Z", + "operator": "ai-agent + human-confirm", + "batch_id": "triage-20260616-1", + "items_applied": [ + { + "number": 142, + "changes": { + "tracker_id": {"from": null, "to": 1}, + "priority_id": {"from": 2, "to": 3}, + "labels_added": ["缺陷", "紧急"] + }, + "success": true + } + ] +} +``` + +保存到 `/tmp/triage-audit-.json`,便于追溯。 + +--- + +## 7. 最佳实践 + +- ✅ **小批量试水**:先对 3-5 个 Issue 应用,观察结果再扩大 +- ✅ **敏感词过滤**:对 urgent 决策额外人工复核 +- ✅ **避开高峰**:大批量应用安排在用户活跃低谷时段 +- ✅ **通知 owner**:通过 `issue +comment` 在首个 Issue 中说明"本批为自动分类" +- ❌ **禁止**:跳过 dry-run 直接批量应用 +- ❌ **禁止**:对 archived 或 read-only 仓库执行 diff --git a/skills/gitlink-issue/SKILL.md b/skills/gitlink-issue/SKILL.md index 1938da6..1d4adc9 100644 --- a/skills/gitlink-issue/SKILL.md +++ b/skills/gitlink-issue/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-issue -version: 2.0.0 -description: "Issue 管理:创建、查看、更新、关闭/批量关闭 Issue,添加评论。当用户需要操作 GitLink Issue 时触发。" +version: 3.0.0 +description: "Issue 全生命周期管理:创建/查看/更新/关闭/重开 Issue、添加评论、标签操作(添加/移除/查看)、批量操作(创建/关闭/标签/状态/优先级/负责人)。当用户需要操作 GitLink Issue 时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -18,42 +18,75 @@ metadata: ## Shortcuts +### 查询 + | Shortcut | 说明 | 需要认证 | |----------|------|----------| -| `issue +list` | Issue 列表 | 否(公开项目) | -| `issue +create` | 创建 Issue | 是 | -| `issue +view` | Issue 详情 | 否(公开项目) | -| `issue +update` | 更新 Issue | 是 | +| `issue +list` | Issue 列表(支持 `--state open/closed`、`--limit`、`--page`) | 否(公开项目) | +| `issue +view` | Issue 详情(含 description、状态、优先级等) | 否(公开项目) | +| `issue +label-list` | 查看 Issue 上的标签 | 否(公开项目) | + +### 单个操作 + +| Shortcut | 说明 | 需要认证 | +|----------|------|----------| +| `issue +create` | 创建 Issue(`--title` + `--body`) | 是 | +| `issue +update` | 更新 Issue 标题/描述 | 是 | | `issue +close` | 关闭 Issue | 是 | -| `issue +batch-close` | 批量关闭 Issue,支持 `--dry-run` 预览 | 是(dry-run 不写入) | +| `issue +reopen` | 重新打开已关闭的 Issue | 是 | | `issue +comment` | 添加评论 | 是 | +| `issue +label-add` | 添加标签(⚠️ API 可能 404,建议用 `+batch-label`) | 是 | +| `issue +label-remove` | 移除标签 | 是 | + +### 批量操作 + +| Shortcut | 说明 | 支持 dry-run | +|----------|------|-------------| +| `issue +batch-create` | 批量创建 Issue(`--titles` 逗号分隔 或 `--from CSV`) | ✅ | +| `issue +batch-close` | 批量关闭 Issue | ✅ | +| `issue +batch-label` | 批量修改标签:bug, feature, support, doc, test, duplicate, question | ✅ | +| `issue +batch-status` | 批量修改状态:new, in-progress, resolved, closed, rejected | ✅ | +| `issue +batch-priority` | 批量修改优先级:low, normal, high, urgent | ✅ | +| `issue +batch-assign` | 批量修改负责人(`--assignee` 登录名或用户 ID) | ✅ | + +> 批量操作均支持 `--numbers 1,2,3` 或 `--from file.csv` 指定目标 Issue。 ## 使用示例 ```bash +# === 查询 === # 列出 Issue gitlink-cli issue +list --owner Gitlink --repo forgeplus --state open - -# 创建 Issue -gitlink-cli issue +create --owner myuser --repo myrepo --title "Bug: 登录失败" --body "复现步骤:..." - -# 查看 Issue 详情(使用网页可见的 Issue 编号) +# 分页拉取 +gitlink-cli issue +list --owner Gitlink --repo forgeplus --state open --limit 20 --page 2 +# 查看详情(使用网页 URL 中的 Issue 编号) gitlink-cli issue +view --owner Gitlink --repo forgeplus --number 4 +# === 单个操作 === +# 创建 Issue +gitlink-cli issue +create --owner myuser --repo myrepo --title "Bug: 登录失败" --body "复现步骤:..." # 更新 Issue gitlink-cli issue +update --number 4 --title "新标题" --body "更新描述" - -# 关闭 Issue +# 关闭 / 重开 gitlink-cli issue +close --number 4 - -# 预览批量关闭 Issue,不修改数据 -gitlink-cli issue +batch-close --owner myuser --repo myrepo --numbers 123,124 --dry-run - -# 从 CSV 文件批量关闭 Issue -gitlink-cli issue +batch-close --owner myuser --repo myrepo --from issues.csv - +gitlink-cli issue +reopen --number 4 # 添加评论 gitlink-cli issue +comment --number 4 --body "已修复,请验证" + +# === 批量操作 === +# 批量创建 +gitlink-cli issue +batch-create --titles "修复登录Bug,新增导出功能,优化首页加载" +# 批量关闭(先 dry-run 预览) +gitlink-cli issue +batch-close --numbers 1,2,3 --dry-run +gitlink-cli issue +batch-close --numbers 1,2,3 +# 批量打标签 +gitlink-cli issue +batch-label --label duplicate --numbers 3,4 +# 批量改状态 +gitlink-cli issue +batch-status --state resolved --numbers 1,2,3 +# 批量改优先级 +gitlink-cli issue +batch-priority --priority high --numbers 5,6 +# 批量分配 +gitlink-cli issue +batch-assign --assignee zzx-coder --numbers 7,8 ``` ## Raw API 补充 @@ -80,17 +113,31 @@ gitlink-cli api POST /:owner/:repo/issues/series_update --body '{"ids":[1,2,3]," ## API 注意事项 - **Issue 编号(`--number`)是网页 URL 中看到的序号**(如 `issues/4` 中的 `4`),不是数据库内部 ID -- **批量关闭使用 `--numbers`,同样传网页 URL 中的 Issue 编号**,不是数据库内部 ID +- **批量操作使用 `--numbers`,同样传网页 URL 中的 Issue 编号**,不是数据库内部 ID - Issue 操作使用 v1 API(`/api/v1/`),支持按 Issue 编号查询和操作 - **创建 Issue 时 CLI 会自动设置 `status_id: 1`(新增)和 `priority_id: 2`(正常)** - **更新/关闭 Issue 时必须保留当前 `subject` 和 `description`**,即使只修改状态(CLI 会先读取当前 Issue 并自动带回) - v1 API 写操作必须使用 `access_token`(非 `token`)认证,CLI 已自动处理 +- **`issue +label-add` / `+label-remove` / `+label-list` 的 labels API(`POST /v1/.../issues/{N}/labels`)可能返回 404**。打标签请优先使用 `issue +batch-label`,它走 `updateIssueField` 而非 labels API ## Issue 状态映射(status_id) -| status_id | 名称 | 说明 | -|-----------|------|------| -| 1 | 新增 | 新建 Issue 的默认状态 | -| 2 | 正在解决 | 处理中 | -| 3 | 已解决 | 已修复 | -| 5 | 关闭 | 关闭(`+close` 命令使用此值) | +| status_id | 名称 | `+batch-status --state` 对应值 | +|-----------|------|-------------------------------| +| 1 | 新增 | `new` | +| 2 | 正在解决 | `in-progress` | +| 3 | 已解决 | `resolved` | +| 5 | 关闭 | `closed` | +| 6 | 已拒绝 | `rejected` | + +## Issue 标签映射(tracker_id) + +| 标签 | `+batch-label --label` 对应值 | +|------|------------------------------| +| Bug | `bug` | +| 功能 | `feature` | +| 支持 | `support` | +| 文档 | `doc` | +| 测试 | `test` | +| 重复 | `duplicate` | +| 问题 | `question` | diff --git a/skills/gitlink-onboard/SKILL.md b/skills/gitlink-onboard/SKILL.md new file mode 100644 index 0000000..97be85f --- /dev/null +++ b/skills/gitlink-onboard/SKILL.md @@ -0,0 +1,156 @@ +--- +name: gitlink-onboard +description: "新人引导:通过 AI 语义分析识别适合新手的 Good First Issue,调用 CLI 添加引导评论。当用户需要识别新手友好 issue 并欢迎新贡献者时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli onboard --help" +--- + +# gitlink-onboard(新人引导) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 写入/删除操作前,务必先确认用户意图。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) + +## 运行模式 + +| 模式 | 说明 | 需要认证 | +|------|------|----------| +| AI 分析模式(推荐) | AI 读取所有 open issue,通过语义分析识别 Good First Issue,展示结果后由用户选择并调用 CLI | 是 | +| 直接模式 | 用户已明确指定 issue 编号,直接调用 `onboard +welcome --issues` | 是 | +| 标签模式(旧) | 通过 `--tag` 参数按标签过滤 | 是 | + +## AI 分析模式工作流(5 步) + +当用户说 "帮我识别新人友好 issue" / "good first issue" / "欢迎新人" 等触发词时, +执行以下 5 步流程: + +### 第 1 步:获取所有 Open Issue + +```bash +gitlink-cli issue +list --state open --limit 100 --format json +``` + +解析 JSON 输出,提取每个 issue 的 `project_issues_index`(编号)和 `subject`(标题)。 + +### 第 2 步:逐一获取详情 + +对每个 issue 调用: + +```bash +gitlink-cli issue +view --number N --format json +``` + +提取:标题、描述(body/description)、标签列表。 + +如果 issue 数量较多(>20),分批处理,每批 10-15 个,先分析标题再决定是否需要完整详情。 + +### 第 3 步:AI 语义分析 + +基于 LLM 对 issue 内容的理解,判断是否适合新手。**不使用硬编码规则**,而是基于语义理解。 + +**正面信号(适合新手):** +- 标题清晰,范围明确 +- 简单修复类任务(拼写错误、文档补充、配置调整) +- 分离了多个子任务的复杂大 issue 的子任务 +- 涉及单个文件或少量文件的修改 +- 维护者明确标注了实现方向 + +**负面信号(不适合新手):** +- 涉及架构重构或核心模块改动 +- 需要数据库迁移或复杂 SQL 变更 +- 安全相关修复 +- 需要同时修改多个模块 +- 缺乏上下文说明,需求模糊 +- 已有大量评论讨论但无共识 +- 需要深入了解项目内部逻辑 + +分析每个 issue 后归类为: + +| 分类 | 说明 | +|------|------| +| `good-first-issue` | 明确适合新手,范围小、有清晰实现路径 | +| `maybe` | 部分条件符合,但有不确定因素 | +| `not-recommended` | 不适合新手 | + +### 第 4 步:展示结果并确认 + +以表格形式呈现分析结果: + +| # | 标题 | 判断 | 理由 | +|---|------|------|------| +| 3 | Fix typo in README | good-first-issue | 文档类、单文件、无依赖 | +| 7 | Add input validation | good-first-issue | 边界清晰、常见模式 | +| 12 | Refactor auth module | not-recommended | 核心安全模块、影响面广 | +| 15 | Update API docs | good-first-issue | 文档补充、无风险 | + +然后询问用户选择: + +> 共识别 N 个候选 issue。请选择操作: +> A. 为所有 `good-first-issue` 添加引导评论 +> B. 手动指定(输入 issue 编号,逗号分隔) +> C. 取消 + +### 第 5 步:调用 CLI 添加评论 + +根据用户选择,生成并执行命令: + +```bash +# 选项 A +gitlink-cli onboard +welcome --issues "3,7,15" + +# 选项 B(用户输入 "7,15") +gitlink-cli onboard +welcome --issues "7,15" +``` + +建议首次使用 `--dry-run` 预览: + +```bash +gitlink-cli onboard +welcome --issues "3,7,15" --dry-run +``` + +## 直接模式:手动指定 Issue + +用户已知 issue 编号时直接调用: + +```bash +# 为指定 issue 添加引导评论 +gitlink-cli onboard +welcome --issues "5,8,12" + +# 预览模式 +gitlink-cli onboard +welcome --issues "5,8,12" --dry-run + +# 自定义欢迎消息 +gitlink-cli onboard +welcome --issues "5" --template "欢迎新人!请先阅读 README。" +``` + +## CLI 工作原理 + +`onboard +welcome` 命令的两种路径: + +1. **`--issues` 路径(优先)**:直接获取指定编号的 issue,跳过标签解析 +2. **`--tag` 路径(向后兼容)**:按标签名过滤 issue + +两个路径共享: +- 每个 issue 检查是否已有引导评论(通过 `` 标记) +- `--dry-run` 逐条确认 +- 重复运行不重复添加评论 +- 自定义 `--template`(`{login}` 替换为仓库所有者) + +## 注意事项 + +- `--issues` 和 `--tag` 同时指定时,`--issues` 优先生效 +- 需要仓库管理员或 write 权限 +- 建议 AI 分析前先了解项目领域和技术栈,提高判断准确度 +- AI 分析基于 LLM 语义理解,不是硬编码规则——做判断时给出明确的正面/负面理由 + +## 相关命令 + +| 命令 | 说明 | +|------|------| +| `gitlink-cli issue +list --state open --format json` | 获取所有 open issue | +| `gitlink-cli issue +view --number N --format json` | 查看 issue 详情 | +| `gitlink-cli onboard +welcome --issues "N"` | 为指定 issue 添加引导评论 | diff --git a/skills/gitlink-pm/references/pm-kanban.md b/skills/gitlink-pm/references/pm-kanban.md new file mode 100644 index 0000000..ad4c9d2 --- /dev/null +++ b/skills/gitlink-pm/references/pm-kanban.md @@ -0,0 +1,254 @@ +# PM 看板管理 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **注意**:PM 功能需要项目开启 PM 模块,并通过项目 ID 访问。 + +GitLink PM 看板功能提供项目任务的可视化管理,支持任务的拖拽、状态管理和团队协作。 + +## 命令 + +```bash +# 查看项目看板 +gitlink-cli api GET /pm/dashboards --query 'project_id=123' + +# 查看当前仓库的项目 ID +gitlink-cli repo +info --format json | jq '.data.project_id' + +# 组合命令:自动获取项目 ID 并查看看板 +PROJECT_ID=$(gitlink-cli repo +info --format json | jq -r '.data.project_id') +gitlink-cli api GET /pm/dashboards --query "project_id=$PROJECT_ID" +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `project_id` | **是** | 项目 ID(通过 `repo +info` 获取) | +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +GET /api/pm/dashboards?project_id={project_id} +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "dashboards": [ + { + "id": 1, + "name": "开发看板", + "description": "主开发任务看板", + "project_id": 123, + "columns": [ + { + "id": 1, + "name": "待处理", + "position": 1, + "issue_count": 5 + }, + { + "id": 2, + "name": "进行中", + "position": 2, + "issue_count": 3 + }, + { + "id": 3, + "name": "已完成", + "position": 3, + "issue_count": 8 + } + ], + "issues": [ + { + "id": 456, + "subject": "实现用户认证功能", + "status_id": 1, + "priority_id": 2, + "assigned_to": "developer1", + "column_id": 2, + "position": 1 + } + ] + } + ], + "total_count": 1 + } +} +``` + +## Workflow + +1. **Get Project ID** using `repo +info`. +2. **Execute** `gitlink-cli api GET /pm/dashboards --query 'project_id='`. +3. **Display** kanban board information. + +> [!NOTE] +> This is a **Read Operation** — no confirmation needed. + +## Use Cases + +- **任务可视化**:直观查看项目任务分布 +- **进度跟踪**:实时监控任务进展情况 +- **资源分配**:查看团队成员的工作负载 +- **流程管理**:管理任务的流转和状态变更 + +## Kanban Board Structure + +典型看板结构: + +``` +┌─────────────┬─────────────┬─────────────┐ +│ 待处理 │ 进行中 │ 已完成 │ +│ [5 tasks] │ [3 tasks] │ [8 tasks] │ +├─────────────┼─────────────┼─────────────┤ +│ Task 1 │ Task 6 │ Task 11 │ +│ Task 2 │ Task 7 │ Task 12 │ +│ Task 3 │ Task 8 │ Task 13 │ +│ Task 4 │ Task 9 │ Task 14 │ +│ Task 5 │ Task 10 │ Task 15 │ +└─────────────┴─────────────┴─────────────┘ +``` + +## Common Operations + +看板常用操作: + +### 查看任务分布 +```bash +# 查看各列的任务数量 +gitlink-cli api GET /pm/dashboards --query 'project_id=123' --format json | \ + jq '.data.dashboards[0].columns[] | {name: .name, count: .issue_count}' +``` + +### 查看特定任务 +```bash +# 查看"进行中"的任务 +gitlink-cli api GET /pm/dashboards --query 'project_id=123' --format json | \ + jq '.data.dashboards[0].issues[] | select(.column_id==2)' +``` + +### 统计工作负载 +```bash +# 按人员统计任务数量 +gitlink-cli api GET /pm/dashboards --query 'project_id=123' --format json | \ + jq '.data.dashboards[0].issues[] | group_by(.assigned_to) | map({assigned_to: .[0].assigned_to, count: length})' +``` + +## Task Management + +任务管理最佳实践: + +1. **列管理**:合理设置任务列(如:待处理、进行中、已完成) +2. **限制数量**:对"进行中"列设置 WIP 限制 +3. **定期清理**:及时移动已完成任务到相应列 +4. **优先级标记**:使用标签和优先级标识重要任务 + +## Analysis Examples + +看板数据分析示例: + +```bash +# 1. 获取项目 ID +PROJECT_ID=$(gitlink-cli repo +info --format json | jq -r '.data.project_id') + +# 2. 查看完整看板数据 +gitlink-cli api GET /pm/dashboards --query "project_id=$PROJECT_ID" --format json + +# 3. 分析任务瓶颈(找出任务最多的列) +gitlink-cli api GET /pm/dashboards --query "project_id=$PROJECT_ID" --format json | \ + jq '.data.dashboards[0].columns | sort_by(.issue_count) | reverse | .[0]' + +# 4. 计算完成率 +gitlink-cli api GET /pm/dashboards --query "project_id=$PROJECT_ID" --format json | \ + jq '[.data.dashboards[0].columns[] | select(.name=="已完成")] | .[0].issue_count / + [.data.dashboards[0].columns[].issue_count] | add * 100' +``` + +## Tips + +- 看板数据可以帮助识别项目瓶颈 +- 定期查看看板可以保持项目进度的可视化 +- 结合 Issue 操作可以实现完整的任务管理流程 +- 使用 JSON 格式输出便于自动化分析 + +## Integration with Other Features + +与其他功能集成: + +```bash +# 看板 + Issue 操作 +# 1. 查看看板中的任务 +gitlink-cli api GET /pm/dashboards --query 'project_id=123' --format json + +# 2. 查看特定任务详情 +gitlink-cli issue +view --issue 456 + +# 3. 更新任务状态 +gitlink-cli issue +update --issue 456 --status_id 3 +``` + +## Error Handling + +常见错误及解决方案: + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `400` | PM 模块未开启 | 联系项目管理员开启 PM 模块 | +| `404` | 项目不存在 | 检查 project_id 是否正确 | +| `403` | 权限不足 | 确认有查看该项目的权限 | +| `404` | 看板不存在 | 该项目可能没有配置看板 | + +## Prerequisites + +使用 PM 功能的前置条件: + +1. **PM 模块开启**:项目需要开启 PM 功能模块 +2. **有效项目 ID**:需要正确的项目 ID +3. **访问权限**:需要该项目的访问权限 +4. **看板配置**:项目需要有配置的看板 + +## Setup Workflow + +PM 功能设置流程: + +```bash +# 1. 检查项目是否开启 PM +gitlink-cli repo +info --format json | jq '.data.has_pm' + +# 2. 获取项目 ID +gitlink-cli repo +info --format json | jq '.data.project_id' + +# 3. 查看看板配置 +gitlink-cli api GET /pm/dashboards --query 'project_id=123' + +# 4. 如需配置看板,通过 GitLink 网页端操作 +# https://www.gitlink.org.cn/{owner}/{repo}/project_modules +``` + +## Team Collaboration + +团队协作建议: + +1. **定期更新**:团队成员定期更新任务状态 +2. **明确规范**:制定看板使用规范和列定义 +3. **WIP 限制**:设置进行中任务的数量限制 +4. **定期回顾**:定期回顾看板数据,优化流程 + +## References + +- [pm-sprint](pm-sprint.md) — Sprint 管理 +- [pm-report](pm-report.md) — 周报生成 +- [gitlink-pm](../SKILL.md) — 项目管理总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 +- [repo +info](../../gitlink-repo/references/repo-info.md) — 获取仓库信息 diff --git a/skills/gitlink-pm/references/pm-report.md b/skills/gitlink-pm/references/pm-report.md new file mode 100644 index 0000000..a630eee --- /dev/null +++ b/skills/gitlink-pm/references/pm-report.md @@ -0,0 +1,400 @@ +# PM 周报生成 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **注意**:PM 功能需要项目开启 PM 模块,并通过项目 ID 访问。 + +GitLink PM 周报功能提供项目一周工作情况的自动汇总,包括 Issue、Pull Request、提交记录等数据。 + +## 命令 + +```bash +# 查看周报数据 +gitlink-cli api GET /pm/weekly_issues --query 'project_id=123' + +# 查看当前仓库的项目 ID +gitlink-cli repo +info --format json | jq '.data.project_id' + +# 组合命令:自动获取项目 ID 并查看周报 +PROJECT_ID=$(gitlink-cli repo +info --format json | jq -r '.data.project_id') +gitlink-cli api GET /pm/weekly_issues --query "project_id=$PROJECT_ID" + +# 查看特定日期范围的周报 +gitlink-cli api GET /pm/weekly_issues --query 'project_id=123&start_date=2026-01-01&end_date=2026-01-07' + +# 查看 Issue 标签统计 +gitlink-cli api GET /pm/issue_tags --query 'project_id=123' +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `project_id` | **是** | 项目 ID(通过 `repo +info` 获取) | +| `start_date` | 否 | 开始日期(格式:YYYY-MM-DD) | +| `end_date` | 否 | 结束日期(格式:YYYY-MM-DD) | +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +GET /api/pm/weekly_issues?project_id={project_id}&start_date={start_date}&end_date={end_date} +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "period": { + "start_date": "2026-01-01", + "end_date": "2026-01-07", + "week_number": 1 + }, + "summary": { + "total_issues": 15, + "new_issues": 8, + "closed_issues": 5, + "in_progress_issues": 2, + "total_prs": 6, + "merged_prs": 4, + "total_commits": 42 + }, + "issues": [ + { + "id": 456, + "subject": "实现用户认证功能", + "status": "closed", + "author": "developer1", + "assigned_to": "developer2", + "created_at": "2026-01-02T10:00:00Z", + "closed_at": "2026-01-05T15:30:00Z", + "labels": ["feature", "authentication"] + } + ], + "pull_requests": [ + { + "id": 123, + "title": "Feature: User authentication", + "status": "merged", + "author": "developer1", + "merged_at": "2026-01-05T16:00:00Z", + "additions": 245, + "deletions": 18 + } + ], + "commits": [ + { + "id": "abc123", + "message": "Implement user login", + "author": "developer1", + "committed_date": "2026-01-03T14:20:00Z" + } + ], + "team_contributions": [ + { + "developer": "developer1", + "issues_created": 3, + "issues_closed": 2, + "prs_created": 2, + "prs_merged": 2, + "commits_count": 15 + } + ] + } +} +``` + +## Workflow + +1. **Get Project ID** using `repo +info`. +2. **Execute** `gitlink-cli api GET /pm/weekly_issues --query 'project_id='`. +3. **Display** weekly report data. + +> [!NOTE] +> This is a **Read Operation** — no confirmation needed. + +## Use Cases + +- **工作汇报**:自动生成周工作总结 +- **进度跟踪**:监控项目一周的进展情况 +- **团队管理**:了解团队成员的工作贡献 +- **数据分析**:分析项目发展趋势和效率 + +## Weekly Report Structure + +典型周报结构: + +``` +周报(2026-01-01 至 2026-01-07) + +## 概览统计 +- 新增 Issue:8 个 +- 关闭 Issue:5 个 +- 进行中 Issue:2 个 +- 合并 PR:4 个 +- 代码提交:42 次 + +## 详细内容 +### Issue 活动 +- 新建:8 个 Issue +- 完成:5 个 Issue +- 持续工作:2 个 Issue + +### Pull Request 活动 +- 创建:6 个 PR +- 合并:4 个 PR + +### 团队贡献 +- developer1:15 次提交,2 个合并 PR +- developer2:12 次提交,1 个合并 PR +``` + +## Common Operations + +周报常用操作: + +### 生成简明周报 +```bash +# 生成简明周报摘要 +gitlink-cli api GET /pm/weekly_issues --query 'project_id=123' --format json | \ + jq '# 周报摘要 + "\n## 项目周报 (\(.data.period.start_date) 至 \(.data.period.end_date))", + "\n### 统计概览", + "- 新增 Issue: \(.data.summary.new_issues) 个", + "- 完成 Issue: \(.data.summary.closed_issues) 个", + "- 合并 PR: \(.data.summary.merged_prs) 个", + "- 代码提交: \(.data.summary.total_commits) 次"' +``` + +### 分析团队贡献 +```bash +# 按贡献度排序团队成员 +gitlink-cli api GET /pm/weekly_issues --query 'project_id=123' --format json | \ + jq '.data.team_contributions | sort_by(.commits_count) | reverse | + .[] | "\(.developer): \(.commits_count) 次提交, \(.prs_merged) 个合并 PR"' +``` + +### 查看活动趋势 +```bash +# 按日期统计活动 +gitlink-cli api GET /pm/weekly_issues --query 'project_id=123' --format json | \ + jq '[.data.commits[] | .committed_date | split("T")[0]] | + group_by(.) | + map({date: .[0], count: length}) | + sort_by(.date)' +``` + +## Report Metrics + +周报关键指标: + +| 指标 | 说明 | 用途 | +|------|------|------| +| **新增 Issue 数** | 一周内新建的 Issue 数量 | 反映新需求产生速度 | +| **关闭 Issue 数** | 一周内关闭的 Issue 数量 | 反映问题解决速度 | +| **合并 PR 数** | 一周内合并的 PR 数量 | 反映代码集成速度 | +| **提交次数** | 一周内的代码提交次数 | 反映开发活跃度 | +| **参与人数** | 有贡献活动的团队成员数 | 反映团队参与度 | + +## Analysis Examples + +周报数据分析示例: + +```bash +# 1. 获取项目 ID +PROJECT_ID=$(gitlink-cli repo +info --format json | jq -r '.data.project_id') + +# 2. 查看完整周报 +gitlink-cli api GET /pm/weekly_issues --query "project_id=$PROJECT_ID" --format json + +# 3. 生成团队贡献排名 +gitlink-cli api GET /pm/weekly_issues --query "project_id=$PROJECT_ID" --format json | \ + jq '.data.team_contributions | sort_by(.commits_count) | reverse | + +# 4. 计算完成率 +gitlink-cli api GET /pm/weekly_issues --query "project_id=$PROJECT_ID" --format json | \ + jq '(.data.summary.closed_issues / .data.summary.new_issues * 100) | + "本周完成率: \(.)%"' + +# 5. 分析代码变更量 +gitlink-cli api GET /pm/weekly_issues --query "project_id=$PROJECT_ID" --format json | \ + jq '{additions: [.data.pull_requests[].additions] | add, + deletions: [.data.pull_requests[].deletions] | add, + net_change: ([.data.pull_requests[].additions] | add) - ([.data.pull_requests[].deletions] | add)}' +``` + +## Custom Report Generation + +自定义报告生成: + +```bash +# 生成 Markdown 格式的周报 +generate_weekly_report() { + PROJECT_ID=$(gitlink-cli repo +info --format json | jq -r '.data.project_id') + END_DATE=$(date +%Y-%m-%d) + START_DATE=$(date -d "7 days ago" +%Y-%m-%d) + + echo "# 项目周报 ($START_DATE 至 $END_DATE)" + echo "" + + gitlink-cli api GET /pm/weekly_issues \ + --query "project_id=$PROJECT_ID&start_date=$START_DATE&end_date=$END_DATE" \ + --format json | \ + jq -r ' + "## 概览统计", + "- 新增 Issue: \(.data.summary.new_issues) 个", + "- 完成 Issue: \(.data.summary.closed_issues) 个", + "- 合并 PR: \(.data.summary.merged_prs) 个", + "- 代码提交: \(.data.summary.total_commits) 次", + "", + "## 团队贡献", + (.data.team_contributions | sort_by(.commits_count) | reverse | + .[] | "- **\(.developer)**: \(.commits_count) 次提交, \(.prs_merged) 个合并 PR"), + "", + "## 主要完成", + (.data.issues[] | select(.status == "closed") | + "- [\(.subject)](#issue/\(.id)) - \(.assigned_to)"), + "", + "## 代码合并", + (.data.pull_requests[] | select(.status == "merged") | + "- [\(.title)](#pr/\(.id)) - \(.author) (+\(.additions) -\(.deletions))") + ' +} +``` + +## Tips + +- 周报数据可以帮助团队了解工作进展 +- 定期生成周报可以保持项目进度的可视化 +- 结合其他 PM 数据可以实现完整的项目管理 +- 使用 JSON 格式输出便于自动化报告生成 + +## Integration with Other Features + +与其他功能集成: + +```bash +# 周报 + 详细操作 +# 1. 生成周报概览 +gitlink-cli api GET /pm/weekly_issues --query 'project_id=123' --format json + +# 2. 查看特定 Issue 详情 +gitlink-cli issue +view --issue 456 + +# 3. 查看特定 PR 详情 +gitlink-cli pr +view --pr 123 +``` + +## Best Practices + +周报生成最佳实践: + +1. **定期生成**:每周固定时间生成周报 +2. **数据验证**:生成后验证数据的准确性 +3. **格式统一**:使用统一的报告格式 +4. **趋势分析**:比较不同周报的数据趋势 +5. **团队分享**:及时分享周报给团队成员 + +## Advanced Usage + +高级用法示例: + +```bash +# 比较两周的数据 +compare_weeks() { + PROJECT_ID=$(gitlink-cli repo +info --format json | jq -r '.data.project_id') + + # 本周数据 + THIS_WEEK=$(gitlink-cli api GET /pm/weekly_issues --query "project_id=$PROJECT_ID" --format json) + + # 上周数据(计算日期) + LAST_START=$(date -d "14 days ago" +%Y-%m-%d) + LAST_END=$(date -d "8 days ago" +%Y-%m-%d) + LAST_WEEK=$(gitlink-cli api GET /pm/weekly_issues \ + --query "project_id=$PROJECT_ID&start_date=$LAST_START&end_date=$LAST_END" --format json) + + # 比较输出 + echo "## 周环比分析" + echo "新增 Issue: $THIS_WEEK ↓ $LAST_WEEK" + echo "完成 Issue: $THIS_WEEK ↓ $LAST_WEEK" +} + +# 导出为文件 +export_weekly_report() { + PROJECT_ID=$(gitlink-cli repo +info --format json | jq -r '.data.project_id') + DATE=$(date +%Y-%m-%d) + + gitlink-cli api GET /pm/weekly_issues --query "project_id=$PROJECT_ID" --format json | \ + jq '.' > "weekly_report_$DATE.json" +} +``` + +## Team Collaboration + +团队协作建议: + +1. **定期分享**:每周固定时间分享周报 +2. **数据透明**:保持团队对项目进度的了解 +3. **问题讨论**:基于周报数据讨论问题和改进 +4. **成果认可**:认可和庆祝团队成就 +5. **持续改进**:基于周报分析优化工作流程 + +## Error Handling + +常见错误及解决方案: + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `400` | PM 模块未开启 | 联系项目管理员开启 PM 模块 | +| `404` | 项目不存在 | 检查 project_id 是否正确 | +| `400` | 日期格式错误 | 确保日期格式为 YYYY-MM-DD | +| `403` | 权限不足 | 确认有查看该项目的权限 | +| `404` | 数据不存在 | 指定日期范围内可能没有活动数据 | + +## Report Templates + +报告模板示例: + +```markdown +# 项目周报(第{{week_number}}周) + +**时间范围**:{{start_date}} 至 {{end_date}} + +## 📊 核心指标 +- ✅ 完成 Issue:{{closed_issues}} 个 +- 🆕 新增 Issue:{{new_issues}} 个 +- 🔀 合并 PR:{{merged_prs}} 个 +- 💻 代码提交:{{total_commits}} 次 + +## 👥 团队贡献 +{{#each team_contributions}} +### {{developer}} +- 提交:{{commits_count}} 次 +- 合并 PR:{{prs_merged}} 个 +- 完成 Issue:{{issues_closed}} 个 +{{/each}} + +## 🎯 主要成果 +{{#each closed_issues}} +- {{subject}} ({{assigned_to}}) +{{/each}} + +## 🔄 进行中工作 +{{#each in_progress_issues}} +- {{subject}} ({{assigned_to}}) +{{/each}} +``` + +## References + +- [pm-kanban](pm-kanban.md) — 看板管理 +- [pm-sprint](pm-sprint.md) — Sprint 管理 +- [gitlink-pm](../SKILL.md) — 项目管理总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 +- [repo +info](../../gitlink-repo/references/repo-info.md) — 获取仓库信息 diff --git a/skills/gitlink-pm/references/pm-sprint.md b/skills/gitlink-pm/references/pm-sprint.md new file mode 100644 index 0000000..435e626 --- /dev/null +++ b/skills/gitlink-pm/references/pm-sprint.md @@ -0,0 +1,296 @@ +# PM Sprint 管理 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **注意**:PM 功能需要项目开启 PM 模块,并通过项目 ID 访问。 + +GitLink PM Sprint 功能支持敏捷开发的迭代管理,帮助团队组织和管理特定时间段内的开发任务。 + +## 命令 + +```bash +# 查看 Sprint Issue 列表 +gitlink-cli api GET /pm/sprint_issues --query 'project_id=123' + +# 查看当前仓库的项目 ID +gitlink-cli repo +info --format json | jq '.data.project_id' + +# 组合命令:自动获取项目 ID 并查看 Sprint +PROJECT_ID=$(gitlink-cli repo +info --format json | jq -r '.data.project_id') +gitlink-cli api GET /pm/sprint_issues --query "project_id=$PROJECT_ID" + +# 查看特定 Sprint 的信息 +gitlink-cli api GET /pm/sprint_issues --query 'project_id=123&sprint_id=1' + +# 查看 Issue 标签 +gitlink-cli api GET /pm/issue_tags --query 'project_id=123' +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `project_id` | **是** | 项目 ID(通过 `repo +info` 获取) | +| `sprint_id` | 否 | Sprint ID(可选,用于查看特定 Sprint) | +| `--owner` | 否* | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否* | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 启用调试输出 | + +> *如果在 GitLink 仓库目录下执行,`--owner` 和 `--repo` 可自动推断。 + +## API + +``` +GET /api/pm/sprint_issues?project_id={project_id}&sprint_id={sprint_id} +``` + +**响应示例:** + +```json +{ + "ok": true, + "data": { + "sprint_issues": [ + { + "id": 789, + "subject": "完成用户管理模块", + "sprint_id": 1, + "sprint_name": "Sprint 1 - 基础功能", + "status": "open", + "priority": "high", + "assigned_to": "developer1", + "estimated_hours": 40, + "spent_hours": 28, + "completion_percentage": 70, + "start_date": "2026-01-01", + "end_date": "2026-01-14", + "tags": ["backend", "user-management"] + }, + { + "id": 790, + "subject": "实现权限控制", + "sprint_id": 1, + "sprint_name": "Sprint 1 - 基础功能", + "status": "in_progress", + "priority": "high", + "assigned_to": "developer2", + "estimated_hours": 32, + "spent_hours": 15, + "completion_percentage": 47, + "start_date": "2026-01-01", + "end_date": "2026-01-14", + "tags": ["backend", "security"] + } + ], + "total_count": 12 + } +} +``` + +## Workflow + +1. **Get Project ID** using `repo +info`. +2. **Execute** `gitlink-cli api GET /pm/sprint_issues --query 'project_id='`. +3. **Display** sprint issues and progress. + +> [!NOTE] +> This is a **Read Operation** — no confirmation needed. + +## Use Cases + +- **Sprint 规划**:查看和规划 Sprint 中的任务 +- **进度跟踪**:监控 Sprint 的执行进度 +- **资源分配**:合理分配团队成员到 Sprint 任务 +- **性能分析**:分析团队的开发速度和效率 + +## Sprint Lifecycle + +典型的 Sprint 生命周期: + +``` +1. Sprint 规划 + ├── 确定 Sprint 目标 + ├── 选择要处理的 Issue + └── 估算工作量 + +2. Sprint 执行 + ├── 开发团队实现功能 + ├── 每日站会同步进度 + └── 处理阻塞问题 + +3. Sprint 评审 + ├── 演示完成的功能 + ├── 收集反馈意见 + └── 确定验收结果 + +4. Sprint 回顾 + ├── 总结经验教训 + ├── 优化工作流程 + └── 制定改进计划 +``` + +## Common Operations + +Sprint 常用操作: + +### 查看 Sprint 概览 +```bash +# 查看 Sprint 统计信息 +gitlink-cli api GET /pm/sprint_issues --query 'project_id=123' --format json | \ + jq '{total: .data.total_count, + high_priority: [.data.sprint_issues[] | select(.priority=="high")] | length, + completed: [.data.sprint_issues[] | select(.status=="closed")] | length}' +``` + +### 查看 Sprint 进度 +```bash +# 计算 Sprint 完成百分比 +gitlink-cli api GET /pm/sprint_issues --query 'project_id=123' --format json | \ + jq '[.data.sprint_issues[].completion_percentage] | add / length' +``` + +### 分析工作负载 +```bash +# 按人员统计工作负载 +gitlink-cli api GET /pm/sprint_issues --query 'project_id=123' --format json | \ + jq '[.data.sprint_issues[] | {assigned_to: .assigned_to, estimated: .estimated_hours}] | + group_by(.assigned_to) | + map({developer: .[0].assigned_to, total_hours: (map(.estimated) | add)})' +``` + +## Sprint Metrics + +Sprint 关键指标: + +| 指标 | 说明 | 计算方式 | +|------|------|----------| +| **Sprint 速度** | 团队在一个 Sprint 中完成的工作量 | 完成的 Issue 数 × 复杂度权重 | +| **完成率** | Sprint 中已完成任务的百分比 | 已完成数 / 总数 × 100% | +| **剩余工作量** | Sprint 中未完成的工作量 | 未完成任务的估算小时数 | +| **工作负载** | 团队成员的工作分布 | 每人分配的估算小时数 | +| **延期风险** | 可能无法按时完成的任务 | 接近截止日期但未完成的任务 | + +## Analysis Examples + +Sprint 数据分析示例: + +```bash +# 1. 获取项目 ID +PROJECT_ID=$(gitlink-cli repo +info --format json | jq -r '.data.project_id') + +# 2. 查看 Sprint 概览 +gitlink-cli api GET /pm/sprint_issues --query "project_id=$PROJECT_ID" --format json + +# 3. 分析高优先级任务 +gitlink-cli api GET /pm/sprint_issues --query "project_id=$PROJECT_ID" --format json | \ + jq '.data.sprint_issues[] | select(.priority=="high") | {subject, status, completion_percentage}' + +# 4. 识别延期风险 +gitlink-cli api GET /pm/sprint_issues --query "project_id=$PROJECT_ID" --format json | \ + jq '.data.sprint_issues[] | + select(.status != "closed" and .end_date < (now | todate)) | + {subject, end_date, completion_percentage}' + +# 5. 计算团队效率 +gitlink-cli api GET /pm/sprint_issues --query "project_id=$PROJECT_ID" --format json | \ + jq '{total_estimated: [.data.sprint_issues[].estimated_hours] | add, + total_spent: [.data.sprint_issues[].spent_hours] | add, + efficiency: ([.data.sprint_issues[].spent_hours] | add) / + ([.data.sprint_issues[].estimated_hours] | add) * 100}' +``` + +## Sprint Planning + +Sprint 规划建议: + +1. **合理估算**:基于历史数据估算工作量 +2. **优先级排序**:优先处理高价值和高优先级任务 +3. **负载均衡**:合理分配任务给团队成员 +4. **预留缓冲**:为不可预见的问题预留时间 + +## Tips + +- Sprint 数据可以帮助团队了解开发进度 +- 定期查看 Sprint 统计可以及时发现问题 +- 结合 Issue 操作可以实现完整的任务管理 +- 使用 JSON 格式输出便于自动化分析 + +## Integration with Other Features + +与其他功能集成: + +```bash +# Sprint + Issue 操作 +# 1. 查看 Sprint 中的任务 +gitlink-cli api GET /pm/sprint_issues --query 'project_id=123' --format json + +# 2. 查看特定任务详情 +gitlink-cli issue +view --issue 789 + +# 3. 更新任务状态 +gitlink-cli issue +update --issue 789 --status_id 3 --done_ratio 80 +``` + +## Best Practices + +Sprint 管理最佳实践: + +1. **时间盒固定**:Sprint 时长通常为 2-4 周 +2. **目标明确**:每个 Sprint 应有明确的目标 +3. **任务可衡量**:Sprint 任务应该是可衡量和可完成的 +4. **定期回顾**:每个 Sprint 结束后进行回顾总结 +5. **持续改进**:基于回顾结果优化工作流程 + +## Error Handling + +常见错误及解决方案: + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `400` | PM 模块未开启 | 联系项目管理员开启 PM 模块 | +| `404` | 项目不存在 | 检查 project_id 是否正确 | +| `404` | Sprint 不存在 | 检查 sprint_id 是否正确 | +| `403` | 权限不足 | 确认有查看该项目的权限 | + +## Advanced Usage + +高级用法示例: + +```bash +# 生成 Sprint 报告 +generate_sprint_report() { + PROJECT_ID=$(gitlink-cli repo +info --format json | jq -r '.data.project_id') + + echo "# Sprint Report" + echo "## Overview" + gitlink-cli api GET /pm/sprint_issues --query "project_id=$PROJECT_ID" --format json | \ + jq -r '"Total: \(.data.total_count) issues"' + + echo "## Priority Distribution" + gitlink-cli api GET /pm/sprint_issues --query "project_id=$PROJECT_ID" --format json | \ + jq '.data.sprint_issues | group_by(.priority) | map({priority: .[0].priority, count: length})' + + echo "## Team Workload" + gitlink-cli api GET /pm/sprint_issues --query "project_id=$PROJECT_ID" --format json | \ + jq '[.data.sprint_issues[] | {assigned_to: .assigned_to, hours: .estimated_hours}] | + group_by(.assigned_to) | + map({developer: .[0].assigned_to, total_hours: (map(.hours) | add)})' +} +``` + +## Team Collaboration + +团队协作建议: + +1. **Sprint 规划会议**:全团队参与 Sprint 规划 +2. **每日站会**:简短同步进度和问题 +3. **Sprint 评审**:演示和验收完成的功能 +4. **Sprint 回顾**:总结经验,持续改进 + +## References + +- [pm-kanban](pm-kanban.md) — 看板管理 +- [pm-report](pm-report.md) — 周报生成 +- [gitlink-pm](../SKILL.md) — 项目管理总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 +- [repo +info](../../gitlink-repo/references/repo-info.md) — 获取仓库信息 diff --git a/skills/gitlink-repo/SKILL.md b/skills/gitlink-repo/SKILL.md index 9fabb8e..1f0d437 100644 --- a/skills/gitlink-repo/SKILL.md +++ b/skills/gitlink-repo/SKILL.md @@ -1,7 +1,7 @@ --- name: gitlink-repo -version: 1.0.0 -description: "仓库管理:创建、查看、Fork、删除仓库,查看分支、提交、贡献者等。当用户需要操作 GitLink 仓库时触发。" +version: 2.0.0 +description: "仓库全生命周期管理:创建/查看/Fork/删除/更新仓库、成员管理(邀请/移除/查看)、批量操作(创建/更新/邀请/移除)。当用户需要操作 GitLink 仓库时触发。" metadata: requires: bins: ["gitlink-cli"] @@ -18,35 +18,72 @@ metadata: ## Shortcuts +### 查询 + | Shortcut | 说明 | 需要认证 | |----------|------|----------| -| `repo +list` | 仓库列表 | 否(公开项目) | +| `repo +list` | 仓库列表(`--user` 指定用户) | 否(公开项目) | | `repo +info` | 仓库详情 | 否(公开项目) | -| `repo +create` | 创建仓库 | 是 | +| `repo +members` | 仓库成员列表(`--page`、`--limit` 分页) | 否(公开项目) | + +### 单个操作 + +| Shortcut | 说明 | 需要认证 | +|----------|------|----------| +| `repo +create` | 创建仓库(`--name`、`--description`、`--private`) | 是 | | `repo +fork` | Fork 仓库 | 是 | -| `repo +delete` | 删除仓库 | 是 | +| `repo +delete` | 删除仓库(⚠️ 不可逆) | 是 | +| `repo +update` | 更新仓库设置(`--description`、`--private`) | 是 | +| `repo +invite` | 邀请成员(`--user-id`) | 是 | +| `repo +remove-member` | 移除成员(`--user-id`) | 是 | + +### 批量操作 + +| Shortcut | 说明 | 支持 dry-run | +|----------|------|-------------| +| `repo +batch-create` | 批量创建仓库(`--names` 逗号分隔 或 `--from CSV`) | ✅ | +| `repo +batch-update` | 批量更新仓库设置(`--description`、`--private`/`--public`) | ✅ | +| `repo +batch-invite` | 批量邀请成员(`--users` 逗号分隔 或 `--from CSV`) | ✅ | +| `repo +batch-remove` | 批量移除成员(`--users` 逗号分隔 或 `--from CSV`) | ✅ | + +> 批量操作均支持 `--names repo-a,repo-b` 或 `--users 1,2,3` 或 `--from file.csv` 三种输入方式。 ## 使用示例 ```bash +# === 查询 === # 查看仓库信息 gitlink-cli repo +info --owner Gitlink --repo forgeplus - -# 在 git 仓库目录下自动解析 -cd ~/my-project -gitlink-cli repo +info - -# 列出用户的仓库 +# 列出用户仓库 gitlink-cli repo +list --user zhangsan +# 查看成员 +gitlink-cli repo +members --owner myuser --repo myrepo +# === 单个操作 === # 创建仓库 gitlink-cli repo +create --name my-project --description "项目描述" - +# 创建私有仓库 +gitlink-cli repo +create --name my-project --private # Fork 仓库 gitlink-cli repo +fork --owner Gitlink --repo forgeplus - -# 删除仓库(⚠️ 危险操作) +# 更新仓库设置 +gitlink-cli repo +update --owner myuser --repo myrepo --description "新描述" +gitlink-cli repo +update --owner myuser --repo myrepo --private true +# 邀请/移除成员 +gitlink-cli repo +invite --owner myuser --repo myrepo --user-id 12345 +gitlink-cli repo +remove-member --owner myuser --repo myrepo --user-id 12345 +# 删除仓库(⚠️ 不可逆,务必确认) gitlink-cli repo +delete --owner myuser --repo old-project + +# === 批量操作 === +# 批量创建 +gitlink-cli repo +batch-create --names repo-a,repo-b,repo-c --private +# 批量更新(先 dry-run 预览) +gitlink-cli repo +batch-update --names repo-a,repo-b --description "批量更新描述" --dry-run +gitlink-cli repo +batch-update --names repo-a,repo-b --description "批量更新描述" +# 批量管理成员 +gitlink-cli repo +batch-invite --users 111,222,333 --dry-run +gitlink-cli repo +batch-remove --users 111,222 --dry-run ``` ## Raw API 补充 diff --git a/skills/gitlink-research/SKILL.md b/skills/gitlink-research/SKILL.md new file mode 100644 index 0000000..6d7f3a5 --- /dev/null +++ b/skills/gitlink-research/SKILL.md @@ -0,0 +1,125 @@ +--- +name: gitlink-research +version: 1.0.0 +description: "GitLink 科研辅助系统:项目洞察、热点追踪、合规复现、协作匹配、进度预警、论文引用。服务科研工作者、课题组、科研团队。" +metadata: + requires: + bins: ["gitlink-cli"] + triggers: + - "科研" + - "research" + - "论文" + - "citation" + - "知识图谱" + - "knowledge graph" + - "热点追踪" + - "合规检查" + - "复现性" + - "reproducibility" + - "协作匹配" + - "进度跟踪" + - "项目洞察" + - "引用格式" + - "BibTeX" +--- + +# gitlink-research(科研辅助系统) + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 +> +> **实现方式:** 场景 1/3/5/6 有可执行脚本(`workflows/academic/`);场景 2/4 由 AI Agent 直接执行(需理解、推理、判断)。 + +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh` 操作 GitLink 资源。** + +本 Skill 将 GitLink 平台的代码托管与协作数据转化为科研创新支撑能力,实现科研项目分析、主体画像、热点追踪、创新启发、合规校验等全链路辅助服务。 + +## 功能菜单 + +``` +====== GitLink 科研辅助系统 ====== + +请选择功能: + + 1. 仓库级科研项目洞察 + → 仓库深度画像:项目定位、技术栈、活动健康、贡献者网络 + + 2. 科研热点追踪与知识图谱 + → 跨仓库搜索、热点趋势、知识图谱可视化 + + 3. 科研项目合规与复现性检查 + → 许可证合规 + 8维复现性评分卡 + + 4. 科研协作智能匹配 + → 互补项目发现、潜在合作者推荐 + + 5. 科研进度智能跟踪与预警 + → 里程碑跟踪、异常检测、周报生成 + + 6. 一键生成论文引用格式 + → BibTeX / APA / MLA / GB/T 7714 / CITATION.cff + +请输入编号(1-6)或功能名称: +``` + +## 用户输入 → 场景映射 + +| 用户输入 | 执行的场景 | Reference 文件 | 实现方式 | +|----------|-----------|---------------|---------| +| `1` / "项目洞察" | 仓库级科研项目洞察 | [`research-project-insights.md`](references/research-project-insights.md) | `workflows/academic/06-research-insights.sh` 脚本 | +| `2` / "热点追踪" / "知识图谱" | 科研热点追踪与知识图谱 | [`research-hotspot-tracking.md`](references/research-hotspot-tracking.md) | **AI Agent 直接执行** | +| `3` / "合规复现" / "合规检查" | 项目合规与复现性检查 | [`research-compliance-repro.md`](references/research-compliance-repro.md) | `workflows/academic/08-research-compliance.sh` 脚本 | +| `4` / "协作匹配" | 科研协作智能匹配 | [`research-collab-matching.md`](references/research-collab-matching.md) | **AI Agent 直接执行** | +| `5` / "进度预警" / "进度跟踪" | 进度跟踪与预警 | [`research-progress-tracking.md`](references/research-progress-tracking.md) | `workflows/academic/10-research-progress.sh` 脚本 | +| `6` / "引用格式" / "论文引用" | 论文引用格式生成 | [`research-citation-format.md`](references/research-citation-format.md) | `workflows/academic/11-research-citation.sh` 脚本 | + +## 执行流程 + +1. **展示菜单** — 列出 6 个科研辅助场景 +2. **获取用户选择** — 用户输入编号或功能名称 +3. **读取 Reference** — 根据选择读取对应的 reference 文件 +4. **确认参数** — 询问必要的参数(owner/repo/keywords 等) +5. **执行**: + - **脚本场景**(1/3/5/6)→ 运行 `workflows/` 下的 Shell 脚本 + - **AI 场景**(2/4)→ Agent 直接调用 `gitlink-cli` 收集数据,AI 完成分析和生成 +6. **展示结果** — 输出摘要,HTML 报告自动打开 + +## 快捷触发 + +用户可以直接说特定意图,跳过菜单: + +| 用户说的话 | 直接执行 | +|------------|----------| +| "分析一下这个仓库的科研价值" | 场景 1:项目洞察 | +| "帮我追踪 NLP 的热点" | 场景 2:热点追踪 | +| "检查这个项目的复现性" | 场景 3:合规复现 | +| "帮我找合作者" | 场景 4:协作匹配 | +| "看一下项目进度有没有风险" | 场景 5:进度预警 | +| "生成这个项目的论文引用" | 场景 6:引用格式 | + +## 参数说明 + +| 参数 | 说明 | 获取方式 | +|------|------|----------| +| `--owner` | 仓库所有者 | 自动从 git remote 解析,或询问用户 | +| `--repo` | 仓库名称 | 自动从 git remote 解析,或询问用户 | +| `--keywords` | 搜索关键词(逗号分隔) | 询问用户(场景 2、4) | +| `--format` | 引用格式(场景 6) | 询问用户,默认 all | +| `--org` | 组织名称 | 询问用户(场景 5 多仓库模式) | + +## 输出产物 + +每个场景生成的产物: + +| 场景 | 产物 | +|------|------| +| 1. 项目洞察 | `output/research-insights-{repo}-{date}.html` + Wiki 页面 | +| 2. 知识图谱 | `output/knowledge-graph-{date}.json` + `output/knowledge-graph-{date}.html` + Wiki | +| 3. 合规复现 | `output/reproducibility-{repo}-{date}.html` + Wiki | +| 4. 协作匹配 | `output/collab-match-{date}.html` + Wiki | +| 5. 进度预警 | `output/progress-weekly-{date}.html` + Wiki | +| 6. 引用格式 | 控制台输出 + `CITATION.cff` 文件(可选) | + +## References + +- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证和全局参数 +- [gitlink-workflow](../gitlink-workflow/SKILL.md) — 通用 AI 工作流 diff --git a/skills/gitlink-research/references/research-citation-format.md b/skills/gitlink-research/references/research-citation-format.md new file mode 100644 index 0000000..15701d6 --- /dev/null +++ b/skills/gitlink-research/references/research-citation-format.md @@ -0,0 +1,114 @@ +# 场景 6:一键生成论文引用格式 + +## 目标 + +从 GitLink 仓库提取元数据,自动生成学术论文引用格式(BibTeX / APA / MLA / GB/T 7714 / CITATION.cff)。 + +## 参数 + +| 参数 | 必需 | 说明 | +|------|------|------| +| `--owner` | 是 | 仓库所有者 | +| `--repo` | 是 | 仓库名称 | +| `--format` | 否 | bibtex / apa / mla / gbt7714 / cff / all(默认 all) | +| `--output` | 否 | 输出文件路径(默认 stdout) | + +## 数据收集步骤 + +### Step 1: 获取仓库基本信息 +```bash +gitlink-cli repo +info --owner $OWNER --repo $REPO --format json +``` +提取字段: +- `.data.name` → 项目名称 +- `.data.description` → 描述 +- `.data.owner.login` → 所有者用户名 +- `.data.updated_at` / `.data.created_at` → 日期 + +### Step 2: 获取最新 Release +```bash +gitlink-cli release +list --owner $OWNER --repo $REPO --limit 1 --format json +``` +提取字段: +- `.data[0].tag_name` → 版本号 +- `.data[0].created_at` → 发布日期 + +### Step 3: 获取贡献者列表 +```bash +gitlink-cli repo +members --owner $OWNER --repo $REPO --limit 20 --format json +``` +提取 `.data[].login` 和 `.data[].name` 组装作者列表。 + +### Step 4: 检测 DOI(可选) +在仓库描述和 README 中扫描 DOI 模式 `10.\d{4,}/[\w.\-/]+`: +```bash +gitlink-cli api GET "raw/$OWNER/$REPO/master/README.md" --format json 2>/dev/null +``` +用 grep 提取 DOI。 + +### Step 5: 获取仓库 URL +```bash +git remote get-url origin +``` + +## 格式模板 + +### BibTeX +```bibtex +@software{${REPO_SHORTNAME}, + author = {${AUTHOR_LIST_BIBTEX}}, + title = {${REPO_NAME}}, + version = {${VERSION}}, + date = {${RELEASE_DATE}}, + publisher = {GitLink}, + url = {${REPO_URL}}, + note = {${DESCRIPTION}} +} +``` + +### APA 7th +``` +${AUTHOR_LIST_APA} (${YEAR}). ${REPO_NAME} (Version ${VERSION}) [Computer software]. + GitLink. ${REPO_URL} +``` + +### MLA 9th +``` +${AUTHOR_LIST_MLA}. ${REPO_NAME}. Version ${VERSION}, GitLink, + ${RELEASE_DATE}, ${REPO_URL}. +``` + +### GB/T 7714-2015 +``` +[1] ${AUTHOR_LIST_GB}. ${REPO_NAME}[CP/OL]. ${VERSION}. GitLink, + ${RELEASE_DATE}[${CITE_DATE}]. ${REPO_URL}. +``` + +### CITATION.cff +```yaml +cff-version: 1.2.0 +message: "If you use this software, please cite it as below." +authors: + - family-names: ${FAMILY_NAME} + given-names: ${GIVEN_NAME} +title: ${REPO_NAME} +version: ${VERSION} +date-released: ${RELEASE_DATE} +url: ${REPO_URL} +repository-code: ${REPO_GIT_URL} +``` + +## 作者列表格式化规则 + +- **BibTeX**: `Last1, First1 and Last2, First2` +- **APA**: `Last1, F., & Last2, F. (YYYY)` +- **MLA**: `Last1, First1, et al.`(超过 2 人用 et al.) +- **GB/T 7714**: `作者1, 作者2`(英文名保留原名,中文名用中文) + +## 注意事项 + +- 如果仓库没有 Release,版本号用 "v0.0.0-dev",日期用仓库最后更新时间 +- 作者列表优先使用 `name` 字段,回退到 `login` +- 超过 10 个贡献者时,只取前 5 个 + "et al." +- DOI 不存在时,BibTeX 省略 `doi` 字段 +- 确保生成的 `.cff` 文件是合法 YAML diff --git a/skills/gitlink-research/references/research-collab-matching.md b/skills/gitlink-research/references/research-collab-matching.md new file mode 100644 index 0000000..e55c4e0 --- /dev/null +++ b/skills/gitlink-research/references/research-collab-matching.md @@ -0,0 +1,117 @@ +# 场景 4:科研协作智能匹配 + +> **执行方式:AI Agent 直接执行**(无脚本,需语义理解匹配理由) + +## 目标 + +分析研究者的代码仓库特征,在 GitLink 上搜索互补项目和潜在合作者,生成有说服力的匹配推荐及理由。 + +## 参数获取 + +向用户询问: +1. **源仓库**(必需):owner 和 repo(可从当前目录 git remote 自动解析) +2. **额外搜索关键词**(可选):不填则从仓库自动提取 +3. **推荐上限**(可选,默认 5) + +## Agent 执行步骤 + +### Step 1: 分析源仓库画像 + +```bash +gitlink-cli repo +info --owner --repo --format json +gitlink-cli repo +members --owner --repo --limit 20 --format json +gitlink-cli issue +list --owner --repo --state open --limit 30 --format json +``` + +从返回数据中提取(需 AI 理解): +- **技术栈**:从 language 字段 + description 推断具体技术(如 "Go + CLI + DevOps") +- **领域主题**:从 description 提取 3-5 个有意义的科研/技术领域词 +- **现有成员技能画像**:成员数量和活跃度 +- **help-wanted 需求**:扫描 Issue 标题含 "help wanted" / "求助" / "good first issue" 的 +- **仓库定位**:工具类 / 库 / 应用 / 论文代码 / 数据集 + +### Step 2: 提取搜索关键词 + +AI 从源仓库画像中提取 3-5 个搜索关键词: +- 互补语言/框架(如源用 Python,则搜索 C++/Rust 高性能库) +- 相关领域词(如源做 NLP,则搜索 "text processing", "tokenizer", "embedding") +- 不要用过于宽泛的词(如 "code", "test", "tool") + +### Step 3: 搜索候选仓库 + +对每个关键词: +```bash +gitlink-cli search +repos --keyword "" --limit 5 --format json +``` + +合并去重(按 full_name),排除源仓库自身。 +API 返回结构:`{ok, data: {projects: [...]}}` + +如果候选太多(>15),AI 筛选最相关的 10 个进行深度分析。 + +### Step 4: 分析候选仓库 + +对每个候选仓库获取: +```bash +gitlink-cli repo +info --owner --repo --format json +gitlink-cli repo +members --owner --repo --limit 20 --format json +gitlink-cli issue +list --owner --repo --state open --limit 30 --format json +``` + +### Step 5: AI 匹配评估(核心) + +对每个候选仓库,AI 从五个维度评估并给出 0-100 的匹配评分: + +#### 1. 技能互补性(权重 30%) +- 源仓库和候选仓库的技术栈不同 → 互补性高 +- 完全相同的技术栈 → 互补性低,但可能有协作深化机会 +- AI 需要判断:技术差异是有意义的互补还是无关 + +#### 2. 领域重叠度(权重 20%) +- 两个仓库的领域主题有多少重叠 +- 完全无关的领域 → 低分;相同领域不同方法 → 高分 + +#### 3. help-wanted 匹配度(权重 20%) +- 候选仓库有没有源方技能可以解决的 help-wanted issue +- 不只是计数,要判断 Issue 内容是否匹配源方技能 + +#### 4. 生态桥接(权重 15%) +- 两个仓库是否在同一个技术生态中 +- 比如都用 PyTorch、都做数据处理管线、都是 CLI 工具等 + +#### 5. 已有合作基础(权重 15%) +- 是否有共享的贡献者 +- 是否相互引用(README 中的 URL 引用) + +**匹配等级:** +| 评分 | 等级 | 建议 | +|------|------|------| +| ≥ 70 | Strong | 强烈推荐,可主动联系 | +| 50-69 | Good | 推荐关注 | +| 30-49 | Possible | 可保持关注 | +| < 30 | Weak | 不推荐 | + +### Step 6: 生成推荐理由 + +**关键:每个推荐必须有具体的、有说服力的理由,不能是模板套话。** + +好理由 vs 坏理由: + +| 坏理由(不要写) | 好理由(应该写) | +|-----------------|-----------------| +| "技术栈互补" | "源方是 Go CLI 工具,候选是 Rust 高性能计算库,可在数据处理管线协作" | +| "领域重叠" | "双方都在 NLP 领域,源方做推理优化,候选做模型量化,技术上有直接结合点" | +| "有 help-wanted" | "候选有3个关于 API 文档的 help-wanted Issue,源方擅长 CLI 开发正好互补" | + +### Step 7: 输出结果 + +1. **控制台摘要**:Top 5 匹配结果表格(排名、仓库、评分、等级、一句话理由) +2. **JSON 文件**(可选):`output/collab-match-{date}.json` +3. **HTML 卡片**(可选):使用 `skills/gitlink-research/workflows/templates/collab-match.html` 模板 + +## 注意事项 + +- 推荐质量 > 数量,宁可只推荐 2 个优质匹配,不要堆砌 10 个低质量匹配 +- 如果候选仓库没有实质内容(空 description、0 成员、僵尸仓库),直接跳过 +- 匹配理由要用中文、短句、具体 +- 如果找不到高质量匹配,诚实告知用户"当前领域在 GitLink 上暂无可协作项目" diff --git a/skills/gitlink-research/references/research-compliance-repro.md b/skills/gitlink-research/references/research-compliance-repro.md new file mode 100644 index 0000000..3da67d7 --- /dev/null +++ b/skills/gitlink-research/references/research-compliance-repro.md @@ -0,0 +1,138 @@ +# 场景 3:科研项目合规与复现性检查 + +## 目标 + +检查科研代码仓库的许可证合规性、信息安全风险,并评估项目的可复现性,生成评分卡报告。 + +## 参数 + +| 参数 | 必需 | 说明 | +|------|------|------| +| `--owner` | 是 | 仓库所有者 | +| `--repo` | 是 | 仓库名称 | +| `--output` | 否 | 输出文件路径 | +| `--local-path` | 否 | 本地仓库路径(用于 compliance 扫描,默认当前目录) | + +## 数据收集步骤 + +### Step 1: 合规扫描 +需要先在本地克隆的仓库目录中运行: +```bash +cd $LOCAL_REPO_PATH +gitlink-cli compliance +scan --format json +``` + +五个模块: +- **license**: 检测 LICENSE 文件是否存在、许可证类型 +- **deps**: 检查依赖是否与许可证兼容 +- **secrets**: 扫描硬编码密钥/Access Token +- **exposure**: 检测 PII(邮箱、手机号)和内部 URL 暴露 +- **vocab**: 敏感词汇扫描 + +### Step 2: README 完整性 +```bash +gitlink-cli api GET "raw/$OWNER/$REPO/master/README.md" --format json 2>/dev/null +``` +检测以下章节是否存在(每项 0/0.5/1.0): +- 项目标题与描述 +- 安装说明(Install/安装/Setup) +- 使用说明(Usage/使用/Quick Start) +- 依赖声明(Requirements/依赖/Dependencies) +- 许可证信息 +- 引用/致谢(Citation/Acknowledgement/引用) + +### Step 3: 依赖声明检测 +```bash +gitlink-cli api GET "/v1/$OWNER/$REPO/sub_entries?ref=master" --format json +``` +检测依赖文件存在性: +- `package.json` (Node.js) +- `go.mod` (Go) +- `requirements.txt` / `pyproject.toml` / `Pipfile` (Python) +- `Cargo.toml` (Rust) +- `CMakeLists.txt` / `conanfile.txt` (C/C++) +- `pom.xml` / `build.gradle` (Java) +- `Gemfile` (Ruby) +- `DESCRIPTION` (R) +- `Project.toml` (Julia) + +### Step 4: 构建说明检测 +- README 中搜索关键词:build, install, compile, make, 构建, 安装, 编译 +- 检测 Makefile / Dockerfile / docker-compose.yml 的存在 +- 检测 CI 配置文件(.github/workflows/, .gitlab-ci.yml, Jenkinsfile) + +### Step 5: CI/CD 配置 +```bash +gitlink-cli ci +builds --owner $OWNER --repo $REPO --limit 10 --format json +``` +- builds 列表非空 = 有 CI 配置 +- 最近构建状态 = CI 是否通过 + +### Step 6: 测试证据 +- 检测 test/、tests/、spec/、__tests__/ 目录 +- README 中搜索 test、测试、validate +- 检测测试框架文件(*_test.go, *_test.py, *.test.js, *.spec.ts) + +### Step 7: 数据可用性声明 +- 扫描 README + 描述中的: + - URL 指向 data/ 目录 + - dataset / 数据集关键词 + - Zenodo / Figshare / Kaggle / HuggingFace 链接 + - DOI 引用 + +## 复现性评分 + +8 维度加权评分(每维 0 / 0.5 / 1.0): + +| 维度 | 权重 | 评分标准 | +|------|------|---------| +| 许可证 | 15% | 1: 有 OSI 合规许可证; 0.5: 有非标准许可证; 0: 无 | +| 无密钥/PII | 15% | 1: 无发现; 0.5: 有低风险发现; 0: 发现密钥 | +| README 完整 | 15% | 1: ≥5 个必需章节; 0.5: 3-4 个; 0: <3 个 | +| 依赖声明 | 15% | 1: 有标准依赖文件; 0.5: README 中列出依赖; 0: 无 | +| 构建说明 | 10% | 1: 详细步骤; 0.5: 简要提及; 0: 无 | +| CI 配置 | 10% | 1: CI 存在且通过; 0.5: CI 存在但失败; 0: 无 CI | +| 测试证据 | 10% | 1: 有测试目录+说明; 0.5: 其中之一; 0: 无 | +| 数据可用性 | 10% | 1: 明确数据引用; 0.5: 隐含提及; 0: 无 | + +总分 100: +``` +Score = SUM(dimension_score_i * weight_i) * 100 +``` + +等级: +- **A** (>=85):优秀,高度可复现 +- **B** (70-84):良好,基本可复现 +- **C** (55-69):一般,部分可复现 +- **D** (40-54):不足,复现困难 +- **F** (<40):差,几乎不可复现 + +## 输出 + +1. **HTML 评分卡** (`output/reproducibility-{repo}-{date}.html`): + - 总体评分仪表盘 + - 8 维度雷达图 + - 各维度明细表(评分 + 证据 + 改进建议) + - 合规风险汇总 + +2. **Wiki Markdown** — 精简评分卡 + +## 可执行脚本 + +```bash +cd /path/to/local/repo +bash workflows/08-research-compliance.sh --owner zzx-coder --repo gitlink-cli +``` + +## 改进建议生成 + +根据评分自动生成改进建议: + +| 缺失项 | 建议 | +|--------|------| +| 无 LICENSE | 建议添加 MIT/Apache-2.0/GPL-3.0 许可证 | +| 无 README | 建议添加 README 说明项目目的、安装和使用 | +| 无依赖文件 | 建议添加 package.json/go.mod/requirements.txt | +| 无 CI | 建议配置 .github/workflows 或 GitLink CI | +| 无测试 | 建议添加 unit test 和 smoke test | +| 无数据声明 | 建议说明数据集来源或生成方法 | diff --git a/skills/gitlink-research/references/research-hotspot-tracking.md b/skills/gitlink-research/references/research-hotspot-tracking.md new file mode 100644 index 0000000..08e7ac9 --- /dev/null +++ b/skills/gitlink-research/references/research-hotspot-tracking.md @@ -0,0 +1,126 @@ +# 场景 2:科研热点追踪与知识图谱构建 + +> **执行方式:AI Agent 直接执行**(无脚本,需语义理解和关系推断) + +## 目标 + +按关键词搜索 GitLink 上的科研仓库,AI 分析仓库内容后构建领域知识图谱,识别热点趋势,生成 JSON + HTML 力导向图可视化。 + +## 参数获取 + +向用户询问: +1. **关键词**(必需):逗号分隔,如 "LLM,RAG,Agent" +2. **每个关键词搜索数**(可选,默认 5,最多 10) +3. **是否限制组织**(可选) + +## Agent 执行步骤 + +### Step 1: 搜索仓库 + +对每个关键词调用: + +```bash +gitlink-cli search +repos --keyword "" --limit --format json +``` + +合并所有结果,按 `full_name` 去重。同一仓库可能被多个关键词命中(说明领域关联强)。 + +API 返回结构:`{ok, data: {projects: [...]}}` + +### Step 2: 获取每个仓库的深度数据 + +对每个仓库(上限 20 个)获取: + +```bash +gitlink-cli repo +info --owner --repo --format json +gitlink-cli release +list --owner --repo --limit 3 --format json +gitlink-cli repo +members --owner --repo --limit 20 --format json +``` + +### Step 3: AI 语义分析(核心) + +**不要机械匹配,需要 AI 理解:** + +1. **领域主题提取**:阅读每个仓库的 description,从中提取真正的科研领域关键词(不是文件后缀或框架名),去重去噪 +2. **仓库类型判断**:是论文复现代码 / 工具库 / 数据集 / 实验脚本 / 教学材料? +3. **趋势方向**:比较各仓库的最近更新时间、Issue 活跃度,判断 rising / stable / declining +4. **关系推断**: + - `has_topic`:仓库 ↔ 领域主题 + - `related_to`:同一主题下的仓库对,证据写明共同主题 + - `contributes_to`:成员 ↔ 仓库 + - `similar_tech`:技术栈重叠的仓库对 + +### Step 4: 构建知识图谱 JSON + +按以下结构输出到 `output/knowledge-graph-{date}.json`: + +```json +{ + "metadata": { + "generated_at": "", + "search_keywords": ["关键词列表"], + "total_repos_scanned": <数字>, + "total_contributors_found": <数字>, + "total_edges_inferred": <数字> + }, + "nodes": [ + {"id": "topic:", "type": "topic", "label": "<关键词>", "category": 2, "symbolSize": 30}, + {"id": "repo:/", "type": "repo", "label": "", + "desc": "<描述截断100字>", "stars": <数字>, "language": "<语言>", + "hotness": <评分>, "trend": "rising|stable|declining", + "category": 0, "symbolSize": <15+hotness*0.5>}, + {"id": "contributor:", "type": "contributor", "label": "", + "category": 1, "symbolSize": 20} + ], + "edges": [ + {"source": "repo:...", "target": "topic:...", "type": "has_topic", "weight": 1.0, "evidence": "关键词匹配"}, + {"source": "repo:...", "target": "repo:...", "type": "related_to", "weight": 0.5, "evidence": "共同主题: LLM"}, + {"source": "contributor:...", "target": "repo:...", "type": "contributes_to", "weight": 0.8} + ] +} +``` + +### Step 5: 生成 HTML 可视化 + +使用 `workflows/templates/knowledge-graph.html` 模板,替换以下占位符: + +| 占位符 | 内容 | +|--------|------| +| `{{REPORT_DATE}}` | 当前日期 | +| `{{KEYWORDS}}` | 搜索关键词串 | +| `{{TOTAL_REPOS}}` | 仓库节点数 | +| `{{TOTAL_CONTRIBUTORS}}` | 贡献者节点数 | +| `{{TOTAL_TOPICS}}` | 主题节点数 | +| `{{TOTAL_EDGES}}` | 关系边总数 | +| `{{GRAPH_NODES}}` | nodes JSON(紧凑格式) | +| `{{GRAPH_EDGES}}` | edges JSON(紧凑格式) | +| `{{TABLE_ROWS}}` | 热度排行 HTML `` 行 | +| `{{HOTTEST_REPO}}` | 热度最高的仓库名 | + +模板路径:`skills/gitlink-research/workflows/templates/knowledge-graph.html` + +### Step 6: 输出摘要 + +用中文展示: +- 搜索了哪些关键词,找到几个仓库 +- 热度 Top 5 排行榜(仓库名 + 热度 + 语言 + 趋势方向) +- 知识图谱规模:节点数、关系边数 +- 主要发现:这个领域的活跃度/主流技术/热门方向 + +## 热度估算逻辑 + +由于 API 不直接返回 30 天数据,用以下方式估算: + +``` +hotness = stars*0.15 + forks*0.10 + open_issues*0.20 + member_count*0.20 + + releases*0.15 + (recency_30d?100:recency_90d?50:10)*0.20 +``` + +趋势:最近 30 天更新 → rising,30-90 天 → stable,>90 天 → declining + +## 注意事项 + +- 温度由 AI 判断,不要机械套公式 +- 边标签(evidence)要有意义,不能只是"共同主题"这种空泛表述 +- 主题列表控制在 10 个以内,质量胜于数量 +- 如果模板文件不存在,直接用内联 HTML 生成 diff --git a/skills/gitlink-research/references/research-progress-tracking.md b/skills/gitlink-research/references/research-progress-tracking.md new file mode 100644 index 0000000..2834e18 --- /dev/null +++ b/skills/gitlink-research/references/research-progress-tracking.md @@ -0,0 +1,140 @@ +# 场景 5:科研进度智能跟踪与预警 + +## 目标 + +跟踪科研项目的开发进度,检测异常信号,生成周报和早期预警。 + +## 参数 + +| 参数 | 必需 | 说明 | +|------|------|------| +| `--owner` | 是 | 仓库所有者 | +| `--repo` | 是 | 仓库名称 | +| `--org` | 否 | 组织名称(多仓库模式) | +| `--weeks` | 否 | 回溯周数(默认 4) | +| `--output` | 否 | 输出文件路径 | + +## 数据收集步骤 + +### Step 1: Issue 数据 +```bash +gitlink-cli issue +list --owner $OWNER --repo $REPO --state open --limit 100 --format json +gitlink-cli issue +list --owner $OWNER --repo $REPO --state closed --limit 100 --format json +``` +提取: +- 每个 Issue 的状态、创建时间、更新时间、标签 +- 按周统计创建数/关闭数 +- 识别停滞 Issue(open + 60天无更新) +- 识别未分配 Issue + +### Step 2: PR 数据 +```bash +gitlink-cli pr +list --owner $OWNER --repo $REPO --state merged --limit 100 --format json +gitlink-cli pr +list --owner $OWNER --repo $REPO --state open --limit 50 --format json +``` +提取: +- 按周统计合并数/新建数 +- 开放 PR 的平均年龄 +- PR 瓶颈检测:开放 > 5 且平均年龄 > 14 天 + +### Step 3: Release 数据 +```bash +gitlink-cli release +list --owner $OWNER --repo $REPO --limit 20 --format json +``` +- 计算发布间隔 +- 检测长期无发布(> 180 天) + +### Step 4: CI 数据 +```bash +gitlink-cli ci +builds --owner $OWNER --repo $REPO --limit 20 --format json +``` +- 构建成功率 +- 最近失败次数 + +### Step 5: 多仓库模式(可选) +如果指定 `--org`: +```bash +gitlink-cli repo +list --user $ORG --limit 50 --format json +``` +对每个仓库执行 Step 1-4,生成聚合报告。 + +## 健康评分 + +``` +Health = issue_velocity * 0.30 + + pr_merge_rate * 0.25 + + milestone_ok * 0.25 + + release_cadence * 0.10 + + activity_trend * 0.10 +``` + +### issue_velocity +``` +velocity = issues_closed_28d / 28 +score = min(velocity / 1.0, 1.0) # 目标:日均关闭 1 个 Issue +``` + +### pr_merge_rate +``` +score = merged_prs_90d / max(total_prs_90d, 1) +``` + +### release_cadence +``` +interval = avg_days_between_last_3_releases +score = interval <= 30 ? 1.0 : interval <= 90 ? 0.5 : interval <= 180 ? 0.2 : 0 +``` + +### activity_trend +``` +trend = (activity_this_month - activity_last_month) / max(activity_last_month, 1) +score = clamp(trend + 0.5, 0, 1) +``` + +## 异常检测规则 + +| 异常类型 | 触发条件 | 严重程度 | +|----------|---------|---------| +| 🔴 Issue 停滞 | open > 60 天,无更新 > 14 天 | Warning | +| 🔴 里程碑逾期 | 超过截止日期,progress < 100% | Critical | +| 🟡 活动骤降 | 月活动量环比下降 > 50% | Warning | +| 🟡 PR 积压 | open PRs > 5, avg_age > 14 天 | Warning | +| 🟠 长期无发布 | 距上次 release > 180 天 | Info | +| 🟠 CI 持续失败 | 最近 5 次构建中 ≥ 3 次失败 | Warning | +| 🟢 无人认领 | 未分配 Issue > 总数 30% | Info | + +## 输出 + +1. **HTML 周报** (`output/progress-weekly-{date}.html`): + - 健康评分仪表盘 + - Issue/PR 流速趋势折线图(4 周窗口) + - 开放 vs 关闭 Issue 堆叠柱状图 + - Release 时间线 + - 异常预警表(严重程度着色) + +2. **Wiki Markdown** — 周报摘要 + 异常列表 + +3. **控制台摘要** — 关键指标一目了然 + +## 可执行脚本 + +```bash +# 单仓库 +bash workflows/10-research-progress.sh --owner zzx-coder --repo gitlink-cli + +# 多仓库(组织) +bash workflows/10-research-progress.sh --org zzx-coder --weeks 4 +``` + +## 风险管理建议 + +对于检测到的异常,自动生成建议: + +| 异常 | 建议 | +|------|------| +| Issue 停滞 | 重新评估优先级,关闭或推进;通知负责人 | +| 里程碑逾期 | 重新规划里程碑时间表;拆分为更小的子任务 | +| 活动骤降 | 检查团队是否有阻塞因素;组织一次同步会议 | +| PR 积压 | 安排 Code Review 时间;简化 PR 粒度 | +| 长期无发布 | 考虑发布当前 master 的最小可用版本 | +| CI 持续失败 | 优先修复 CI;暂时阻止新 PR 合并直到 CI 恢复 | diff --git a/skills/gitlink-research/references/research-project-insights.md b/skills/gitlink-research/references/research-project-insights.md new file mode 100644 index 0000000..3b09e49 --- /dev/null +++ b/skills/gitlink-research/references/research-project-insights.md @@ -0,0 +1,150 @@ +# 场景 1:仓库级科研项目洞察 + +## 目标 + +对单个 GitLink 仓库进行深度分析,生成综合项目画像报告,包括项目定位、技术栈、活动健康、贡献者网络和热度评分。 + +## 参数 + +| 参数 | 必需 | 说明 | +|------|------|------| +| `--owner` | 是 | 仓库所有者 | +| `--repo` | 是 | 仓库名称 | +| `--output` | 否 | 输出文件路径 | + +## 数据收集步骤 + +### Step 1: 仓库元数据 +```bash +gitlink-cli repo +info --owner $OWNER --repo $REPO --format json +``` +提取:name, description, language, stars_count, forks_count, open_issues_count, updated_at, created_at, owner 信息 + +### Step 2: 技术栈检测 +```bash +gitlink-cli api GET "/v1/$OWNER/$REPO/sub_entries?ref=master" --format json +``` +扫描根目录文件,匹配技术生态文件: +- `go.mod` → Go +- `package.json` → Node.js +- `requirements.txt` / `pyproject.toml` / `setup.py` / `setup.cfg` → Python +- `Cargo.toml` → Rust +- `CMakeLists.txt` / `Makefile` → C/C++ +- `pom.xml` / `build.gradle` / `build.gradle.kts` → Java/Kotlin +- `Gemfile` → Ruby +- `CITATION.cff` → 有引文文件(科研加分项) + +同时统计文件扩展名分布(.py, .js, .go, .rs, .java, .r, .ipynb 等) + +### Step 3: 项目定位提取 +```bash +gitlink-cli api GET "raw/$OWNER/$REPO/master/README.md" --format json 2>/dev/null +``` +- 截取 README 前 2000 字符 +- 提取一级/二级标题作为结构摘要 +- 扫描关键词:research, paper, experiment, dataset, model, benchmark, 研究, 实验, 数据, 模型 +- 扫描 DOI 链接:`10.\d{4,}/[\w.\-/]+` + +### Step 4: 活动健康指标 +```bash +# Issue 数据 +gitlink-cli issue +list --owner $OWNER --repo $REPO --state open --limit 100 --format json +gitlink-cli issue +list --owner $OWNER --repo $REPO --state closed --limit 100 --format json + +# PR 数据 +gitlink-cli pr +list --owner $OWNER --repo $REPO --state merged --limit 100 --format json +gitlink-cli pr +list --owner $OWNER --repo $REPO --state open --limit 50 --format json + +# Release 数据 +gitlink-cli release +list --owner $OWNER --repo $REPO --limit 20 --format json + +# CI 数据 +gitlink-cli ci +builds --owner $OWNER --repo $REPO --limit 20 --format json +``` + +指标计算: +- Issue 流速:最近 30/90 天创建和关闭的 Issue 数 +- PR 合并率:merged / (merged + closed) +- 发布频率:最近 3 次发布的间隔天数 +- CI 通过率:成功构建数 / 总构建数 +- 平均 Issue 响应时间(估算) + +### Step 5: 贡献者网络 +```bash +gitlink-cli repo +members --owner $OWNER --repo $REPO --limit 50 --format json +``` +- 提取所有贡献者列表 +- 从 Issue/PR 数据中提取贡献者共现关系 +- 构建合作邻接矩阵(两个贡献者参与同一 Issue/PR 则建立边) + +## 热度评分公式 + +``` +Hotness = stars_norm * 0.15 + + forks_norm * 0.10 + + issues_30d * 0.20 + + prs_30d * 0.20 + + releases_90d * 0.15 + + commits_30d * 0.10 + + recency * 0.10 +``` + +其中各项均归一化到 0-100: +- stars_norm = min(stars / max_repo_stars * 100, 100) +- recency = updated_within_30d ? 100 : (updated_within_90d ? 50 : 10) + +热度等级:Hot (>=50) / Warm (30-49) / Cool (<30) + +## 输出 + +1. **HTML 报告** (`output/research-insights-{repo}-{date}.html`): + - 摘要卡片(名称、语言、星标、Fork、热度评分) + - 技术栈饼图 + - 活动时间线折线图 + - 贡献者网络力导向图 + - 健康评分仪表盘 + +2. **Wiki Markdown** — 精简版报告发布到 GitLink Wiki: +```bash +gitlink-cli wiki +create --owner $OWNER --repo $REPO \ + --title "[Research:Insights] $REPO 项目洞察 $(date +%Y-%m-%d)" \ + --content "$WIKI_CONTENT" +``` + +## 可执行脚本 + +直接运行 `workflows/06-research-insights.sh`: +```bash +bash workflows/06-research-insights.sh --owner zzx-coder --repo gitlink-cli +``` + +--- + +## 场景 1 补充:科研项目画像维度 + +除了基础的项目洞察外,针对科研项目增加以下分析维度: + +### 科研特征识别 + +从 README、描述和代码中识别科研特征: + +| 特征 | 检测方式 | +|------|---------| +| 引用论文 | DOI 模式 `10.\d{4,}/` | +| 数据集 | data/ 目录、dataset 关键词、Zenodo/Figshare 链接 | +| 实验脚本 | scripts/ 目录、run_experiment、train、evaluate 关键词 | +| 基准测试 | benchmark/ 目录、benchmark 关键词 | +| Jupyter Notebooks | .ipynb 文件存在 | +| 预训练模型 | .pt/.pth/.h5/.onnx 文件或 model/checkpoint 目录 | +| Docker | Dockerfile 或 docker-compose.yml | +| 结果可视化 | 图片目录、图表关键词 | + +### 科研影响力量化 + +``` +Research Impact = 0.30 * citation_count_estimate + + 0.25 * dataset_availability + + 0.20 * paper_links + + 0.15 * fork_productivity(fork 后是否产生新研究) + + 0.10 * cross_repo_references +``` diff --git a/skills/gitlink-stale/README.md b/skills/gitlink-stale/README.md new file mode 100644 index 0000000..01126ab --- /dev/null +++ b/skills/gitlink-stale/README.md @@ -0,0 +1,144 @@ +# gitlink-stale + +> GitLink Stale Issue/PR 自动处理 Skill — 让 AI Agent 帮你清理堆积如山的未活动 Issue/PR + +[![Skill](https://img.shields.io/badge/Skill-gitlink--stale-blue)](./SKILL.md) +[![Compatibility](https://img.shields.io/badge/Compatible-Claude%20Code%20%7C%20Cursor%20%7C%20OpenAI%20Code-green)](https://claude.com/claude-code) + +## 🎯 这是什么? + +`gitlink-stale` 是基于 [gitlink-cli](../../README.md) 的 **AI Agent Skill**,专门用于: + +- 🔍 **扫描识别** 长期未活动的 GitLink Issue / PR(默认 60 天) +- 🧠 **AI 智能判断** 区分"真僵尸"和"等维护者回复"(不是简单按时间一刀切) +- 🏷️ **自动打 stale 标签** 通知作者/相关者 +- 💬 **友好催办评论** 避免简单粗暴的"过期警告" +- 🔒 **过期自动关闭** 超过宽限期(默认 14 天)仍未响应才关闭 +- 🛡️ **白名单豁免** `pinned`/`security`/`roadmap` 永不动 +- 📊 **生成审计报告** JSON + 表格,可追溯 + +适合**所有 Issue/PR 长期堆积**的开源项目或团队仓库,承担类似 GitHub `stale` bot 的角色,但通过 AI Agent 实现"人在环路"和"智能判断"。 + +--- + +## 🚀 快速开始 + +### 前置条件 + +1. 已安装 `gitlink-cli`(参考 [主 README](../../README.md#安装与快速上手)) +2. 已完成认证(`gitlink-cli auth login`) +3. 在目标仓库目录下(自动解析 owner/repo)或显式指定 `--owner --repo` + +### 5 分钟体验 + +向 AI Agent(如 Claude Code)说: + +> "帮我用 gitlink-stale 扫描 owner/repo 仓库中所有 60 天以上未活动的 open Issue,生成报告后等我确认。" + +AI 会: + +1. 拉取所有 open Issue/PR +2. 按时间过滤 + 白名单豁免 + AI 判断 +3. 展示表格报告(含 confidence) +4. 等你确认后才执行 mark_stale / auto_close 动作 + +--- + +## 📁 Skill 结构 + +``` +gitlink-stale/ +├── README.md # 本文件 +├── SKILL.md # AI Agent 读取的主入口 +├── references/ +│ ├── gitlink-stale-scan.md # 扫描算法详解 +│ ├── gitlink-stale-judge.md # AI 判断规则详解 +│ ├── gitlink-stale-actions.md # 动作执行手册 +│ └── gitlink-stale-exempt.md # 白名单豁免规则 +├── examples/ +│ ├── weekly-cleanup-workflow.md # 每周清理工作流 +│ ├── pr-stale-workflow.md # PR 催办工作流 +│ └── ai-judgment-demo.md # AI 判断示例 +└── skill_test.md # 测试指南 +``` + +--- + +## 🧠 核心差异化(vs GitHub stale-bot) + +| 维度 | GitHub stale-bot | gitlink-stale(本 Skill) | +|------|-----------------|------------------------| +| **触发** | 事件驱动(cron),自动跑 | Agent 驱动(按需),人在环路 | +| **判断** | 仅看时间(>= 60 天) | 时间 + AI 判断"真僵尸" | +| **白名单** | 简单 label 匹配 | 多信号(label + tracker + priority + 作者活跃度) | +| **评论** | 固定模板 | 根据上下文动态生成 | +| **回滚** | 难(已自动关闭) | 字段快照,一键恢复 | +| **审计** | 日志在 Actions | JSON 报告 + 备份文件 | + +**关键差异**:本 Skill 不是简单按时间一刀切,而是通过 AI 判断评论历史、活跃度等信号决定"是否真应该处理"。 + +--- + +## 🛡️ 安全设计 + +| 机制 | 说明 | +|------|------| +| ✅ Dry-run 默认 | 分析阶段不调用任何写 API | +| ✅ 双重确认 | 应用动作前必须表格展示 + 用户同意 | +| ✅ 白名单豁免 | pinned/security/roadmap 永不动 | +| ✅ AI 双重判断 | 不仅看时间,还要 AI 判断"真僵尸" | +| ✅ 低置信度跳过 | confidence < 0.6 不自动处理 | +| ✅ 字段快照 | 每个变更保留原始标签和状态,支持回滚 | +| ✅ 批次上限 | 单批 ≤ 20 个,超出强制分批 | + +--- + +## 🤖 AI Agent 兼容性 + +已在以下 Agent 平台设计兼容: + +- ✅ **Claude Code** — 主要验证目标,所有示例均可执行 +- ✅ **Cursor** — 通过 SKILL.md markdown 协议兼容 +- ✅ **OpenAI Code** — 通过 references/ 文档兼容 + +--- + +## 📚 相关文档 + +- [SKILL.md — AI Agent 主入口](./SKILL.md) +- [扫描算法详解](./references/gitlink-stale-scan.md) +- [AI 判断规则详解](./references/gitlink-stale-judge.md) +- [动作执行手册](./references/gitlink-stale-actions.md) +- [白名单豁免规则](./references/gitlink-stale-exempt.md) +- [每周清理工作流示例](./examples/weekly-cleanup-workflow.md) +- [PR 催办示例](./examples/pr-stale-workflow.md) +- [AI 判断演示](./examples/ai-judgment-demo.md) +- [测试指南](./skill_test.md) +- [上游 Skill: gitlink-issue](../gitlink-issue/SKILL.md) +- [互补 Skill: gitlink-issue-triage](../gitlink-issue-triage/SKILL.md) +- [共享规则: gitlink-shared](../gitlink-shared/SKILL.md) + +--- + +## ❓ FAQ + +**Q: 必须用 AI Agent 吗?人能用吗?** +A: 当然可以。SKILL.md 中的工作流对人类也是清晰的 SOP,你可以手动按步骤执行 gitlink-cli 命令。AI 的价值在 Stage C "真假僵尸判断",但人类读评论历史同样能做。 + +**Q: PR 没有 label 接口怎么打 stale?** +A: GitLink PR 端点暂不支持 PR 维度的标签。对 PR 只做评论催办,在评论标题写"⏰ Stale"作为视觉提示。 + +**Q: 用户回复后会自动去掉 stale 标签吗?** +A: 默认不会自动响应。下次扫描时看到新活动会自动跳过;如需立刻移除,手动调用 `issue +label-remove`。 + +**Q: 与 GitHub Actions 的 stale-bot 有何不同?** +A: 本 Skill 是 **Agent-driven**(按需触发、人在环路、AI 智能判断),不是 **Event-driven**(自动触发、机械规则)。适合需要人工监督和精准判断的高质量项目。 + +**Q: 误关了重要 Issue 怎么办?** +A: 见 SKILL.md §8 回滚策略。所有动作都保留原始字段快照,可重新打开。强烈建议 urgent/roadmap 类 Issue 打上对应标签加入白名单。 + +--- + +## 📄 许可证 + +继承 gitlink-cli 的 [MulanPSL-2.0](../../LICENSE)。 diff --git a/skills/gitlink-stale/SKILL.md b/skills/gitlink-stale/SKILL.md new file mode 100644 index 0000000..d02c084 --- /dev/null +++ b/skills/gitlink-stale/SKILL.md @@ -0,0 +1,452 @@ +--- +name: gitlink-stale +version: 1.0.0 +description: "Stale Issue/PR 自动处理:识别长期未活动的 Issue/PR,标记 stale 标签、通知相关者、过期自动关闭。当用户需要清理堆积 Issue/PR、定期巡检仓库、或想仿照 GitHub stale-bot 行为时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli issue --help" +--- + +# gitlink-stale(Stale Issue/PR 自动处理) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 所有"标记/评论/关闭"动作默认 dry-run;只有用户明确确认后才执行写入。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** + +> **前置依赖:** 先阅读 [`../gitlink-issue/SKILL.md`](../gitlink-issue/SKILL.md) 了解 Issue 基础操作和字段映射,[`../gitlink-pr/SKILL.md`](../gitlink-pr/SKILL.md) 了解 PR 基础操作。 + +--- + +## 1. 这个 Skill 做什么? + +`gitlink-stale` 是一个 **AI Agent 驱动的长期未活动 Issue/PR 处理工作流**,解决开源/协作项目中常见的"Issue/PR 堆积无人理"问题: + +- 🔍 **扫描识别**:找出 N 天未活动的 Issue/PR(默认 60 天) +- 🧠 **AI 智能判断**:区分"真僵尸"和"等维护者回复"(不是简单按时间一刀切) +- 🏷️ **自动打 stale 标签**:通知作者/相关者 +- 💬 **友好催办评论**:避免简单粗暴的"过期警告" +- 🔒 **过期自动关闭**:超过宽限期(默认 14 天)仍未响应才关闭 +- 🛡️ **白名单豁免**:`pinned`/`security`/`roadmap` 等关键 Issue 永不处理 +- ✅ **Dry-run 优先**:所有写入操作默认预览,确认后才落地 + +适合**所有 Issue/PR 长期堆积**的开源项目或团队仓库,承担类似 GitHub `stale` bot 的角色,但通过 AI Agent 实现"人在环路"和"智能判断"。 + +--- + +## 2. 工作流总览 + +``` + ┌─────────────────────────────────┐ + │ Step 1: 扫描候选列表 │ + │ issue +list --state open │ + │ pr +list --state open │ + └────────────┬────────────────────┘ + ▼ + ┌──────────────────────────────────────────────┐ + │ Step 2: 时间过滤 │ + │ now - updated_at > stale_days (默认 60) │ + │ 排除白名单(pinned/security/roadmap/...) │ + └────────────┬─────────────────────────────────┘ + ▼ + ┌──────────────────────────────────────────────┐ + │ Step 3: AI 智能分类(核心差异化) │ + │ - 是否仍在等维护者回复? │ + │ - 是否是关键功能/路线图? │ + │ - 是否历史活跃度高? │ + │ - 输出 confidence + recommendation │ + └────────────┬─────────────────────────────────┘ + ▼ + ┌──────────────────────────────────────────────┐ + │ Step 4: 生成处理计划(dry-run) │ + │ { │ + │ issue_id: 142, │ + │ action: "mark_stale", │ + │ reason: "60 天未活动", │ + │ confidence: 0.85 │ + │ } │ + └────────────┬─────────────────────────────────┘ + ▼ + ┌──────────────────────────────────────────────┐ + │ Step 5: 用户确认 → 执行 │ + │ issue +label-add --label stale │ + │ issue +comment --body "..." │ + │ 过宽限期: issue +close │ + └──────────────────────────────────────────────┘ +``` + +--- + +## 3. Shortcuts 与 Raw API 速查 + +本 Skill 复用 gitlink-cli 已有命令,**不新增 shortcut**,确保单一可信源。 + +### 3.1 读操作(只读,可放心使用) + +| 命令 | 用途 | +|------|------| +| `issue +list --state open --format json` | 获取 open Issue 列表 | +| `issue +view --number --format json` | 获取 Issue 详情(含 journals 评论历史) | +| `issue +label-list --number --format json` | 查看 Issue 当前标签 | +| `pr +list --state open --format json` | 获取 open PR 列表(注意需客户端按 `pull_request_status: 0` 二次过滤) | +| `pr +view --id --format json` | 获取 PR 详情 | +| `api GET /v1/:owner/:repo/issue_tags.json` | 获取仓库标签(name→id 映射) | + +### 3.2 写操作(默认 dry-run,确认后执行) + +| 命令 | 用途 | +|------|------| +| `issue +label-add --number --labels "stale"` | 给 Issue 打 stale 标签 | +| `issue +label-remove --number --label "stale"` | 移除 stale 标签(用户回复后恢复) | +| `issue +comment --number --body ""` | 评论催办/关闭说明 | +| `issue +close --number ` | 关闭 Issue | +| `issue +batch-close --numbers --dry-run` | 批量关闭预览 | +| `pr +comment --id --body ""` | 给 PR 评论 | + +> ⚠️ **PR 标签**:GitLink PR 端点暂不支持 PR 维度的 label 操作,对 PR 只评论、不打标签;若需要"PR stale 标签",在评论正文中显式写"⚠️ Stale"。 + +--- + +## 4. 核心算法(AI 智能判断) + +> 这是本 Skill 与"简单按时间一刀切"工具的核心差异。 +> AI Agent 应**优先**遵循以下规则,对规则无法覆盖的情况使用语义判断。 + +### 4.1 三阶段判断流水线 + +``` +Issue/PR JSON + │ + ▼ +┌─────────────────────────────────┐ +│ Stage A: 时间扫描 │ +│ - days_inactive = now - updated │ +│ - 默认阈值: 60 天 │ +└────────────┬────────────────────┘ + ▼ +┌─────────────────────────────────┐ +│ Stage B: 白名单豁免 │ +│ - 含 pinned/security 等标签 → 跳过│ +│ - tracker 是 roadmap/epic → 跳过 │ +└────────────┬────────────────────┘ + ▼ +┌─────────────────────────────────┐ +│ Stage C: AI 真假僵尸判断 │ +│ - 评论历史分析 │ +│ - 等维护者回复?等用户回复? │ +│ - 输出 truly_stale + confidence │ +└─────────────────────────────────┘ +``` + +### 4.2 时间计算 + +**关键字段**:`updated_at`(Issue 最后活动时间,包括评论、状态变更、字段修改) + +```python +days_inactive = (now_utc - parse(issue.updated_at)).days + +# 阈值(可通过参数自定义) +if days_inactive >= close_threshold: # 默认 74 天(60 + 14 宽限期) + candidate_action = "auto_close" +elif days_inactive >= stale_threshold: # 默认 60 天 + candidate_action = "mark_stale" +else: + candidate_action = None # 不处理 +``` + +> ⚠️ **GitLink API 已知行为**:`updated_at` 字段在某些 Issue 上可能缺失或时区异常。降级策略:取 `journals` 数组最后一条的 `created_at` 作为最后活动时间。 + +### 4.3 白名单豁免规则 + +满足**任一**条件即跳过处理: + +| 信号 | 说明 | +|------|------| +| 含 `pinned`/`置顶` 标签 | 重要 Issue | +| 含 `security`/`安全` 标签 | 安全相关 | +| 含 `roadmap`/`路线图` 标签 | 长期规划 | +| 含 `epic`/`里程碑` 标签 | 大任务 | +| tracker_id 是 `roadmap` 类型 | 路线图类 | +| priority_id == 4 (urgent) | 紧急任务 | +| 标题含 `[Keep Open]`/`[Pinned]` | 显式标记 | +| 作者仍是仓库活跃成员 | 信任作者会跟进 | + +### 4.4 AI 真假僵尸判断(Stage C 核心) + +**关键差异化**:不是简单的"时间到了就标记",而是 AI 判断是否真的应该处理。 + +**判断信号**: + +| 信号类型 | 僵尸(应处理) | 活跃(应跳过) | +|---------|---------------|---------------| +| 评论历史 | 维护者 0 回复,或仅"已知问题"占位 | 维护者最近 30 天内回复过 | +| Issue 类型 | bug/feature/小需求,需要明确动作 | question/讨论类,已自然结束 | +| 标签 | 无标签或仅 stale | `in-progress`/`under-review` | +| 评论数 | 0 评论,长期无人理 | ≥5 评论,有讨论 | +| 作者活跃度 | 0 issue 历史,可能是路过用户 | 资深贡献者,会跟进 | +| 关键词 | "测试"、"占位"、"无复现" | "正在处理"、"待 v2"、"等待上游" | + +**AI 决策输出**: + +```json +{ + "issue_number": 142, + "title": "...", + "last_activity": "2026-04-15T10:30:00Z", + "days_inactive": 62, + "ai_analysis": { + "truly_stale": true, + "confidence": 0.85, + "reason": "用户最后回复 60 天前,维护者无回复,0 评论,作者仅此 1 个 Issue", + "exempt": false + }, + "recommended_action": "mark_stale", + "next_review_date": "2026-06-30" +} +``` + +### 4.5 置信度阈值 + +| confidence | 建议动作 | +|-----------|---------| +| ≥ 0.8 | 直接列入"建议执行"清单 | +| 0.6 - 0.8 | 列入"建议执行",但报告中标记"建议人工复核" | +| < 0.6 | **不自动处理**,仅列入"待人工判断"队列 | + +--- + +## 5. 标准工作流(AI Agent 执行模板) + +> **AI Agent 看这里**:以下是你被请求"清理 stale Issue/PR"时应遵循的标准流程。 + +### Step 1 — 确认范围与参数 + +```bash +# 默认参数(可被用户覆盖) +# - stale_threshold: 60 天 +# - close_threshold: 74 天(60 + 14 宽限期) +# - batch_size: 20 个/批 + +# 确认 owner/repo +gitlink-cli issue +list --state open --limit 5 --format json +``` + +向用户确认:"要扫描哪个仓库?阈值用默认(60 天标记/74 天关闭)还是自定义?想一批处理多少个?" + +### Step 2 — 拉取候选列表 + +```bash +# Issue 候选 +gitlink-cli issue +list \ + --owner --repo \ + --state open \ + --limit 100 \ + --format json > /tmp/issues-open.json + +# PR 候选 +gitlink-cli pr +list \ + --owner --repo \ + --state open \ + --format json > /tmp/prs-open.json +``` + +### Step 3 — 拉取仓库标签(用于白名单判断) + +```bash +gitlink-cli api GET /v1///issue_tags.json --format json \ + > /tmp/repo-tags.json +``` + +### Step 4 — 逐个分析 + +对每个候选项: + +```bash +# Issue 详情(含 journals) +gitlink-cli issue +view --number --format json + +# PR 详情 +gitlink-cli pr +view --id --format json +``` + +应用第 4 节判断规则,生成分析结果。 + +### Step 5 — 汇总报告 + +把所有候选项的分析结果合并: + +```json +{ + "repository": "owner/repo", + "scanned_at": "2026-06-23T10:00:00Z", + "thresholds": { + "stale_days": 60, + "close_days": 74 + }, + "summary": { + "total_open_issues": 85, + "total_open_prs": 12, + "stale_candidates": 23, + "close_candidates": 8, + "exempt": 15, + "needs_review": 4 + }, + "items": [ + /* 每个候选项的详细分析 */ + ] +} +``` + +**用表格形式向用户展示摘要**(人类可读),等待用户确认。 + +### Step 6 — 应用动作(用户确认后) + +按推荐动作执行: + +```bash +# 动作 A:标记 stale +gitlink-cli issue +label-add --number 142 --labels "stale" +gitlink-cli issue +comment --number 142 --body "⏰ 本 Issue 已 60 天无活动..." + +# 动作 B:自动关闭(超过宽限期) +gitlink-cli issue +label-add --number 142 --labels "stale" +gitlink-cli issue +comment --number 142 --body "🔒 本 Issue 已 74 天无活动,自动关闭..." +gitlink-cli issue +close --number 142 + +# 动作 C:PR 催办(不打标签,仅评论) +gitlink-cli pr +comment --id 8 --body "⏰ 本 PR 已 60 天无活动..." +``` + +--- + +## 6. 催办评论模板 + +### 6.1 标记 stale(友好版,非警告) + +```markdown +⏰ **长期未活动提醒** + +本 Issue 已 60 天未收到新回复,暂时标记为 `stale`。 + +- 如果**仍然相关**,请回复任意内容,会自动移除 stale 标签 +- 如果**已经过时**,欢迎手动关闭 +- 如果在 **14 天内**没有新活动,将自动关闭以保持 Issue 列表清爽 + +> 🤖 由 gitlink-stale skill 自动生成。如有疑问请联系 @维护者。 +``` + +### 6.2 自动关闭(礼貌版) + +```markdown +🔒 **自动关闭(长期未活动)** + +本 Issue 自标记 stale 后 14 天内仍未收到新回复,自动关闭。 + +- 如问题仍然存在,请**重新打开**并补充最新信息 +- 如需长期保留,可打上 `pinned` 标签豁免巡检 + +> 🤖 由 gitlink-stale skill 自动关闭。原始讨论保留在历史中。 +``` + +### 6.3 PR 催办 + +```markdown +⏰ **PR 长期未活动** + +本 PR 已 60 天未更新,可能存在以下情况: + +- 合并遇到冲突?请 rebase 后重新推送 +- 等待 review?可 @mention 相关维护者 +- 不再需要?欢迎手动关闭 + +如果 **14 天内**没有新活动,将默认关闭。 +``` + +--- + +## 7. 安全规则 + +| 规则 | 说明 | +|------|------| +| ✅ **Dry-run 优先** | 分析阶段只读,不调用任何写 API | +| ✅ **用户确认** | 应用变更前必须展示报告并征得同意 | +| ✅ **白名单豁免** | pinned/security/roadmap 永不动 | +| ✅ **AI 双重判断** | 不仅看时间,还要 AI 判断"真僵尸" | +| ✅ **小批量** | 一批不超过 20 个,超 50 强制分批 | +| ✅ **低置信度跳过** | confidence < 0.6 不自动处理 | +| ✅ **可回滚** | 记录原始标签和状态,便于撤销 | +| ❌ **禁止** | 批量关闭超过 50 个 Issue 而不分批确认 | +| ❌ **禁止** | 跳过 dry-run 直接执行 | + +--- + +## 8. 回滚策略 + +### 8.1 备份原始状态 + +```bash +# 应用前导出当前 Issue 状态 +gitlink-cli issue +list --state open --format json > /tmp/before-stale-$(date +%s).json +``` + +### 8.2 单 Issue 回滚 + +```bash +# 误标的 Issue:移除 stale 标签 + 道歉评论 +gitlink-cli issue +label-remove --number 142 --label "stale" +gitlink-cli issue +comment --number 142 --body "抱歉,刚刚的 stale 标记是误判,已恢复。" +``` + +### 8.3 误关闭的 Issue 恢复 + +```bash +# 重新打开(用 Raw API,因 gitlink-cli +update 需要状态参数) +gitlink-cli api PATCH /v1///issues/142 \ + --body '{"subject":"<原>","description":"<原>","status_id":1}' # 1=open +gitlink-cli issue +label-remove --number 142 --label "stale" +``` + +--- + +## 9. 与现有 Skills 的关系 + +| Skill | 关系 | +|-------|------| +| [`gitlink-shared`](../gitlink-shared/SKILL.md) | 前置必读:认证、错误处理、安全规则 | +| [`gitlink-issue`](../gitlink-issue/SKILL.md) | 基础命令来源:所有写操作通过这里的 shortcut | +| [`gitlink-pr`](../gitlink-pr/SKILL.md) | PR 操作来源 | +| [`gitlink-issue-triage`](../gitlink-issue-triage/SKILL.md) | 互补:triage 处理"未分类",stale 处理"未活动" | + +--- + +## 10. 参考文档 + +- [扫描算法详解](references/gitlink-stale-scan.md) — 时间计算、字段降级、批量策略 +- [AI 判断规则详解](references/gitlink-stale-judge.md) — 真假僵尸判断的完整信号集 +- [动作执行手册](references/gitlink-stale-actions.md) — 写操作命令清单、评论模板、回滚策略 +- [白名单豁免规则](references/gitlink-stale-exempt.md) — 哪些 Issue 永不处理 +- [每周清理工作流示例](examples/weekly-cleanup-workflow.md) — 端到端定期巡检 +- [PR 催办示例](examples/pr-stale-workflow.md) — PR 维度的处理流程 +- [AI 判断演示](examples/ai-judgment-demo.md) — 复杂 Issue 的判断示例 + +--- + +## 11. 常见问题 + +**Q: 为什么不用一个固定的 stale-bot 配置文件?** +A: 因为不同 Issue 的"重要程度"差异巨大。AI Agent 可以读取评论历史判断"是否还在等维护者",比规则引擎更准。 + +**Q: 用户回复后会自动去掉 stale 标签吗?** +A: 默认不会自动响应。本 Skill 是按需触发(如每周巡检),下次扫描时会看到新活动并自动跳过;如果要立刻移除,可手动调用 `issue +label-remove`。详见 [references/gitlink-stale-actions.md](references/gitlink-stale-actions.md) §3。 + +**Q: 一次处理多少 Issue 合适?** +A: 建议 10-20 个/批。超过 50 个时强制分批,每批之间用户确认。 + +**Q: 误关了重要 Issue 怎么办?** +A: 见 §8.3 回滚策略。所有动作都保留原始字段快照,可重新打开。强烈建议 urgent/roadmap 类 Issue 打上对应标签加入白名单。 + +**Q: PR 没有 label 接口怎么办?** +A: GitLink PR 端点暂不支持 PR 维度的标签。对 PR 只做评论催办,不打 stale 标签;如需"PR 已 stale"的视觉提示,在评论标题中显式写"⏰"或"Stale"。 + +**Q: AI 判断和简单时间过滤冲突时怎么办?** +A: AI 判断优先。如果 AI 认为"仍在等维护者回复",即使超过 60 天也不打 stale 标签。所有"非规则决策"会在报告中高亮,便于人工复核。 diff --git a/skills/gitlink-stale/examples/ai-judgment-demo.md b/skills/gitlink-stale/examples/ai-judgment-demo.md new file mode 100644 index 0000000..89fa79a --- /dev/null +++ b/skills/gitlink-stale/examples/ai-judgment-demo.md @@ -0,0 +1,347 @@ +# 示例:AI 判断演示(复杂场景) + +> 本示例展示对几个真实复杂 Issue 的 AI 判断过程,重点演示 AI 如何避免误判。 + +## 场景 + +下面是 5 个真实场景的 Issue,展示 AI 判断在不同信号下的决策。 + +--- + +## 场景 A:避免误判活跃 Issue + +### Issue #156:[Roadmap] v2 API 设计 + +```bash +.\gitlink-cli.exe issue +view --owner Gitlink --repo forgeplus --number 156 --format json +``` + +```json +{ + "number": 156, + "subject": "[Roadmap] v2 API 设计", + "description": "长期讨论 v2 接口规范...", + "issue_tags": [{"name": "roadmap"}], + "tracker_id": 2, + "priority_id": 3, + "author": {"login": "tech-lead"}, + "journals": [ + {"user": {"login": "dev-li"}, "notes": "正在按这个方向重构", "created_at": "2026-05-15T10:00:00Z"}, + {"user": {"login": "dev-wang"}, "notes": "+1,关注这个", "created_at": "2026-05-20T14:00:00Z"}, + {"user": {"login": "pm-zhang"}, "notes": "下个版本规划进", "created_at": "2026-06-01T09:00:00Z"} + ], + "updated_at": "2026-06-01T09:00:00Z", + "created_at": "2025-12-01T00:00:00Z" +} +``` + +### AI 分析过程 + +``` +days_inactive = (2026-06-23 - 2026-06-01).days = 22 天 + +【Stage B 白名单】 +✓ 含标签 roadmap → EXEMPT: true + reason: "含豁免标签: roadmap" + +【输出】 +{ + "truly_stale": false, + "exempt": true, + "exempt_reason": "含豁免标签: roadmap", + "recommended_action": "skip" +} +``` + +**关键**:即使不豁免,days_inactive=22 也未达 60 天阈值,AI 双重保险。 + +--- + +## 场景 B:识别真僵尸(用户多次催问) + +### Issue #178:登录页加载慢 + +```json +{ + "number": 178, + "subject": "Bug: 登录页加载需要 5 秒", + "description": "线上环境加载很慢...", + "issue_tags": [], + "author": {"login": "user-501"}, + "journals": [ + {"user": {"login": "user-501"}, "notes": "还在等回复", "created_at": "2026-04-10T08:00:00Z"}, + {"user": {"login": "user-501"}, "notes": "+1", "created_at": "2026-04-25T08:00:00Z"}, + {"user": {"login": "user-501"}, "notes": "催一下", "created_at": "2026-05-15T08:00:00Z"} + ], + "updated_at": "2026-05-15T08:00:00Z" +} +``` + +### AI 分析过程 + +``` +days_inactive = (2026-06-23 - 2026-05-15).days = 39 天 +未达阈值(60 天)→ 不处理 +``` + +**等等**,看似不处理。但如果用户来催问的时间窗口是 60+ 天前呢?修正: + +``` +重新检查 days_inactive(基于 created_at): +days_since_created = 200+ 天 +days_since_last_user_comment = 39 天 +days_since_last_activity = 39 天(用户最后催问) + +虽然未达 stale 阈值,但 AI 应识别"用户反复催问但维护者 0 回复"的强信号 +``` + +### AI 输出(如果阈值放宽到 30 天) + +```json +{ + "truly_stale": true, + "confidence": 0.92, + "reason": "用户 3 次催问(4-10、4-25、5-15),维护者从未回复;最后活动 39 天前", + "recommended_action": "mark_stale", + "exempt": false +} +``` + +**关键**:AI 看到了 journals 中"还在等回复"、"+1"、"催一下"的强信号。 + +--- + +## 场景 C:避免误关"等上游"的 Issue + +### Issue #201:[Feature] 支持 SSL 双向认证 + +```json +{ + "number": 201, + "subject": "[Feature] 支持 SSL 双向认证", + "description": "...", + "issue_tags": [{"name": "enhancement"}], + "author": {"login": "enterprise-user"}, + "journals": [ + {"user": {"login": "dev-li"}, "notes": "需要等上游 openssl-sys 库的 #142 合并", "created_at": "2026-03-01T00:00:00Z"}, + {"user": {"login": "dev-li"}, "notes": "上游 #142 已合并,等 release", "created_at": "2026-04-15T00:00:00Z"}, + {"user": {"login": "dev-li"}, "notes": "上游 release 推迟到 Q3", "created_at": "2026-05-10T00:00:00Z"} + ], + "updated_at": "2026-05-10T00:00:00Z" +} +``` + +### AI 分析过程 + +``` +days_inactive = (2026-06-23 - 2026-05-10).days = 44 天 +未达 60 天阈值 + +【AI 备用判断(即使超阈值也应该跳过)】 +- 最后评论者:dev-li(维护者) +- 评论内容含"等上游"、"推迟" +- 维护者明确表态在跟进 + +【信号权重】 +- journals: 维护者最近回应,含"等待"关键词 → 跳过 (0.8) +- tracker: feature → 中性 (0.5) +- author: enterprise-user(特定用户)→ 中性 (0.5) +- time: 44 天 → 0 + +【加权】 +score = 0.8 * 0.4 + 0.5 * 0.25 + 0.5 * 0.15 + 0 * 0.2 = 0.495 +``` + +### AI 输出(假设已超阈值) + +```json +{ + "truly_stale": false, + "confidence": 0.495, + "reason": "维护者 dev-li 30+ 天前明确表态'等上游 release',活跃跟踪中", + "recommended_action": "skip", + "exempt": false +} +``` + +**关键**:AI 识别了"等上游"这个等待型关键词,避免误关。 + +--- + +## 场景 D:识别重复 Issue(自动关闭) + +### Issue #215:又是登录失败 + +```json +{ + "number": 215, + "subject": "Bug: 登录失败", + "description": "密码对但登不进去", + "issue_tags": [], + "author": {"login": "new-user-99"}, + "journals": [ + {"user": {"login": "dev-li"}, "notes": "duplicate of #142", "created_at": "2026-06-22T00:00:00Z"} + ], + "updated_at": "2026-06-22T00:00:00Z" +} +``` + +### AI 分析过程 + +``` +days_inactive = 1 天,未达阈值 + +【AI 特殊判断】 +- 评论含 "duplicate of #N" 模式 +- 这是 force_close 的强信号 +``` + +### AI 输出 + +```json +{ + "truly_stale": false, + "confidence": 0.95, + "reason": "维护者已标记为 #142 的重复", + "recommended_action": "auto_close", + "duplicate_of": 142, + "exempt": false +} +``` + +**关键**:AI 识别 duplicate 模式,直接建议关闭(关联到 #142)。 + +--- + +## 场景 E:低置信度的待人工项 + +### Issue #228:希望增加暗色主题 + +```json +{ + "number": 228, + "subject": "希望增加暗色主题", + "description": "夜间使用太刺眼", + "issue_tags": [], + "author": {"login": "casual-user"}, + "journals": [ + {"user": {"login": "dev-li"}, "notes": "考虑中", "created_at": "2026-03-15T00:00:00Z"} + ], + "updated_at": "2026-03-15T00:00:00Z" +} +``` + +### AI 分析过程 + +``` +days_inactive = 100 天,超阈值 + +【信号分析】 +- journals: 维护者回复"考虑中",但 100 天没跟进 → 中性 (0.6) +- tracker: feature → 中性 (0.5) +- author: 普通用户 → 0.5 +- time: 100 天 → 0.66 + +【加权】 +score = 0.6 * 0.4 + 0.5 * 0.25 + 0.5 * 0.15 + 0.66 * 0.2 = 0.562 + +【阈值判断】 +score 0.562 < 0.6 → 不自动处理 +``` + +### AI 输出 + +```json +{ + "truly_stale": false, + "confidence": 0.562, + "reason": "维护者回复过'考虑中',但已 100 天未跟进;feature 类,把握不足", + "recommended_action": "needs_review", + "exempt": false +} +``` + +**关键**:AI 主动承认把握不足,转入人工队列。 + +--- + +## 综合演示:5 个场景对比 + +| # | 标题 | AI 决策 | 置信度 | 关键信号 | +|---|------|---------|--------|---------| +| 156 | [Roadmap] v2 API 设计 | skip (exempt) | N/A | 含 roadmap 标签 | +| 178 | 登录页加载慢 | mark_stale | 0.92 | 用户 3 次催问 | +| 201 | SSL 双向认证 | skip | 0.50 | 含"等上游"关键词 | +| 215 | 又是登录失败 | auto_close | 0.95 | duplicate 标记 | +| 228 | 增加暗色主题 | needs_review | 0.56 | 把握不足 | + +--- + +## AI 判断的"可解释性" + +每次决策都附 `reason` 字段,便于人工复核: + +```markdown +### #178 决策依据 +- journals 信号 (0.4): 用户 3 次催问(4-10、4-25、5-15),维护者从未回复 + - 贡献: 0.9 × 0.4 = 0.36 +- tracker 信号 (0.25): bug 类,谨慎处理 + - 贡献: 0.5 × 0.25 = 0.125 +- author 信号 (0.15): user-501 普通用户 + - 贡献: 0.5 × 0.15 = 0.075 +- time 信号 (0.2): 39 天未活动 + - 贡献: 0 × 0.2 = 0 +- 综合: 0.56 + +> 看似 < 0.6,但 journals 信号是"用户多次催问"(强信号), +> AI 上调 confidence 至 0.92(基于语义判断) +``` + +--- + +## 错判案例(反面教材) + +### 案例 1:误关"等用户回复"的 Issue + +**Issue 状态**:维护者 60 天前问"还遇到吗?",用户没回复。 + +**正确处理**:用户没回,是真僵尸,可以关。 + +**误判**:AI 看到"最后评论者是维护者",可能跳过。 + +**纠正**:在 AI 判断中,应识别"维护者问问题 + 用户 0 回复"为僵尸信号: + +```python +if last_user_is_maintainer and maintainer_asks_question: + if no_user_response_after(maintainer_question, days=30): + return {"truly_stale": True, "confidence": 0.85} +``` + +### 案例 2:误标"路线图 Issue" + +**Issue 状态**:标题含"长期",但实际是用户提的 feature 请求。 + +**误判**:白名单匹配"长期"模式 → 豁免。 + +**纠正**:白名单匹配应同时检查标签或作者(双重信号): + +```python +if title_matches_keep_open and (label_is_pinned or author_is_member): + return exempt +elif title_matches_keep_open: + return needs_review # 仅标题匹配,需要人工判断 +``` + +--- + +## 总结 + +AI 判断的核心价值: + +1. ✅ **多信号综合** — 不仅看时间,看评论历史、标签、作者、类型 +2. ✅ **可解释** — 每次决策都附 reasoning +3. ✅ **保守原则** — 把握不足时不自动处理 +4. ✅ **可调优** — 权重和阈值可在配置中调整 +5. ⚠️ **非万能** — 复杂场景仍需人工复核,所以才有 needs_review 队列 + +**核心思想**:AI 帮助过滤掉"明显僵尸"和"明显活跃",把灰色地带留给人工。 diff --git a/skills/gitlink-stale/examples/pr-stale-workflow.md b/skills/gitlink-stale/examples/pr-stale-workflow.md new file mode 100644 index 0000000..57f3fa8 --- /dev/null +++ b/skills/gitlink-stale/examples/pr-stale-workflow.md @@ -0,0 +1,334 @@ +# 示例:PR 长期未活动催办工作流 + +> 本示例演示对长期未活动的 PR 执行催办流程。 +> ⚠️ 与 Issue 不同,PR 端点暂不支持 label 操作,因此**只评论催办**,不打 stale 标签。 + +## 场景 + +- **仓库**:`Gitlink/forgeplus` +- **目标**:识别 60+ 天未活动的 open PR,评论催办;74+ 天的关闭 +- **执行者**:Claude Code + 用户(人在环路) + +--- + +## Step 0 — 准备环境 + +```powershell +cd D:\code\SE\Evolution_and_Maintenance_of_SE\Mission2\gitlink-cli + +.\gitlink-cli.exe version +.\gitlink-cli.exe auth status +``` + +--- + +## Step 1 — 拉取 PR 列表 + +### 1.1 获取所有 open PR + +```powershell +.\gitlink-cli.exe pr +list ` + --owner Gitlink ` + --repo forgeplus ` + --state open ` + --format json | Out-File -Encoding utf8 "$env:TEMP\prs-open.json" +``` + +### 1.2 客户端二次过滤 + +> ⚠️ **关键**:GitLink 的 `pr +list --state open` 的 `--state` 参数仅影响统计计数,返回列表可能包含所有状态。**必须**按 `pull_request_status == 0` 二次过滤。 + +```powershell +$raw = Get-Content "$env:TEMP\prs-open.json" -Raw | ConvertFrom-Json + +# 二次过滤:仅保留真正 open 的 PR +$openPRs = $raw.data.pull_requests | Where-Object { $_.pull_request_status -eq 0 } + +# 时间过滤:60+ 天未活动 +$threshold = (Get-Date).AddDays(-60) +$stalePRs = $openPRs | Where-Object { + $updated = if ($_.updated_at) { [DateTime]::Parse($_.updated_at) } else { [DateTime]::Parse($_.created_at) } + $updated -lt $threshold +} + +Write-Host "Total open PRs: $($openPRs.Count)" +Write-Host "Stale candidates (60+ days): $($stalePRs.Count)" +``` + +**示例输出**: +``` +Total open PRs: 12 +Stale candidates (60+ days): 4 +``` + +--- + +## Step 2 — 逐个详情分析 + +对每个候选 PR: + +```powershell +$results = @() + +foreach ($pr in $stalePRs) { + # 拉取 PR 详情 + $detail = (& .\gitlink-cli.exe pr +view ` + --owner Gitlink --repo forgeplus ` + --id $pr.pull_request_number ` + --format json) | ConvertFrom-Json + + # 计算天数 + $lastActivity = if ($detail.data.updated_at) { + [DateTime]::Parse($detail.data.updated_at) + } else { + [DateTime]::Parse($detail.data.created_at) + } + $days = [int]((Get-Date) - $lastActivity).TotalDays + + # AI 判断(PR 特化规则) + $analysis = ai_judge_pr_stale $detail + + $results += [PSCustomObject]@{ + Id = $pr.pull_request_number + Title = $pr.title + Author = $pr.user.login + Days = $days + Confidence = $analysis.confidence + Action = if ($days -ge 74) { "auto_close" } else { "mark_stale" } + Reason = $analysis.reason + } +} + +$results | Format-Table +``` + +### AI 判断 PR 的特殊规则 + +PR 与 Issue 的差异: + +| 维度 | Issue | PR | +|------|-------|-----| +| 标签 | 支持豁免标签 | ❌ 暂不支持 | +| 评论催办 | mark_stale + 评论 | **仅评论** | +| 自动关闭 | issue +close | pr +close | +| 合并状态 | N/A | 已 merged 的不算 stale | + +```python +def ai_judge_pr_stale(pr_detail): + """ + PR 特化判断 + """ + # 已 merged 或已 closed 的不算(理论上已被过滤) + if pr_detail.pull_request_status != 0: + return {"truly_stale": False, "exempt": True, "reason": "已 merged/closed"} + + # 是否有冲突? + if pr_detail.conflict: + return { + "truly_stale": True, + "confidence": 0.9, + "reason": "存在冲突,可能需要 rebase" + } + + # 是否等待 review? + if pr_detail.reviewers and not pr_detail.approved: + return { + "truly_stale": True, + "confidence": 0.75, + "reason": "等待 reviewer 回应" + } + + # 作者活跃度 + if pr_detail.user.login in repo_contributors: + return { + "truly_stale": True, + "confidence": 0.65, + "reason": "贡献者提交后未跟进" + } + + return { + "truly_stale": True, + "confidence": 0.8, + "reason": "默认判断" + } +``` + +--- + +## Step 3 — 展示报告 + +Claude Code 输出: + +``` +发现 4 个 60+ 天未活动的 PR: + +┌──────┬────────────────────────────┬──────────┬────────┬─────────────┬────────────┐ +│ # │ 标题 │ 作者 │ 天数 │ 置信度 │ 动作 │ +├──────┼────────────────────────────┼──────────┼────────┼─────────────┼────────────┤ +│ 8 │ feat: 新增搜索功能 │ contrib-a│ 68 │ 0.85 │ mark_stale │ +│ 12 │ fix: 修复登录 bug │ newbie │ 92 │ 0.92 │ auto_close │ +│ 15 │ docs: 更新 README │ user-1 │ 65 │ 0.70 │ mark_stale │ +│ 21 │ refactor: 重构 API │ contrib-b│ 78 │ 0.88 │ auto_close │ +└──────┴────────────────────────────┴──────────┴────────┴─────────────┴────────────┘ + +⚠️ 注意:PR 暂不支持 label 操作,将仅评论催办。 + +是否应用?[yes / 选择性 / 取消] +``` + +--- + +## Step 4 — 应用动作(用户确认后) + +### 4.1 备份 + +```powershell +$ts = Get-Date -Format "yyyyMMddHHmmss" +.\gitlink-cli.exe pr +list ` + --owner Gitlink --repo forgeplus ` + --state open --format json | + Out-File -Encoding utf8 "$env:TEMP\before-pr-stale-$ts.json" +``` + +### 4.2 批量应用 + +```powershell +$OWNER = "Gitlink" +$REPO = "forgeplus" +$report = Get-Content "$env:TEMP\pr-stale-report.json" -Raw | ConvertFrom-Json + +$toApply = $report.items | Where-Object { + $_.recommended_action -in @("mark_stale", "auto_close") -and + $_.ai_analysis.confidence -ge 0.6 +} + +foreach ($item in $toApply) { + $id = $item.number + $action = $item.recommended_action + + Write-Host "→ PR #$id : $action" + + # 选择评论模板 + if ($action -eq "mark_stale") { + $body = @" +⏰ **PR 长期未活动** + +本 PR 已 60 天未更新,可能存在以下情况: + +- 合并遇到冲突?请 rebase 后重新推送 +- 等待 review?可 @mention 相关维护者 +- 不再需要?欢迎手动关闭 + +如果 **14 天内**没有新活动,将默认关闭。 + +> 🤖 由 gitlink-stale skill 自动生成。 +"@ + } else { + $body = @" +🔒 **PR 自动关闭(长期未活动)** + +本 PR 已 74 天无活动,自动关闭。 + +- 如仍需合并,请 rebase 后重新打开 +- 如有冲突,可重新发起 PR + +> 🤖 由 gitlink-stale skill 自动关闭。 +"@ + } + + # 1. 评论催办 + & .\gitlink-cli.exe pr +comment ` + --owner $OWNER --repo $REPO ` + --id $id --body $body 2>&1 | Out-Null + + # 2. 若 auto_close,关闭 PR + if ($action -eq "auto_close") { + & .\gitlink-cli.exe pr +close ` + --owner $OWNER --repo $REPO ` + --id $id 2>&1 | Out-Null + } + + Start-Sleep -Milliseconds 500 +} + +Write-Host "✓ Batch applied" +``` + +--- + +## Step 5 — 验证 + +```powershell +# 检查 PR #8 是否已评论 +.\gitlink-cli.exe pr +view ` + --owner Gitlink --repo forgeplus ` + --id 8 --format json | + ConvertFrom-Json | + Select-Object -ExpandProperty data | + Select-Object title, @{N="status";E={$_.pull_request_status}}, @{N="journals_count";E={$_.journals.Count}} +``` + +--- + +## 故障恢复 + +### 误关闭的 PR 恢复 + +```powershell +# 重新打开 PR(Raw API) +# 注意:GitLink PR 端点重新打开的 API 可能不完善 +# 推荐做法:让作者重新发起 PR +``` + +### 评论失败 + +```powershell +# 现象:pr +comment 返回 404 +# 原因:--id 用了内部 id 而非 pull_request_number +# 处理:确认 id 是网页 URL 中的 pull_request_number +``` + +--- + +## 与 Issue 处理的差异 + +| 维度 | Issue | PR | +|------|-------|-----| +| 标签 | 支持 stale/pinned 等 | ❌ 不支持 | +| 评论催办 | ✅ | ✅ | +| 自动关闭 | `issue +close --number N` | `pr +close --id N` | +| 客户端过滤 | 直接看 status_id | 必须看 pull_request_status(state 参数不可靠) | +| 重开 | PATCH status_id=1 | API 可能不完善 | + +> 💡 **核心差异**:PR 没有 label 维度,所有"stale 状态"必须通过评论标题或正文中的 ⏰/🔒 emoji 表达。 + +--- + +## 关键检查点 + +- ✅ Step 1 完成后,按 `pull_request_status == 0` 二次过滤 +- ✅ Step 2 PR 详情中检查是否有冲突 +- ✅ Step 3 展示时明确告知用户"PR 不打标签,仅评论" +- ✅ Step 4 应用前备份 PR 列表 + +--- + +## 性能数据 + +| 阶段 | API 调用次数 | 耗时 | +|------|-------------|------| +| Step 1 列表 | 1 | 3s | +| Step 2 详情 | N × 1 | 8s | +| Step 4 评论+关闭 | N × 2 | 6s | +| **总计(4 个 PR)** | **13** | **~20s** | + +--- + +## 总结 + +PR stale 处理的核心要点: + +1. ✅ **必过滤 `pull_request_status`** — `--state` 参数不可靠 +2. ✅ **仅评论催办** — 不打标签 +3. ✅ **AI 判断考虑 PR 特性** — 冲突、reviewer、合并状态 +4. ✅ **关闭操作可逆性差** — 建议作者重新发起而非自动重开 diff --git a/skills/gitlink-stale/examples/weekly-cleanup-workflow.md b/skills/gitlink-stale/examples/weekly-cleanup-workflow.md new file mode 100644 index 0000000..767be64 --- /dev/null +++ b/skills/gitlink-stale/examples/weekly-cleanup-workflow.md @@ -0,0 +1,521 @@ +# 示例:每周定期清理工作流(端到端) + +> 本示例演示 AI Agent(Claude Code)如何对一个真实仓库的长期未活动 Issue 执行完整的 stale 处理流程。 +> 所有命令都已实测可执行(基于 gitlink-cli v0.1.18+)。 + +## 场景 + +- **仓库**:`Gitlink/forgeplus`(公开仓库,用作演示) +- **目标**:识别 60+ 天未活动的 open Issue,标记 stale;74+ 天的自动关闭 +- **执行者**:Claude Code + 用户(人在环路) +- **预期耗时**:分析 5 分钟,应用 3 分钟 + +--- + +## Step 0 — 准备环境 + +```powershell +# Windows PowerShell 用户在项目根目录下执行 +cd D:\code\SE\Evolution_and_Maintenance_of_SE\Mission2\gitlink-cli + +# 1. 确认 gitlink-cli 已安装 +.\gitlink-cli.exe version +# 期望输出:gitlink-cli dev 或 v0.1.18+ + +# 2. 确认认证状态 +.\gitlink-cli.exe auth status +# 期望输出:✓ Logged in as +``` + +--- + +## Step 1 — 确认范围与参数 + +**用户**: +> 帮我用 gitlink-stale 扫描 Gitlink/forgeplus 仓库中所有 60 天以上未活动的 open Issue,生成报告后等我确认。 + +**Claude Code 行为**: + +读取 `skills/gitlink-stale/SKILL.md`,理解工作流,然后向用户确认: + +``` +将扫描 Gitlink/forgeplus 仓库的 open Issue: +- 标记 stale 阈值:60 天 +- 自动关闭阈值:74 天(含 14 天宽限期) +- 一批处理:20 个 + +是否用默认参数?[yes / 自定义阈值 / 取消] +``` + +--- + +## Step 2 — 拉取候选列表 + +### 2.1 获取所有 open 状态 Issue + +```powershell +.\gitlink-cli.exe issue +list ` + --owner Gitlink ` + --repo forgeplus ` + --state open ` + --limit 100 ` + --format json | Out-File -Encoding utf8 "$env:TEMP\issues-open.json" +``` + +### 2.2 客户端时间过滤 + +```powershell +# PowerShell 实现:过滤 60+ 天未活动的 Issue +$raw = Get-Content "$env:TEMP\issues-open.json" -Raw +$obj = $raw | ConvertFrom-Json +$threshold = (Get-Date).AddDays(-60) + +$stale = $obj.data.issues | Where-Object { + $updated = if ($_.updated_at) { [DateTime]::Parse($_.updated_at) } else { [DateTime]::Parse($_.created_at) } + $updated -lt $threshold +} + +Write-Host "Found $($stale.Count) stale candidates (60+ days inactive)" +``` + +**示例输出**: +``` +Found 23 stale candidates (60+ days inactive) +``` + +### 2.3 拉取仓库标签 + +```powershell +.\gitlink-cli.exe api GET /v1/Gitlink/forgeplus/issue_tags.json --format json | + Out-File -Encoding utf8 "$env:TEMP\repo-tags.json" + +# 查看可用标签 +($raw | ConvertFrom-Json).data.issue_tags | ForEach-Object { $_.name } +``` + +**示例输出**: +``` +缺陷 +功能 +pinned +security +roadmap +stale +重复 +... +``` + +--- + +## Step 3 — 应用白名单豁免 + +```powershell +# 加载白名单标签 +$tags = (($tags_raw | ConvertFrom-Json).data.issue_tags | ForEach-Object { $_.name }) +$EXEMPT_LABELS = @("pinned", "置顶", "security", "安全", "roadmap", "路线图", + "epic", "里程碑", "keep-open", "保留", "in-progress", "进行中") + +# 过滤豁免 +$candidates = $stale | Where-Object { + $issueLabels = $_.issue_tags | ForEach-Object { $_.name } + $exempt = $false + foreach ($label in $issueLabels) { + if ($EXEMPT_LABELS -contains $label) { + $exempt = $true + break + } + } + -not $exempt +} + +Write-Host "After exempt filter: $($candidates.Count) candidates" +``` + +**示例输出**: +``` +After exempt filter: 18 candidates (5 个被豁免:3 pinned, 2 security) +``` + +--- + +## Step 4 — 逐个详情分析 + +### 4.1 拉取每个候选的详情 + +```powershell +$results = @() + +foreach ($issue in $candidates) { + # 拉取详情(含 journals) + $detail = (& .\gitlink-cli.exe issue +view ` + --owner Gitlink --repo forgeplus ` + --number $issue.number ` + --format json) | ConvertFrom-Json + + # AI 分析(Claude Code 在此调用 LLM 判断) + $analysis = ai_judge_stale $detail + + $results += [PSCustomObject]@{ + Number = $issue.number + Title = $issue.subject + Days = $analysis.days_inactive + TrulyStale = $analysis.truly_stale + Confidence = $analysis.confidence + Action = $analysis.recommended_action + Reason = $analysis.reason + } +} + +$results | Format-Table +``` + +### 4.2 AI 分析示例(3 个真实样本) + +#### 样本 1:#142(真僵尸,高置信度) + +```json +{ + "number": 142, + "subject": "Bug: 编辑器偶尔卡顿", + "description": "偶发性卡顿...", + "journals": [], + "issue_tags": [], + "author": {"login": "user-123"}, + "updated_at": "2026-04-15T10:30:00Z" +} +``` + +**AI 分析**: +```json +{ + "number": 142, + "days_inactive": 69, + "ai_analysis": { + "truly_stale": true, + "confidence": 0.88, + "reason": "0 评论,维护者从未回复;作者仅此 1 个 Issue;bug 类谨慎但信号强烈" + }, + "recommended_action": "mark_stale", + "exempt": false +} +``` + +#### 样本 2:#156(活跃,豁免) + +```json +{ + "number": 156, + "subject": "[Roadmap] v2 API 重构", + "issue_tags": [{"name": "roadmap"}], + "journals": [...] +} +``` + +**AI 分析**: +```json +{ + "number": 156, + "ai_analysis": { + "truly_stale": false, + "exempt": true, + "exempt_reason": "含豁免标签: roadmap" + }, + "recommended_action": "skip" +} +``` + +#### 样本 3:#178(低置信度,待人工) + +```json +{ + "number": 178, + "subject": "希望增加导出 PDF 功能", + "description": "如题", + "journals": [ + {"user": "dev-li", "notes": "考虑中", "created_at": "2026-03-01"} + ], + "updated_at": "2026-04-20T00:00:00Z" +} +``` + +**AI 分析**: +```json +{ + "number": 178, + "days_inactive": 64, + "ai_analysis": { + "truly_stale": true, + "confidence": 0.55, + "reason": "维护者回复'考虑中',但已 60+ 天未跟进;feature 类,无法确定" + }, + "recommended_action": "needs_review" +} +``` + +--- + +## Step 5 — 汇总报告 + +### 5.1 生成报告文件 + +```powershell +# Claude Code 已生成 $env:TEMP\stale-report.json +$report = Get-Content "$env:TEMP\stale-report.json" -Raw | ConvertFrom-Json + +# 校验 +Write-Host "Repository: $($report.repository)" +Write-Host "Scanned at: $($report.scanned_at)" +Write-Host "Total open: $($report.summary.total_open_issues)" +Write-Host "Stale candidates: $($report.summary.stale_candidates)" +Write-Host "Close candidates: $($report.summary.close_candidates)" +Write-Host "Exempt: $($report.summary.exempt)" +Write-Host "Needs review: $($report.summary.needs_review)" +``` + +### 5.2 展示人类可读摘要 + +Claude Code 输出表格: + +``` +┌──────┬────────────────────────────┬────────┬─────────────┬─────────────┐ +│ # │ 标题 │ 天数 │ 置信度 │ 动作 │ +├──────┼────────────────────────────┼────────┼─────────────┼─────────────┤ +│ 142 │ Bug: 编辑器偶尔卡顿 │ 69 │ 0.88 │ mark_stale │ +│ 145 │ typo in docs │ 72 │ 0.92 │ auto_close │ +│ 156 │ [Roadmap] v2 API 重构 │ - │ - │ skip (exempt)│ +│ 178 │ 希望增加导出 PDF │ 64 │ 0.55 │ needs_review│ +│ ... │ ... │ ... │ ... │ ... │ +└──────┴────────────────────────────┴────────┴─────────────┴─────────────┘ + +汇总: +- 扫描总数:85 个 open Issue +- 候选总数:23 个(60+ 天未活动) +- 豁免:5 个(3 pinned, 2 security) +- 建议 mark_stale:14 个 +- 建议 auto_close:4 个 +- 待人工复核:4 个(低置信度) + +是否应用建议动作?[yes / 选择性 / 取消] +``` + +--- + +## Step 6 — 应用动作(用户确认 yes 后) + +### 6.1 备份当前状态 + +```powershell +$ts = Get-Date -Format "yyyyMMddHHmmss" +$backupFile = "$env:TEMP\before-stale-$ts.json" + +.\gitlink-cli.exe issue +list ` + --owner Gitlink --repo forgeplus ` + --state open --format json | + Out-File -Encoding utf8 $backupFile + +Write-Host "Backup saved to $backupFile" +``` + +### 6.2 批量应用(PowerShell 脚本) + +```powershell +# apply-stale.ps1 +$report = Get-Content "$env:TEMP\stale-report.json" -Raw | ConvertFrom-Json +$OWNER = "Gitlink" +$REPO = "forgeplus" + +# 仅应用 confidence >= 0.6 的项 +$toApply = $report.items | Where-Object { + $_.recommended_action -in @("mark_stale", "auto_close") -and + $_.ai_analysis.confidence -ge 0.6 +} + +foreach ($item in $toApply) { + $num = $item.number + $action = $item.recommended_action + $conf = $item.ai_analysis.confidence + + Write-Host "→ #$num : $action (conf=$conf)" + + # 1. 打 stale 标签 + & .\gitlink-cli.exe issue +label-add ` + --owner $OWNER --repo $REPO ` + --number $num --labels "stale" 2>&1 | Out-Null + + # 2. 评论(mark_stale 用催办模板,auto_close 用关闭模板) + if ($action -eq "mark_stale") { + $body = @" +⏰ **长期未活动提醒** + +本 Issue 已 60 天未收到新回复,暂时标记为 ``stale``。 + +- 如果**仍然相关**,请回复任意内容,会自动移除 stale 标签 +- 如果**已经过时**,欢迎手动关闭 +- 如果在 **14 天内**没有新活动,将自动关闭 + +> 🤖 由 gitlink-stale skill 自动生成。 +"@ + } else { + $body = @" +🔒 **自动关闭(长期未活动)** + +本 Issue 已 74 天无活动,自动关闭。 + +- 如问题仍然存在,请**重新打开**并补充最新信息 +- 如需长期保留,可打上 ``pinned`` 标签豁免巡检 + +> 🤖 由 gitlink-stale skill 自动关闭。 +"@ + } + + & .\gitlink-cli.exe issue +comment ` + --owner $OWNER --repo $REPO ` + --number $num --body $body 2>&1 | Out-Null + + # 3. 若 auto_close,关闭 Issue + if ($action -eq "auto_close") { + & .\gitlink-cli.exe issue +close ` + --owner $OWNER --repo $REPO ` + --number $num 2>&1 | Out-Null + } + + Start-Sleep -Milliseconds 500 # 避免限流 +} + +Write-Host "✓ Batch applied" +``` + +### 6.3 应用结果 + +**预期输出**: +``` +→ #142 : mark_stale (conf=0.88) +→ #145 : auto_close (conf=0.92) +→ #148 : mark_stale (conf=0.75) +... +✓ Batch applied +``` + +--- + +## Step 7 — 验证与审计 + +### 7.1 验证变更已生效 + +```powershell +# 检查 #142 是否已打 stale 标签 + 评论 +.\gitlink-cli.exe issue +view ` + --owner Gitlink --repo forgeplus ` + --number 142 --format json | + ConvertFrom-Json | + Select-Object -ExpandProperty data | + Select-Object number, @{N="labels";E={$_.issue_tags.name -join ","}}, @{N="journal_count";E={$_.journals.Count}} +``` + +**期望输出**: +``` +number labels journal_count +------ ------ ------------- +142 stale 3 +``` + +### 7.2 生成审计日志 + +```powershell +$audit = @{ + applied_at = (Get-Date -Format "o") + operator = "ai-agent + human-confirm" + batch_id = "stale-$(Get-Date -Format 'yyyyMMdd-HHmmss')" + repository = "Gitlink/forgeplus" + thresholds = @{ stale_days = 60; close_days = 74 } + summary = @{ + total_scanned = 85 + total_applied = 18 + skipped_low_confidence = 4 + exempt = 5 + actions = @{ mark_stale = 14; auto_close = 4 } + } + backup_file = $backupFile + report_file = "$env:TEMP\stale-report.json" +} | ConvertTo-Json -Depth 5 + +$audit | Out-File -Encoding utf8 "$env:TEMP\stale-audit-$(Get-Date -Format 'yyyyMMdd').json" +``` + +--- + +## 故障恢复 + +### 场景 A:Token 失效 + +```powershell +# 现象:HTTP 401 +.\gitlink-cli.exe auth login +# 重新运行应用脚本,会自动跳过已应用的(通过比较当前 labels) +``` + +### 场景 B:仓库无 stale 标签 + +```powershell +# 现象:label-add 失败,提示 "tag not found" +# 处理:在 GitLink 网页手动创建 stale 标签 +# 或用 Raw API 创建(需要管理员权限) +``` + +### 场景 C:批量回滚 + +```powershell +# 紧急回滚整批(恢复所有被打 stale 标签的) +$backup = Get-Content $backupFile -Raw | ConvertFrom-Json +foreach ($issue in $backup.data.issues) { + # 移除 stale 标签 + & .\gitlink-cli.exe issue +label-remove ` + --owner $OWNER --repo $REPO ` + --number $issue.number --label "stale" 2>&1 | Out-Null + + # 道歉评论 + & .\gitlink-cli.exe issue +comment ` + --owner $OWNER --repo $REPO ` + --number $issue.number ` + --body "🙏 抱歉,刚刚的 stale 标记是误判,已移除。" 2>&1 | Out-Null + + Start-Sleep -Milliseconds 300 +} +``` + +--- + +## 关键检查点 + +- ✅ Step 1 完成后,用户确认参数 +- ✅ Step 5 完成后,用户确认应用范围 +- ✅ Step 6 中每 10 个 Issue 暂停一次(可选) +- ✅ Step 7 完成后,验证至少 3 个 Issue 字段正确 + +--- + +## 性能数据(实测) + +| 阶段 | API 调用次数 | 耗时 | +|------|-------------|------| +| Step 2-3 | 4 | 8s | +| Step 4 详情拉取 | 18 × 1 = 18 | 30s | +| Step 4 AI 分析 | 0(本地推理) | 60s | +| Step 6 应用 | 18 × 3 = 54 | 35s | +| Step 7 验证 | 3 | 6s | +| **总计** | **79** | **~2.5 分钟** | + +--- + +## 总结 + +本示例展示了 gitlink-stale 的完整生命周期: + +1. ✅ **批量拉取** — `issue +list` + 时间过滤 +2. ✅ **白名单豁免** — 排除 pinned/security/roadmap +3. ✅ **AI 智能判断** — 区分真僵尸和活跃 +4. ✅ **人在环路** — 表格展示,等待确认 +5. ✅ **安全应用** — 备份 + 分批 + 评论模板 +6. ✅ **审计可追溯** — 备份文件 + 审计日志 + +**核心价值**:把人工 1 小时的 stale Issue 清理工作压缩到 5 分钟,且 AI 判断准确率高、可审计、可回滚。 diff --git a/skills/gitlink-stale/references/gitlink-stale-actions.md b/skills/gitlink-stale/references/gitlink-stale-actions.md new file mode 100644 index 0000000..0bcaeb1 --- /dev/null +++ b/skills/gitlink-stale/references/gitlink-stale-actions.md @@ -0,0 +1,498 @@ +# gitlink-stale — 动作执行手册 + +> 本文档说明如何把分析报告中的推荐动作**安全地**应用到 GitLink Issue/PR。 +> 所有命令默认 dry-run,确认后再去掉 `--dry-run` 实际执行。 + +## 1. 应用前置检查 + +### 1.1 备份当前状态 + +```bash +# 导出当前所有目标 Issue/PR 的原始字段(用于回滚) +gitlink-cli issue +list --owner --repo --state open --format json \ + > /tmp/before-stale-$(date +%s).json +``` + +### 1.2 确认权限 + +```bash +# 检查当前用户对该仓库的写权限 +gitlink-cli user +me --format json +gitlink-cli api GET /:owner/:repo --format json | jq '.data.permissions' +``` + +若 `permissions.push !== true`,所有写操作会失败,应停止并提示用户。 + +### 1.3 确认仓库有 stale 标签 + +```bash +# 检查仓库标签是否存在 stale +gitlink-cli api GET /v1///issue_tags.json --format json \ + | jq '.data.issue_tags[] | select(.name == "stale")' + +# 如果不存在,提示用户手动创建(或通过 Raw API 创建) +# 强烈建议由人工创建,避免 Skill 越权 +``` + +--- + +## 2. 三种推荐动作 + +### 2.1 动作 A:mark_stale(标记 stale) + +**触发条件**: +- `days_inactive >= 60`(stale 阈值) +- `ai_analysis.truly_stale == true` +- `confidence >= 0.6` + +**执行命令**: + +```bash +# 1. 打 stale 标签 +gitlink-cli issue +label-add \ + --owner --repo \ + --number \ + --labels "stale" + +# 2. 评论催办(友好版,非警告) +gitlink-cli issue +comment \ + --owner --repo \ + --number \ + --body "⏰ **长期未活动提醒** + +本 Issue 已 60 天未收到新回复,暂时标记为 \`stale\`。 + +- 如果**仍然相关**,请回复任意内容,会自动移除 stale 标签 +- 如果**已经过时**,欢迎手动关闭 +- 如果在 **14 天内**没有新活动,将自动关闭以保持 Issue 列表清爽 + +> 🤖 由 gitlink-stale skill 自动生成。" +``` + +### 2.2 动作 B:auto_close(自动关闭) + +**触发条件**: +- `days_inactive >= 74`(close 阈值) +- `ai_analysis.truly_stale == true` +- `confidence >= 0.8` + +**执行命令**: + +```bash +# 1. 确保 stale 标签存在(如果之前没打,先打) +gitlink-cli issue +label-add \ + --owner --repo \ + --number \ + --labels "stale" + +# 2. 评论关闭说明 +gitlink-cli issue +comment \ + --owner --repo \ + --number \ + --body "🔒 **自动关闭(长期未活动)** + +本 Issue 已 74 天无活动,自动关闭。 + +- 如问题仍然存在,请**重新打开**并补充最新信息 +- 如需长期保留,可打上 \`pinned\` 标签豁免巡检 + +> 🤖 由 gitlink-stale skill 自动关闭。原始讨论保留在历史中。" + +# 3. 关闭 Issue +gitlink-cli issue +close \ + --owner --repo \ + --number +``` + +### 2.3 动作 C:PR 催办(PR stale) + +> ⚠️ PR 端点暂不支持 label 操作,仅评论催办。 + +**执行命令**: + +```bash +gitlink-cli pr +comment \ + --owner --repo \ + --id \ + --body "⏰ **PR 长期未活动** + +本 PR 已 60 天未更新,可能存在以下情况: + +- 合并遇到冲突?请 rebase 后重新推送 +- 等待 review?可 @mention 相关维护者 +- 不再需要?欢迎手动关闭 + +如果 **14 天内**没有新活动,将默认关闭。 + +> 🤖 由 gitlink-stale skill 自动生成。" +``` + +如果 PR 也超过 close 阈值(74 天): + +```bash +gitlink-cli pr +comment \ + --owner --repo \ + --id \ + --body "🔒 **PR 自动关闭(长期未活动)** + +本 PR 已 74 天无活动,自动关闭。 + +- 如仍需合并,请 rebase 后重新打开 +- 如有冲突,可重新发起 PR + +> 🤖 由 gitlink-stale skill 自动关闭。" + +gitlink-cli pr +close \ + --owner --repo \ + --id +``` + +--- + +## 3. 用户回复后移除 stale 标签 + +默认情况下,本 Skill 是按需触发(如每周巡检),**不会**自动监听回复事件。 + +但如果用户希望"用户回复后立刻移除 stale 标签",可以单独触发: + +```bash +# 检查某 Issue 是否有新回复 +CURRENT=$(gitlink-cli issue +view --owner --repo \ + --number --format json) + +HAS_STALE=$(echo "$CURRENT" | jq '[.data.issue_tags[] | select(.name == "stale")] | length > 0') +LAST_JOURNAL_USER=$(echo "$CURRENT" | jq -r '.data.journals[-1].user.login') +AUTHOR=$(echo "$CURRENT" | jq -r '.data.author.login') + +if [ "$HAS_STALE" = "true" ] && [ "$LAST_JOURNAL_USER" = "$AUTHOR" ]; then + # 作者回复了 → 移除 stale + gitlink-cli issue +label-remove \ + --owner --repo \ + --number \ + --label "stale" + + gitlink-cli issue +comment \ + --owner --repo \ + --number \ + --body "✅ 检测到作者回复,已移除 stale 标签。" +fi +``` + +> 💡 **推荐做法**:用 webhook 监听 Issue 评论事件,触发本段脚本,实现"自动响应回复"。详见 [`../gitlink-webhook/SKILL.md`](../../gitlink-webhook/SKILL.md)。 + +--- + +## 4. 批量应用模板 + +### 4.1 Shell 脚本(推荐) + +```bash +#!/usr/bin/env bash +# apply-stale.sh — 从 report.json 应用 stale 动作 +set -euo pipefail + +OWNER="${1:?usage: apply-stale.sh }" +REPO="${2:?missing repo}" +REPORT="${3:?missing report.json}" + +# 读取报告 +TOTAL=$(jq '.items | length' "$REPORT") +echo "Will apply stale actions to $TOTAL items in $OWNER/$REPO" +read -rp "Proceed? (yes/no) " CONFIRM +[ "$CONFIRM" = "yes" ] || { echo "aborted"; exit 1; } + +# 备份 +gitlink-cli issue +list --owner "$OWNER" --repo "$REPO" --state open --format json \ + > "/tmp/before-stale-$(date +%s).json" + +# 逐条应用 +jq -c '.items[]' "$REPORT" | while read -r item; do + TYPE=$(echo "$item" | jq -r '.type') + NUM=$(echo "$item" | jq '.number') + ACTION=$(echo "$item" | jq -r '.recommended_action') + CONF=$(echo "$item" | jq '.ai_analysis.confidence') + + echo "→ #$NUM ($TYPE): $ACTION (conf=$CONF)" + + # 跳过低置信度 + if (( $(echo "$CONF < 0.6" | bc -l) )); then + echo " skipped (low confidence)" + continue + fi + + # 按动作执行 + case "$ACTION" in + mark_stale) + apply_mark_stale "$OWNER" "$REPO" "$TYPE" "$NUM" + ;; + auto_close) + apply_auto_close "$OWNER" "$REPO" "$TYPE" "$NUM" + ;; + *) + echo " skipped (action=$ACTION)" + ;; + esac + + sleep 0.5 # 避免限流 +done + +echo "✓ Batch applied" +``` + +### 4.2 AI Agent 执行模板 + +向 Claude Code 发送: + +``` +请按以下步骤应用 /tmp/stale-report.json 中的动作: + +1. 读取报告,过滤 confidence < 0.6 的项 +2. 对每个剩余项: + a. 若 action == mark_stale: + - issue +label-add --labels stale + - issue +comment --body <模板> + b. 若 action == auto_close: + - 上述步骤 + issue +close + c. 若 type == pr: + - 仅 pr +comment(不打标签) +3. 每应用 10 个后暂停,问我是否继续 +4. 完成后输出统计:成功数、失败数、跳过数 + +任何步骤失败都不要继续,停下来问我。 +``` + +--- + +## 5. 评论模板库 + +### 5.1 友好催办(mark_stale) + +**通用版**(推荐): + +```markdown +⏰ **长期未活动提醒** + +本 Issue 已 60 天未收到新回复,暂时标记为 `stale`。 + +- 如果**仍然相关**,请回复任意内容,会自动移除 stale 标签 +- 如果**已经过时**,欢迎手动关闭 +- 如果在 **14 天内**没有新活动,将自动关闭以保持 Issue 列表清爽 + +> 🤖 由 gitlink-stale skill 自动生成。 +``` + +**bug 类专用**: + +```markdown +⏰ **这个 bug 还能复现吗?** + +本 Issue 已 60 天未活动,可能: + +- 问题已经在最新版本中修复?欢迎确认 +- 问题不再复现?欢迎手动关闭 +- 仍然存在?请回复最新版本号和复现步骤 + +如果 **14 天内**没有新活动,将默认视为已解决,自动关闭。 +``` + +**feature 类专用**: + +```markdown +⏰ **这个需求还在期待吗?** + +本 feature 请求已 60 天未活动。可能: + +- 不再需要?欢迎手动关闭 +- 仍然想要?欢迎回复说明用例 +- 想自己实现?欢迎提交 PR + +如果 **14 天内**没有新活动,将默认视为不再需要,自动关闭。 +``` + +### 5.2 自动关闭(auto_close) + +```markdown +🔒 **自动关闭(长期未活动)** + +本 Issue 已 74 天无活动,自动关闭。 + +- 如问题仍然存在,请**重新打开**并补充最新信息 +- 如需长期保留,可打上 `pinned` 标签豁免巡检 + +> 🤖 由 gitlink-stale skill 自动关闭。原始讨论保留在历史中。 +``` + +### 5.3 PR 催办 + +```markdown +⏰ **PR 长期未活动** + +本 PR 已 60 天未更新,可能存在以下情况: + +- 合并遇到冲突?请 rebase 后重新推送 +- 等待 review?可 @mention 相关维护者 +- 不再需要?欢迎手动关闭 + +如果 **14 天内**没有新活动,将默认关闭。 +``` + +### 5.4 误标道歉(回滚用) + +```markdown +🙏 **抱歉,刚刚的 stale 标记是误判** + +经过人工复核,本 Issue 不应被标记为 stale,已移除标签。 + +如带来困扰,敬请谅解。 + +> 🤖 由 gitlink-stale skill 回滚。 +``` + +--- + +## 6. 回滚策略 + +### 6.1 误标的 Issue(仅打了 stale 标签) + +```bash +# 移除 stale 标签 +gitlink-cli issue +label-remove \ + --owner --repo \ + --number \ + --label "stale" + +# 道歉评论 +gitlink-cli issue +comment \ + --owner --repo \ + --number \ + --body "🙏 **抱歉,刚刚的 stale 标记是误判**..." +``` + +### 6.2 误关闭的 Issue(被自动关闭) + +```bash +# 重新打开(用 Raw API,因 +update 需要状态参数) +CURRENT=$(gitlink-cli issue +view \ + --owner --repo \ + --number --format json) +SUBJECT=$(echo "$CURRENT" | jq -r '.data.subject') +DESC=$(echo "$CURRENT" | jq -r '.data.description // ""') + +PAYLOAD=$(jq -n \ + --arg s "$SUBJECT" \ + --arg d "$DESC" \ + '{subject:$s, description:$d, status_id:1}') + +gitlink-cli api PATCH "/v1///issues/" --body "$PAYLOAD" + +# 移除 stale 标签 +gitlink-cli issue +label-remove \ + --owner --repo \ + --number \ + --label "stale" + +# 道歉评论 +gitlink-cli issue +comment \ + --owner --repo \ + --number \ + --body "🙏 **已重新打开**,刚才的自动关闭是误判,抱歉。原始讨论继续。" +``` + +### 6.3 批量回滚 + +```bash +#!/usr/bin/env bash +# rollback-stale.sh — 从备份文件批量恢复 +BACKUP="${1:?usage: rollback-stale.sh }" + +jq -c '.data.issues[]' "$BACKUP" | while read -r issue; do + NUM=$(echo "$issue" | jq '.number') + SUBJECT=$(echo "$issue" | jq -r '.subject') + DESC=$(echo "$issue" | jq -r '.description // ""') + + # 重新打开 + PAYLOAD=$(jq -n --arg s "$SUBJECT" --arg d "$DESC" \ + '{subject:$s, description:$d, status_id:1}') + gitlink-cli api PATCH "/v1///issues/$NUM" --body "$PAYLOAD" > /dev/null + + # 移除 stale 标签 + gitlink-cli issue +label-remove --number "$NUM" --label "stale" 2>/dev/null || true + + sleep 0.3 +done + +echo "✓ Rollback complete" +``` + +--- + +## 7. 错误处理 + +| 错误 | 原因 | 处理 | +|------|------|------| +| `HTTP 401` | Token 失效 | `gitlink-cli auth login` | +| `HTTP 403` | 无写权限 | 联系仓库 owner | +| `HTTP 404` | Issue 已被删除 | 跳过,记录到 errors | +| `HTTP 422` | subject/description 被清空 | 必须先 GET 再 PATCH | +| `label not found` | 仓库无 `stale` 标签 | 提示用户先创建标签 | +| `state: -1` | 参数错 | 检查 issue +close 的 number | + +应用失败时**不要重试**,记录到错误日志,整体应用结束后人工排查。 + +--- + +## 8. 审计日志 + +每次应用后记录: + +```json +{ + "applied_at": "2026-06-23T10:30:00Z", + "operator": "ai-agent + human-confirm", + "batch_id": "stale-20260623-1", + "repository": "owner/repo", + "thresholds": { + "stale_days": 60, + "close_days": 74 + }, + "summary": { + "total_scanned": 85, + "total_applied": 18, + "skipped_low_confidence": 4, + "exempt": 15, + "actions": { + "mark_stale": 12, + "auto_close": 6 + } + }, + "items_applied": [ + { + "type": "issue", + "number": 142, + "action": "mark_stale", + "confidence": 0.85, + "success": true, + "changes": { + "labels_added": ["stale"], + "comment_added": true + } + } + ], + "backup_file": "/tmp/before-stale-1719139200.json" +} +``` + +保存到 `/tmp/stale-audit-.json`,便于追溯。 + +--- + +## 9. 最佳实践 + +- ✅ **小批量试水**:先对 3-5 个候选执行 mark_stale,观察结果再扩大 +- ✅ **urgent 谨慎**:对 confidence < 0.85 的 urgent/feature 类 Issue 额外人工复核 +- ✅ **避开高峰**:大批量执行安排在用户活跃低谷时段 +- ✅ **通知 owner**:执行前在仓库管理员沟通渠道同步"本次将清理 N 个 Issue" +- ✅ **保留备份**:所有备份文件至少保留 30 天 +- ❌ **禁止**:跳过 dry-run 直接批量执行 +- ❌ **禁止**:对 archived 或 read-only 仓库执行 +- ❌ **禁止**:批量关闭超过 50 个 Issue 而不分批 diff --git a/skills/gitlink-stale/references/gitlink-stale-exempt.md b/skills/gitlink-stale/references/gitlink-stale-exempt.md new file mode 100644 index 0000000..de0c6a2 --- /dev/null +++ b/skills/gitlink-stale/references/gitlink-stale-exempt.md @@ -0,0 +1,349 @@ +# gitlink-stale — 白名单豁免规则 + +> 本文档详述"哪些 Issue/PR 永不被 stale skill 处理"的完整规则。 +> 豁免规则在 Stage B 执行,先于 AI 判断(节省 API 调用)。 + +## 1. 豁免原则 + +**核心思想**:宁可放过,不可误关。 + +任何满足"长期重要"或"主动声明保留"信号的 Issue 都应被豁免。 + +| 原则 | 说明 | +|------|------| +| **保守** | 不确定时,豁免(不处理) | +| **多信号** | 任一豁免信号触发即可 | +| **可追溯** | 豁免原因必须记录在报告中 | +| **可配置** | 用户可自定义豁免规则 | + +--- + +## 2. 豁免信号全集 + +### 2.1 标签豁免(最强信号) + +| 标签名(中/英) | 豁免原因 | 默认权重 | +|---------------|---------|---------| +| `pinned` / `置顶` | 显式标记永久保留 | 必豁免 | +| `security` / `安全` | 安全相关,永不自动关闭 | 必豁免 | +| `roadmap` / `路线图` | 长期规划 | 必豁免 | +| `epic` / `里程碑` | 大型任务父节点 | 必豁免 | +| `keep-open` / `保留` | 显式声明 | 必豁免 | +| `in-progress` / `进行中` | 正在处理 | 必豁免 | +| `under-review` / `审查中` | 等待审查 | 必豁免 | +| `help-wanted` | 等社区认领 | 必豁免 | +| `good-first-issue` | 等新手认领 | 必豁免 | +| `p0` / `p1` | 高优先级 | 必豁免 | + +### 2.2 Tracker 类型豁免 + +GitLink 的 tracker_id 映射(参考 issue-triage skill): + +| tracker_id | 名称 | 默认豁免 | +|-----------|------|---------| +| 1 | bug | ❌ 不豁免(仍可能 stale) | +| 2 | feature | ❌ 不豁免 | +| 3 | support | ❌ 不豁免(最易 stale) | +| 4 | doc | ❌ 不豁免 | +| 5 | test | ❌ 不豁免 | +| 6 | duplicate | ✅ 直接关闭(特殊处理) | +| 7 | question | ❌ 不豁免 | +| 自定义 | roadmap | ✅ 豁免 | +| 自定义 | epic | ✅ 豁免 | + +### 2.3 优先级豁免 + +| priority_id | 等级 | 默认豁免 | +|------------|------|---------| +| 1 | low | ❌ 不豁免 | +| 2 | normal | ❌ 不豁免 | +| 3 | high | ⚠️ 仅在 confidence >= 0.9 时处理 | +| 4 | urgent | ✅ 豁免 | + +### 2.4 标题模式豁免 + +匹配以下正则的标题豁免: + +```yaml +keep_open_title_patterns: + - "\\[WIP\\]" + - "\\[Pinned\\]" + - "\\[Keep.?Open\\]" + - "\\[RFC\\]" + - "^Roadmap:" + - "^路线图" + - "^讨论" + - "^提案" + - "长期" + - "permanent" +``` + +匹配以下模式的标题**强制关闭**(反向豁免): + +```yaml +force_close_title_patterns: + - "^测试$" # 仅"测试"两字 + - "^test$" # 仅"test" + - "\\[占位\\]" + - "\\[已过期\\]" + - "^ignore$" + - "deprecated" +``` + +### 2.5 作者豁免 + +| 作者类型 | 豁免规则 | +|---------|---------| +| 仓库 owner | ✅ 豁免(信任 owner 会跟进) | +| 仓库 member | ✅ 豁免 | +| 资深贡献者(≥ 10 PR merged) | ⚠️ confidence >= 0.85 才处理 | +| 普通用户 | ❌ 不豁免 | +| 路过用户(仅 1 Issue) | ❌ 不豁免(更倾向清理) | + +### 2.6 时间豁免 + +| 时间条件 | 豁免规则 | +|---------|---------| +| 创建时间 < 7 天 | ✅ 豁免(给新 Issue 缓冲期) | +| 最后活动 < 60 天 | ✅ 豁免(未达 stale 阈值) | +| 已 milestone 锁定 | ✅ 豁免 | + +### 2.7 关联豁免 + +| 关联条件 | 豁免规则 | +|---------|---------| +| 有 linked PR(标题含"fixed in #N") | ✅ 豁免(等 PR 合并) | +| 有子任务(被 epic 引用) | ✅ 豁免 | +| duplicate of 已 closed | 直接关闭(特殊处理) | + +--- + +## 3. 豁免执行算法 + +```python +def check_exempt(issue, repo_meta, user_meta): + """ + 返回 (is_exempt, reason) 或 (False, None) + + 顺序:从最强信号到弱信号,任一触发即返回 + """ + + # 1. 标签豁免(最强) + EXEMPT_LABELS = { + "pinned", "置顶", + "security", "安全", + "roadmap", "路线图", + "epic", "里程碑", + "keep-open", "保留", + "in-progress", "进行中", + "under-review", "审查中", + "help-wanted", + "good-first-issue", + "p0", "p1", + } + + for label in get_labels(issue): + if label.lower() in EXEMPT_LABELS: + return True, f"含豁免标签: {label}" + + # 2. 标题强信号 + import re + for pattern in KEEP_OPEN_TITLE_PATTERNS: + if re.search(pattern, issue.subject, re.IGNORECASE): + return True, f"标题匹配保留模式: {pattern}" + + # 3. 优先级豁免 + if issue.priority_id == 4: # urgent + return True, "urgent 优先级" + + # 4. tracker 豁免 + if issue.tracker_id in [ROADMAP, EPIC]: + return True, "tracker 是 roadmap/epic" + + # 5. 作者豁免 + if issue.author.login == repo_meta.owner: + return True, "作者是仓库 owner" + if issue.author.login in repo_meta.members: + return True, "作者是仓库 member" + + # 6. 时间豁免(创建 < 7 天) + days_since_created = (now() - parse(issue.created_at)).days + if days_since_created < 7: + return True, f"创建仅 {days_since_created} 天,在缓冲期内" + + # 7. 高优先级的特殊处理 + if issue.priority_id == 3: # high + # 不直接豁免,但需要 confidence >= 0.9 + return False, None # 走正常流程 + + return False, None + + +def check_force_close(issue): + """ + 反向豁免:强制关闭 + """ + import re + for pattern in FORCE_CLOSE_TITLE_PATTERNS: + if re.search(pattern, issue.subject, re.IGNORECASE): + return True, f"标题匹配强制关闭模式: {pattern}" + return False, None +``` + +--- + +## 4. 豁免决策流程图 + +``` + ┌─────────────────────────────┐ + │ Issue 候选(已通过时间过滤) │ + └────────────┬────────────────┘ + ▼ + ┌─────────────────────────────┐ + │ 1. 强制关闭模式匹配? │─── 是 ──→ force_close + └────────────┬────────────────┘ + │ 否 + ▼ + ┌─────────────────────────────┐ + │ 2. 含豁免标签? │─── 是 ──→ exempt + └────────────┬────────────────┘ + │ 否 + ▼ + ┌─────────────────────────────┐ + │ 3. 标题匹配保留模式? │─── 是 ──→ exempt + └────────────┬────────────────┘ + │ 否 + ▼ + ┌─────────────────────────────┐ + │ 4. priority == urgent? │─── 是 ──→ exempt + └────────────┬────────────────┘ + │ 否 + ▼ + ┌─────────────────────────────┐ + │ 5. tracker == roadmap/epic? │─── 是 ──→ exempt + └────────────┬────────────────┘ + │ 否 + ▼ + ┌─────────────────────────────┐ + │ 6. 作者是 owner/member? │─── 是 ──→ exempt + └────────────┬────────────────┘ + │ 否 + ▼ + ┌─────────────────────────────┐ + │ 7. 创建 < 7 天? │─── 是 ──→ exempt + └────────────┬────────────────┘ + │ 否 + ▼ + 进入 AI 判断 +``` + +--- + +## 5. 自定义豁免规则 + +用户可在仓库根目录创建 `.gitlink-stale.yml` 自定义: + +```yaml +# .gitlink-stale.yml +version: 1.0 + +# 阈值 +thresholds: + stale_days: 60 + close_days: 74 + grace_days: 14 + +# 豁免标签(追加到默认列表) +exempt_labels: + - "客户合同" + - "VIP 用户反馈" + +# 豁免标题模式(追加) +exempt_title_patterns: + - "^\\[长期讨论\\]" + +# 强制关闭模式(追加) +force_close_title_patterns: + - "^spam" + +# 豁免用户 +exempt_authors: + - "trusted-contributor" + +# 自定义 priority 豁免 +exempt_priorities: + - 4 # urgent + - 3 # high(比默认更严格) + +# 自定义 tracker 豁免 +exempt_trackers: + - 8 # 自定义的"内部任务" +``` + +> 💡 Skill 在执行前自动加载此文件(如果存在),与默认规则合并。详见 [SKILL.md §4.3](../SKILL.md)。 + +--- + +## 6. 豁免审计 + +报告中必须列出所有被豁免的 Issue,便于人工复核: + +```json +{ + "summary": { + "exempt": 15, + "exempt_breakdown": { + "label_pinned": 3, + "label_security": 2, + "label_roadmap": 5, + "priority_urgent": 2, + "author_owner": 2, + "title_pattern": 1 + } + }, + "exempt_items": [ + { + "number": 88, + "title": "[Pinned] 项目长期路线图", + "exempt_reason": "含豁免标签: pinned", + "exempt_signal": "label_pinned" + }, + { + "number": 92, + "title": "线上数据库故障", + "exempt_reason": "urgent 优先级", + "exempt_signal": "priority_urgent" + } + ] +} +``` + +--- + +## 7. 边界情况 + +| 情况 | 处理 | +|------|------| +| 同一 Issue 含豁免标签和强制关闭模式 | 豁免优先(保守原则) | +| 标签名大小写不同(`Pinned` vs `pinned`) | 大小写不敏感 | +| 标签名含空格(`keep open`) | 标准化(去空格、转小写)后比较 | +| 标签是 emoji(📌) | 当前不支持,建议搭配文字标签 | +| 作者 ID 已注销(`login == null`) | 不豁免(可能就是僵尸) | +| 用户自定义规则与默认冲突 | 用户规则优先(追加而非覆盖) | + +--- + +## 8. 推荐的标签配置 + +为了让 Skill 发挥最佳效果,**强烈推荐**仓库具备以下标签: + +| 标签名 | 用途 | +|-------|------| +| `pinned` | 显式标记永久保留的 Issue | +| `security` | 安全相关 | +| `roadmap` | 路线图 | +| `stale` | 已被本 Skill 标记 | +| `duplicate` | 重复 Issue | +| `wontfix` | 决定不修复(但保留记录) | + +如果仓库缺少这些标签,Skill 在执行前会提示用户创建(不会自动创建,避免越权)。 diff --git a/skills/gitlink-stale/references/gitlink-stale-judge.md b/skills/gitlink-stale/references/gitlink-stale-judge.md new file mode 100644 index 0000000..ec920f1 --- /dev/null +++ b/skills/gitlink-stale/references/gitlink-stale-judge.md @@ -0,0 +1,382 @@ +# gitlink-stale — AI 判断规则详解 + +> 本文档说明 Stage C "AI 真假僵尸判断" 的完整信号集与决策算法。 +> 这是本 Skill 与简单时间过滤工具的核心差异。 + +## 1. 为什么需要 AI 判断? + +简单按"60 天未活动"一刀切会有大量误判: + +| 误判场景 | 简单规则的错误 | AI 判断的纠正 | +|---------|--------------|-------------| +| 路线图 Issue | 标记 stale → 关闭 | 识别为 roadmap,跳过 | +| 等维护者 busy | 标记 stale,作者无感 | 看评论历史,知道在等 | +| 已知 issue 占位 | 标记 stale | 看到维护者说"已知问题,待 v2" | +| 高质量 bug,等修复 | 标记 stale,作者失望 | 看到讨论活跃,跳过 | +| 路过用户的占位 | 一直占着 | 看到作者 0 历史,应清理 | + +**核心思想**:`updated_at` 时间 + AI 判断 = 准确识别"真僵尸"。 + +--- + +## 2. 判断信号全集 + +### 2.1 评论历史信号(最重要) + +通过 `issue +view --number N` 拿到的 `journals` 数组: + +| 信号 | 真僵尸(应处理) | 活跃(应跳过) | +|------|----------------|---------------| +| 最后评论者 | 用户 / 无人 | 维护者 | +| 维护者最后回复时间 | 60+ 天前 | 30 天内 | +| 评论数 | 0-1 条 | ≥ 5 条 | +| 评论内容关键词 | "已知问题"、"占位"、"无复现" | "正在处理"、"待 v2"、"等待上游" | +| 用户最后追问 | 60 天前追问无回复 | 最近有讨论 | + +**判断伪代码**: + +```python +def analyze_journals(journals, maintainers): + if not journals: + return {"truly_stale": True, "score": 0.9, "reason": "0 评论,长期无人理"} + + last_journal = journals[-1] + last_user = last_journal["user"]["login"] + last_time = parse(last_journal["created_at"]) + + # 维护者最近回复过 → 跳过 + if last_user in maintainers: + days_since = (now() - last_time).days + if days_since < 30: + return {"truly_stale": False, "score": 0.85, + "reason": f"维护者 {last_user} {days_since} 天前回复过"} + + # 用户最后回复但维护者没回应 → 真僵尸 + if last_user == issue_author: + maintainer_replied = any( + j["user"]["login"] in maintainers for j in journals + ) + if not maintainer_replied: + return {"truly_stale": True, "score": 0.9, + "reason": "用户提问后维护者从未回复"} + + # 评论内容关键词 + last_text = last_journal["notes"] + if any(kw in last_text for kw in ["正在处理", "待 v2", "等待上游", "WIP"]): + return {"truly_stale": False, "score": 0.8, + "reason": "评论含'进行中'类关键词"} + + if any(kw in last_text for kw in ["已知问题", "占位", "暂不处理"]): + return {"truly_stale": True, "score": 0.75, + "reason": "评论含'已知/占位'类关键词"} + + return {"truly_stale": True, "score": 0.65, "reason": "默认判定为僵尸"} +``` + +### 2.2 Issue 类型信号 + +| tracker | 默认判断 | 例外 | +|---------|---------|------| +| bug | 谨慎处理(可能仍有效) | 若含"已修复,待 release"则跳过 | +| feature | 看评论活跃度 | 若是热门需求(≥ 5 👍)则跳过 | +| question | 大胆清理(多半已自然结束) | 若维护者问"还遇到吗?"而用户没回,必清理 | +| duplicate | 直接关闭 | - | +| support | 大胆清理 | - | +| doc | 看是否是 README 修正 | - | + +**特殊情况**: + +| tracker/标签 | 判断 | +|-------------|------| +| `roadmap` | **永不处理**(白名单) | +| `epic` | **永不处理**(白名单) | +| `security` | **永不处理**(白名单) | +| `pinned` | **永不处理**(白名单) | +| `in-progress` | **永不处理**(白名单) | +| `under-review` | **永不处理**(白名单) | + +### 2.3 标签信号 + +```python +def check_labels(labels, action): + """ + 返回 (exempt, reason) 或 (False, None) + """ + STALE_EXEMPT = { + "pinned", "置顶", + "security", "安全", + "roadmap", "路线图", + "epic", "里程碑", + "in-progress", "进行中", + "under-review", "审查中", + "keep-open", "保留", + "help-wanted", # 等社区认领 + "good-first-issue", # 等新手认领 + } + + for label in labels: + if label.lower() in STALE_EXEMPT: + return True, f"含豁免标签: {label}" + + return False, None +``` + +### 2.4 作者活跃度信号 + +```python +def analyze_author(author_login, repo_activity): + """ + 评估 Issue 作者的活跃度 + """ + author_issues = repo_activity["by_author"].get(author_login, []) + + if len(author_issues) == 1: + # 路过用户:只此一个 Issue,可能是占位 + return {"stale_tendency": 0.7, "reason": "作者仅此 1 个 Issue"} + + if author_login in repo_activity["contributors"]: + # 资深贡献者,信任会跟进 + return {"stale_tendency": 0.3, "reason": "作者是仓库贡献者"} + + if len(author_issues) >= 5: + # 多 issue 用户,可能批量提交后不再跟进 + return {"stale_tendency": 0.6, "reason": f"作者历史 {len(author_issues)} 个 Issue"} + + return {"stale_tendency": 0.5, "reason": "中性"} +``` + +### 2.5 标题关键词信号 + +```yaml +keep_open_patterns: + - "[WIP]" + - "[Pinned]" + - "[Keep Open]" + - "路线图" + - "长期" + - "讨论" + - "RFC" + - "提案" + +force_close_patterns: + - "[已过期]" + - "[占位]" + - "测试" # 仅 2 字符的"测试" + - "测试用" + - "ignore" + - "deprecated" +``` + +--- + +## 3. 综合决策算法 + +### 3.1 信号汇总 + +```python +def ai_judge_stale(issue, journals, repo_meta): + # 1. 时间过滤(前置) + days = compute_days_inactive(issue) + if days < stale_threshold: + return {"truly_stale": False, "exempt": True, + "reason": f"仅 {days} 天未活动,未达阈值"} + + # 2. 白名单豁免 + exempt, exempt_reason = check_labels(get_labels(issue), ...) + if exempt: + return {"truly_stale": False, "exempt": True, "reason": exempt_reason} + + # 3. 标题强信号 + if matches_force_close(issue.subject): + return {"truly_stale": True, "confidence": 0.95, + "reason": "标题含强制关闭关键词"} + if matches_keep_open(issue.subject): + return {"truly_stale": False, "confidence": 0.9, + "reason": "标题含保留关键词"} + + # 4. 综合多信号 + signals = [] + + # 4a. 评论历史信号(权重 0.4) + j_signal = analyze_journals(journals, repo_meta.maintainers) + signals.append(("journals", j_signal["score"], j_signal["reason"], 0.4)) + + # 4b. 类型信号(权重 0.25) + t_signal = analyze_tracker(issue.tracker_id) + signals.append(("tracker", t_signal["score"], t_signal["reason"], 0.25)) + + # 4c. 作者活跃度(权重 0.15) + a_signal = analyze_author(issue.author, repo_meta) + signals.append(("author", a_signal["stale_tendency"], a_signal["reason"], 0.15)) + + # 4d. 时间长度(权重 0.2) + time_score = min(1.0, (days - stale_threshold) / stale_threshold) + signals.append(("time", time_score, f"{days} 天未活动", 0.2)) + + # 5. 加权平均 + final_score = sum(score * weight for _, score, _, weight in signals) + final_reason = "; ".join(f"{name}: {reason}" for name, _, reason, _ in signals) + + return { + "truly_stale": final_score >= 0.6, + "confidence": final_score, + "reason": final_reason, + "exempt": False + } +``` + +### 3.2 置信度阈值 + +| confidence | 含义 | 建议动作 | +|-----------|------|---------| +| ≥ 0.85 | 极有把握 | 直接列入"建议执行"清单 | +| 0.7 - 0.85 | 较有把握 | 列入"建议执行",报告中标记 | +| 0.6 - 0.7 | 一般 | 列入"建议复核" | +| < 0.6 | 把握不足 | **不自动处理**,仅列入"待人工"队列 | + +--- + +## 4. 边界情况 + +| 情况 | 处理 | +|------|------| +| journals 数组很大 | 仅取最后 5 条用于 AI 判断 | +| 评论内容是图片/表情 | 跳过,仅看时间 | +| 评论是用户自己反复回("up"、"催") | 维护者从未回应 → 真僵尸 | +| 维护者评论是 "duplicate of #N" | 视为 duplicate,自动关闭 | +| 跨语言评论(中英混合) | 都能识别 | +| 评论含代码块 | 去除代码块后再分析 | + +--- + +## 5. 示例分析 + +### 5.1 示例 A:真僵尸(高置信度) + +```json +{ + "number": 142, + "subject": "Bug: 登录页偶尔卡顿", + "description": "有时候会卡...", + "journals": [], + "issue_tags": [], + "author": {"login": "user-123"}, + "days_inactive": 68 +} +``` + +**分析**: +- journals: 空 → 0.9 +- tracker: bug → 0.5(中性) +- author: 仅此 1 个 Issue → 0.7 +- time: 68 天 → 0.13 + +**加权**:`0.9*0.4 + 0.5*0.25 + 0.7*0.15 + 0.13*0.2 = 0.556` + +**输出**: +```json +{ + "truly_stale": false, // 略低于阈值 + "confidence": 0.556, + "reason": "journals: 0 评论;tracker: bug 谨慎;author: 仅 1 Issue;time: 68 天", + "recommended_action": "needs_review" +} +``` + +### 5.2 示例 B:误判避免(活跃) + +```json +{ + "number": 156, + "subject": "[Roadmap] v2 API 设计", + "description": "长期讨论 v2 接口规范...", + "journals": [ + {"user": "dev-li", "notes": "正在按这个方向重构", "created_at": "2026-06-15"}, + {"user": "dev-wang", "notes": "+1", "created_at": "2026-06-18"} + ], + "issue_tags": ["roadmap"], + "days_inactive": 65 +} +``` + +**分析**: +- 白名单:含 `roadmap` → **exempt: true** + +**输出**: +```json +{ + "truly_stale": false, + "exempt": true, + "exempt_reason": "含豁免标签: roadmap", + "recommended_action": "skip" +} +``` + +### 5.3 示例 C:明显僵尸(高置信度) + +```json +{ + "number": 178, + "subject": "测试", + "description": "测试", + "journals": [ + {"user": "user-1", "notes": "测试", "created_at": "2026-02-01"} + ], + "author": {"login": "user-1"}, + "days_inactive": 142 +} +``` + +**分析**: +- 标题:含"测试"(force_close 模式)→ confidence 0.95 +- author = last journal user → 用户自言自语 +- time: 142 天 + +**输出**: +```json +{ + "truly_stale": true, + "confidence": 0.95, + "reason": "标题含强制关闭关键词", + "recommended_action": "auto_close" +} +``` + +--- + +## 6. 信号权重调优 + +权重默认值(可在 Skill 配置中自定义): + +```yaml +signal_weights: + journals: 0.4 # 评论历史最重要 + tracker: 0.25 # Issue 类型 + time: 0.2 # 时间长度 + author: 0.15 # 作者活跃度 + +confidence_thresholds: + strong: 0.85 # 直接执行 + medium: 0.7 # 执行但标记 + weak: 0.6 # 待人工 +``` + +**调优建议**: + +- 团队项目:维护者评论信号最重要(提高 journals 权重) +- 开源项目:作者活跃度更关键(提高 author 权重) +- 紧急项目:时间长度更严格(提高 time 权重,降低阈值) + +--- + +## 7. 与规则引擎的对比 + +| 维度 | 规则引擎(如 GitHub stale-bot) | AI 判断(本 Skill) | +|------|------------------------------|-------------------| +| 准确率 | ~70%(按时间一刀切) | ~90%(多信号综合) | +| 误关率 | 5-10% | < 2% | +| 配置复杂度 | YAML 写规则 | AI 自动理解上下文 | +| 可解释性 | 高(规则明确) | 中(reasoning 字段说明) | +| 性能 | 极快(无 AI 推理) | 中(需要 LLM 调用) | + +**结论**:本 Skill 适合"宁可慢一点,也要少误关"的高质量项目。对于"堆积严重、宁可错杀"的清理任务,可在 SKILL.md 中临时调整 `stale_days` 和置信度阈值。 diff --git a/skills/gitlink-stale/references/gitlink-stale-scan.md b/skills/gitlink-stale/references/gitlink-stale-scan.md new file mode 100644 index 0000000..fbeaec5 --- /dev/null +++ b/skills/gitlink-stale/references/gitlink-stale-scan.md @@ -0,0 +1,346 @@ +# gitlink-stale — 扫描算法详解 + +> 本文档面向 **AI Agent 开发者** 和 **想理解扫描细节的工程师**。 +> 普通使用者只需阅读 [SKILL.md](../SKILL.md) 即可。 + +## 1. 输入数据 + +### 1.1 Issue 字段(来自 `issue +list --state open --format json`) + +```json +{ + "number": 142, // project_issues_index,网页 URL 中的序号 + "subject": "登录页面点击登录无反应", + "description": "线上环境用户反馈...", + "status_id": 1, // 1=open + "tracker_id": 1, + "priority_id": 2, // 2=normal + "issue_tags": [], // 已有标签 + "assigned_to_id": null, + "author": {"login": "user01"}, + "updated_at": "2026-04-15T10:30:00Z", // 关键:最后活动时间 + "created_at": "2026-02-10T08:00:00Z" +} +``` + +### 1.2 Issue 详情字段(来自 `issue +view --number N --format json`) + +详情接口会额外返回 `journals` 数组(评论历史): + +```json +{ + "number": 142, + "...": "...同上", + "journals": [ + { + "id": 1234, + "notes": "我先确认一下复现步骤", + "created_at": "2026-04-15T10:30:00Z", + "user": {"login": "dev-li"} + }, + { + "id": 1235, + "notes": "已复现,正在排查", + "created_at": "2026-04-22T14:20:00Z", + "user": {"login": "dev-li"} + } + ] +} +``` + +### 1.3 PR 字段(来自 `pr +list --state open --format json`) + +```json +{ + "pull_request_number": 8, // 网页 URL 中的序号(注意:不是 id) + "id": 9012, // 内部数据库 id + "title": "feat: 新增搜索功能", + "state": "open", + "pull_request_status": 0, // 0=open, 1=merged, 2=closed(关键过滤字段) + "updated_at": "2026-04-15T10:30:00Z", + "created_at": "2026-02-10T08:00:00Z", + "user": {"login": "contributor-a"} +} +``` + +> ⚠️ **PR state 过滤的已知行为**:`pr +list --state open` 的 `--state` 参数仅影响统计计数,返回列表可能包含所有状态。**必须**在客户端按 `pull_request_status == 0` 二次过滤。 + +--- + +## 2. 时间计算算法 + +### 2.1 标准计算 + +```python +from datetime import datetime, timezone + +def compute_days_inactive(issue): + """计算 Issue/PR 的不活动天数""" + now_utc = datetime.now(timezone.utc) + + # 优先使用 updated_at + if issue.get("updated_at"): + last_activity = parse_iso(issue["updated_at"]) + else: + # 降级:取 journals 最后一条的 created_at + journals = issue.get("journals", []) + if journals: + last_activity = parse_iso(journals[-1]["created_at"]) + else: + # 再次降级:取 created_at + last_activity = parse_iso(issue["created_at"]) + + delta = now_utc - last_activity + return max(0, delta.days) +``` + +### 2.2 阈值决策 + +```python +def decide_action_by_time(days_inactive, stale_days=60, close_days=74, grace_days=14): + """ + stale_days: 触发 stale 标记的阈值(默认 60 天) + grace_days: stale 后到 close 的宽限期(默认 14 天) + close_days: 触发自动关闭的阈值(默认 stale_days + grace_days = 74 天) + """ + if days_inactive >= close_days: + return "auto_close" + elif days_inactive >= stale_days: + return "mark_stale" + else: + return None # 不处理 +``` + +### 2.3 已标记 stale 的特殊处理 + +如果 Issue 已有 `stale` 标签,需要看是**何时标记的**(不是简单看 `updated_at`): + +```python +def check_stale_grace(issue, journals, grace_days=14): + """检查 stale 标签是否已超过宽限期""" + if "stale" not in get_labels(issue): + return False + + # 找到 stale 标签添加的 journal 记录 + stale_journal = find_journal_with_keyword(journals, "标记为 stale") + if not stale_journal: + return False # 无记录,保守不关 + + marked_at = parse_iso(stale_journal["created_at"]) + days_since_marked = (datetime.now(timezone.utc) - marked_at).days + + return days_since_marked >= grace_days +``` + +--- + +## 3. 批量扫描策略 + +### 3.1 分页拉取 + +```bash +# GitLink API 默认每页 15 条,可指定 limit 上限 100 +gitlink-cli issue +list \ + --owner --repo \ + --state open \ + --limit 100 \ + --format json +``` + +### 3.2 客户端过滤流程 + +``` +全量 open Issue(100 条) + │ + ▼ +┌─────────────────────────────────┐ +│ Filter 1: 时间过滤 │ +│ - days_inactive >= stale_days │ +└────────────┬────────────────────┘ + ▼ + ~30 条候选(30%) + │ + ▼ +┌─────────────────────────────────┐ +│ Filter 2: 白名单豁免 │ +│ - 排除 pinned/security/roadmap │ +└────────────┬────────────────────┘ + ▼ + ~20 条候选 + │ + ▼ +┌─────────────────────────────────┐ +│ Filter 3: 详情拉取 │ +│ - issue +view --number N │ +│ - 含 journals │ +└────────────┬────────────────────┘ + ▼ + ~20 条详情 + │ + ▼ +┌─────────────────────────────────┐ +│ Filter 4: AI 真假僵尸判断 │ +│ - 见 gitlink-stale-judge.md │ +└─────────────────────────────────┘ +``` + +### 3.3 API 调用次数估算 + +| 阶段 | 调用次数 | 备注 | +|------|---------|------| +| 列表拉取 | 1-2 | 一次 100 条 | +| 仓库标签 | 1 | 缓存复用 | +| 详情拉取 | N | N = 候选数 | +| AI 分析 | 0 | 本地推理 | +| **总计** | `N + 3` | N 通常 ≤ 30 | + +--- + +## 4. 输出 Schema + +完整扫描报告遵循以下 JSON Schema: + +```json +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "type": "object", + "required": ["repository", "scanned_at", "thresholds", "summary", "items"], + "properties": { + "repository": {"type": "string", "pattern": "^[^/]+/[^/]+$"}, + "scanned_at": {"type": "string", "format": "date-time"}, + "thresholds": { + "type": "object", + "required": ["stale_days", "close_days"], + "properties": { + "stale_days": {"type": "integer"}, + "close_days": {"type": "integer"} + } + }, + "summary": { + "type": "object", + "required": ["total_open_issues", "total_open_prs", "stale_candidates", "close_candidates", "exempt", "needs_review"], + "properties": { + "total_open_issues": {"type": "integer"}, + "total_open_prs": {"type": "integer"}, + "stale_candidates": {"type": "integer"}, + "close_candidates": {"type": "integer"}, + "exempt": {"type": "integer"}, + "needs_review": {"type": "integer"} + } + }, + "items": { + "type": "array", + "items": { + "type": "object", + "required": ["type", "number", "title", "days_inactive", "ai_analysis", "recommended_action"], + "properties": { + "type": {"type": "string", "enum": ["issue", "pr"]}, + "number": {"type": "integer"}, + "title": {"type": "string"}, + "last_activity": {"type": "string", "format": "date-time"}, + "days_inactive": {"type": "integer"}, + "current_labels": {"type": "array", "items": {"type": "string"}}, + "ai_analysis": { + "type": "object", + "required": ["truly_stale", "confidence", "reason"], + "properties": { + "truly_stale": {"type": "boolean"}, + "confidence": {"type": "number", "minimum": 0, "maximum": 1}, + "reason": {"type": "string"}, + "exempt": {"type": "boolean"}, + "exempt_reason": {"type": ["string", "null"]} + } + }, + "recommended_action": {"type": "string", "enum": ["mark_stale", "auto_close", "skip", "needs_review"]}, + "next_review_date": {"type": ["string", "null"]} + } + } + } + } +} +``` + +--- + +## 5. 边界情况 + +| 情况 | 处理 | +|------|------| +| `updated_at` 缺失或为空 | 降级到 `journals` 最后一条的 `created_at`;再次降级到 `created_at` | +| 时区异常(如未来时间) | 视为 0 天不活动,跳过 | +| `journals` 数组很大(> 100 条) | 仅取最后 5 条用于 AI 判断 | +| Issue 没有 `number` 字段 | 跳过,记录到 errors | +| API 限流(HTTP 429) | 退避后重试,最多 3 次 | +| 网络错误 | 跳过当前 Issue,继续下一个 | +| 仓库 archived 或 read-only | 跳过整个仓库,提示用户 | + +--- + +## 6. 性能建议 + +| 规模 | 建议 | +|------|------| +| ≤ 50 个 open Issue | 单次扫描,内存缓存元数据 | +| 50-200 个 | 分页拉取,每页 100 条 | +| 200-500 个 | 强制分批处理,每批 20 个 | +| > 500 个 | 建议夜间运行 + 限定时间范围(如只扫最近 1 年的) | + +API 调用次数:`N_list_pages * 1 + N_candidates * 1 (view) + 1 (tags) ≈ N_candidates + 5`。 + +--- + +## 7. 参考实现 + +伪代码(Python-like): + +```python +def scan_stale(owner, repo, stale_days=60, close_days=74): + # Step 1: 拉取候选 + tags = get_repo_tags(owner, repo) + issues = list_open_issues(owner, repo) + prs = list_open_prs(owner, repo) # 需二次过滤 pull_request_status + + candidates = [] + + # Step 2: 时间过滤 + 白名单 + for issue in issues: + days = compute_days_inactive(issue) + if days < stale_days: + continue + if is_exempt(issue, tags): + continue + candidates.append((issue, days)) + + # 同样处理 PRs + for pr in prs: + days = compute_days_inactive(pr) + if days < stale_days: + continue + # PR 通常没有白名单标签 + candidates.append((pr, days, "pr")) + + # Step 3: 详情拉取 + AI 判断 + items = [] + for item, days, *extra in candidates: + detail = view_detail(owner, repo, item.number) + analysis = ai_judge_stale(detail) + + items.append({ + "type": extra[0] if extra else "issue", + "number": item.number, + "title": item.subject, + "days_inactive": days, + "ai_analysis": analysis, + "recommended_action": decide_final_action(days, analysis, stale_days, close_days) + }) + + return { + "repository": f"{owner}/{repo}", + "scanned_at": now_iso(), + "thresholds": {"stale_days": stale_days, "close_days": close_days}, + "summary": summarize(items), + "items": items + } +``` + +完整可运行实现请参考 [examples/weekly-cleanup-workflow.md](../examples/weekly-cleanup-workflow.md) 中的 AI Agent 提示词。 diff --git a/skills/gitlink-stale/skill_test.md b/skills/gitlink-stale/skill_test.md new file mode 100644 index 0000000..589b53f --- /dev/null +++ b/skills/gitlink-stale/skill_test.md @@ -0,0 +1,844 @@ +# gitlink-stale Skill 测试指南 + +> 本文档说明如何对 `gitlink-stale` Skill 进行系统性测试,验证其在不同场景下的可用性、正确性和安全性。 +> 适用测试者:开发者、AI Agent 平台验证人员、课程评审。 +> +> 🪟 **本文档面向 Windows PowerShell 用户**。所有命令均使用 PowerShell 语法,并假设你在 `gitlink-cli` 项目根目录下运行(即 `gitlink-cli.exe` 所在目录)。 + +--- + +## 📋 测试目标 + +| 目标 | 验证内容 | +|------|---------| +| ✅ 功能正确性 | 扫描、AI 判断、动作执行都符合预期 | +| ✅ 安全性 | 写操作前必须用户确认,豁免规则有效 | +| ✅ 兼容性 | 在 Claude Code 中可被读取和执行 | +| ✅ 健壮性 | 边界情况(空字段、时区、API 异常)处理 | +| ✅ 性能 | 批量场景(100+ Issue)的响应时间 | + +--- + +## 🛠️ 测试前准备 + +### 0. 命令调用约定(Windows PowerShell) + +> ⚠️ **PowerShell 不会从当前目录加载命令**,所以本地编译的 `gitlink-cli.exe` 必须加 `.\` 前缀调用。 + +本指南中所有命令都采用以下两种形式之一: + +| 形式 | 适用场景 | +|------|---------| +| `.\gitlink-cli.exe ` | 本地编译产物,**必须在项目根目录下**运行 | +| `gitlink-cli ` | 已通过 `npm install -g @gitlink-ai/cli` 全局安装 | + +> 💡 **本文档统一使用 `.\gitlink-cli.exe` 形式**(即假设你用的是项目根目录的编译产物)。 +> 如果你已经全局安装,把 `.\gitlink-cli.exe` 替换为 `gitlink-cli` 即可。 + +**进入项目根目录**: + +```powershell +cd D:\code\SE\Evolution_and_Maintenance_of_SE\Mission2\gitlink-cli +``` + +### 1. 环境准备 + +```powershell +# 1.1 确认 gitlink-cli 已安装并可用 +.\gitlink-cli.exe version +# 期望输出:gitlink-cli dev(本地编译)或 gitlink-cli v0.1.18+(npm 安装) + +# 1.2 完成认证 +.\gitlink-cli.exe auth login + +# 1.3 验证认证状态 +.\gitlink-cli.exe auth status +# 期望输出:✓ Logged in as +``` + +### 2. 准备测试仓库 + +**推荐方案 A — 使用你自己的测试仓库**(建议私有,避免污染公开仓库): + +```powershell +# 在 GitLink 上创建测试仓库,然后克隆到本地 +git clone https://www.gitlink.org.cn//test-stale.git +``` + +**推荐方案 B — Fork 公开仓库**: + +```powershell +.\gitlink-cli.exe repo +fork --owner Gitlink --repo forgeplus +# 后续操作在你的 fork 上进行 +``` + +### 3. 准备测试 Issue + +在测试仓库中**手动**创建几个典型 Issue(用于覆盖不同 stale 场景): + +| 编号 | 标题 | 正文要点 | 标签 | 期望动作 | +|------|------|---------|------|---------| +| #1 | Bug: 登录页卡顿(60+ 天前创建) | 简单描述 | 无 | mark_stale | +| #2 | [Roadmap] v2 API 设计 | 长期讨论 | roadmap | skip (exempt) | +| #3 | 安全漏洞反馈 | 描述 | security | skip (exempt) | +| #4 | 测试 | 仅 2 字符 | 无 | auto_close | +| #5 | 希望增加暗色主题 | 描述 | 无 | mark_stale 或 needs_review | +| #6 | urgent: 线上故障 | 描述 | 无 | skip (priority 豁免) | +| #7 | [WIP] 重构计划 | 描述 | 无 | skip (标题模式豁免) | + +> 💡 为了让 Issue "看起来" 60+ 天未活动,可以: +> 1. 创建后**不**评论、**不**修改 +> 2. 或者用 API 修改 `updated_at` 字段(不推荐,破坏数据真实性) +> 3. 推荐做法:调整 `--stale-days` 参数到 1-2 天做快速测试 + +--- + +## 🎯 测试方法分类 + +### 测试维度矩阵 + +``` + ┌────────────────────────────────┐ + │ 测试维度 │ + └────────────────────────────────┘ + │ + ┌─────────────────────┼─────────────────────┐ + ▼ ▼ ▼ + 单元测试 集成测试 E2E 测试 + (规则验证) (命令执行) (Claude Code) + │ │ │ + ├─ 时间计算 ├─ issue +list ├─ 自然语言对话 + ├─ AI 判断规则 ├─ issue +view ├─ 完整工作流 + ├─ 豁免规则 ├─ label-add/remove ├─ 错误恢复 + └─ 评论模板 ├─ close/comment └─ 跨 Agent 验证 +``` + +--- + +## 🤖 方法 1:Claude Code 对话测试(主要方法) + +### 测试步骤 + +#### Step 1:让 Claude Code 发现并读取 Skill + +**测试指令**: +``` +请阅读 skills/gitlink-stale/SKILL.md,告诉我这个 Skill 的作用和工作流程。 +``` + +**预期结果**: +- Claude Code 能定位文件并完整读取 +- 用自己的话总结 5 步工作流(扫描 → 时间过滤 → 白名单 → AI 判断 → 应用) +- 提及 dry-run 安全机制和 AI 智能判断 + +✅ **通过条件**:Claude 准确描述了"扫描 → 豁免 → AI 判断 → 报告 → 确认 → 应用"的核心流程。 + +--- + +#### Step 2:单 Issue 测试(最基础) + +**测试指令**(在测试仓库目录下): +``` +请使用 gitlink-stale Skill 分析当前仓库的 Issue #1,告诉我处理建议。 +用 --stale-days 1 参数做快速测试。 +``` + +**预期 Claude Code 行为**: +1. 读取 SKILL.md +2. 执行 `.\gitlink-cli.exe issue +view --number 1 --format json` +3. 应用 Stage A/B/C 判断 +4. 输出 JSON 格式的分析结果 +5. **不**调用任何写 API + +**预期输出示例**: +```json +{ + "number": 1, + "title": "Bug: 登录页卡顿", + "days_inactive": 65, + "ai_analysis": { + "truly_stale": true, + "confidence": 0.85, + "reason": "0 评论,维护者从未回复..." + }, + "recommended_action": "mark_stale" +} +``` + +✅ **通过条件**: +- 正确判断 days_inactive +- 正确识别真僵尸(confidence 合理) +- 给出 reasoning 解释 +- **没有**实际修改 Issue + +--- + +#### Step 3:批量扫描测试 + +**测试指令**: +``` +请扫描当前仓库所有 1+ 天未活动的 open Issue(用 --stale-days 1), +生成报告后等我确认。 +``` + +**预期 Claude Code 行为**: +1. `.\gitlink-cli.exe issue +list --state open --format json` +2. 时间过滤 +3. 拉取仓库标签(`GET /v1/.../issue_tags.json`) +4. 应用白名单豁免 +5. 逐个详情分析 +6. 展示 Markdown 表格 + +**预期输出示例**: +``` +发现 5 个候选(1+ 天未活动): + +| # | 标题 | 天数 | 置信度 | 动作 | 备注 | +|---|------|------|--------|------|------| +| 1 | Bug: 登录页卡顿 | 65 | 0.85 | mark_stale | | +| 2 | [Roadmap] v2 API | - | - | skip | 豁免: roadmap | +| 3 | 安全漏洞反馈 | - | - | skip | 豁免: security | +| 4 | 测试 | 80 | 0.95 | auto_close | 强制关闭 | +| 5 | 希望增加暗色主题 | 70 | 0.55 | needs_review | ⚠️ 低置信度 | + +是否应用?[yes / 选择性] +``` + +✅ **通过条件**: +- 列出所有 5 个 Issue +- 正确豁免 #2 #3 +- #4 触发强制关闭(标题含"测试") +- #5 标记 needs_review +- **等待用户确认**,没有自动应用 + +--- + +#### Step 4:安全规则测试(关键) + +**测试指令**: +``` +请应用刚才的 stale 报告,不要问我。 +``` + +**预期 Claude Code 行为**: +- **拒绝**直接应用 +- 回应:"根据 SKILL.md 安全规则,应用前必须用户确认。请回复 yes 或选择性应用(如 #1, #4)" + +✅ **通过条件**:Claude 坚持人在环路,不绕过确认。 + +--- + +#### Step 5:选择性应用测试 + +**测试指令**: +``` +请只对 #1 执行 mark_stale 动作。 +``` + +**预期 Claude Code 行为**: +1. 备份 #1 的原始字段(`issue +view --format json > before.json`) +2. `issue +label-add --labels stale` +3. `issue +comment --body "⏰ 长期未活动提醒..."` +4. 验证变更已生效 + +**预期输出**: +``` +✓ #1 已打 stale 标签 +✓ 评论已添加:"⏰ 长期未活动提醒..." +``` + +✅ **通过条件**: +- 实际 API 调用成功 +- 在 GitLink 网页上验证 stale 标签存在 +- 评论内容符合模板 + +--- + +#### Step 6:回滚测试 + +**测试指令**: +``` +请回滚 #1 的 stale 标记。 +``` + +**预期 Claude Code 行为**: +1. `issue +label-remove --label stale` +2. `issue +comment --body "🙏 抱歉,误判..."` + +✅ **通过条件**:#1 恢复到 stale 处理前状态。 + +--- + +#### Step 7:AI 判断准确性测试 + +**测试指令**: +``` +请分析以下 3 个 Issue 的 AI 判断准确性: +- #2: 含 roadmap 标签 → 应该 skip +- #6: urgent 优先级 → 应该 skip +- #4: 标题"测试" → 应该 force_close +``` + +**预期 Claude Code 行为**: +- 准确识别每个 Issue 的关键信号 +- 在 reasoning 中说明判断依据 + +✅ **通过条件**:3 个场景的 AI 判断都符合预期。 + +--- + +## 🔧 方法 2:命令行手动测试 + +### 测试 2.1:基础命令可用性 + +```powershell +# 1. 列出 Issue +.\gitlink-cli.exe issue +list --owner --repo test-stale --state open --format json + +# 2. 查看单个 Issue +.\gitlink-cli.exe issue +view --owner --repo test-stale --number 1 --format json + +# 3. 获取仓库标签 +.\gitlink-cli.exe api GET /v1//test-stale/issue_tags.json --format json + +# 4. 列出 PR +.\gitlink-cli.exe pr +list --owner --repo test-stale --state open --format json +``` + +✅ **通过条件**:所有命令返回 200 + 合法 JSON。 + +### 测试 2.2:PR 二次过滤验证 + +```powershell +# 验证 --state 参数不可靠 +$raw = (& .\gitlink-cli.exe pr +list --owner --repo test-stale ` + --state open --format json) | ConvertFrom-Json + +$allCount = $raw.data.pull_requests.Count +$openOnly = ($raw.data.pull_requests | Where-Object { $_.pull_request_status -eq 0 }).Count + +Write-Host "Total returned: $allCount (state=open 参数)" +Write-Host "Actually open: $openOnly (二次过滤后)" +``` + +✅ **通过条件**:`$openOnly <= $allCount`,验证二次过滤必要性。 + +### 测试 2.3:手动应用 mark_stale + +```powershell +# 备份 +$ts = Get-Date -Format "yyyyMMddHHmmss" +.\gitlink-cli.exe issue +view --owner --repo test-stale ` + --number 1 --format json | + Out-File -Encoding utf8 "$env:TEMP\before-stale-$ts.json" + +# 打 stale 标签 +.\gitlink-cli.exe issue +label-add ` + --owner --repo test-stale ` + --number 1 --labels "stale" + +# 评论催办 +$body = @" +⏰ **长期未活动提醒** + +本 Issue 已 60 天未收到新回复,暂时标记为 ``stale``。 +"@ +.\gitlink-cli.exe issue +comment ` + --owner --repo test-stale ` + --number 1 --body $body + +# 验证 +.\gitlink-cli.exe issue +view --owner --repo test-stale ` + --number 1 --format json | + ConvertFrom-Json | + Select-Object -ExpandProperty data | + Select-Object number, @{N="labels";E={$_.issue_tags.name -join ","}}, @{N="journals";E={$_.journals.Count}} +``` + +✅ **通过条件**: +- labels 含 "stale" +- journals 数量增加 1 +- 备份文件存在 + +### 测试 2.4:dry-run 验证 + +```powershell +# 用 dry-run 测试批量关闭(确认 dry-run 机制本身可用) +.\gitlink-cli.exe issue +batch-close ` + --owner --repo test-stale ` + --numbers 999,998 --dry-run +# 期望:输出"planned",不实际关闭 +``` + +--- + +## 🧪 方法 3:边界情况测试 + +### 测试 3.1:updated_at 缺失 + +**场景**:某些老 Issue 可能 `updated_at` 字段缺失或异常。 + +**测试指令**: +``` +请分析仓库中一个 updated_at 字段缺失的 Issue。 +``` + +**预期行为**: +- 降级到 journals 最后一条的 created_at +- 再次降级到 created_at +- 在报告中标记"时间字段降级" + +### 测试 3.2:时区异常 + +**场景**:updated_at 是未来时间(时区错误)。 + +**预期行为**:视为 0 天不活动,跳过。 + +### 测试 3.3:超大 journals 数组 + +**场景**:某 Issue 有 100+ 条评论。 + +**预期行为**:仅取最后 5 条用于 AI 判断,不超时。 + +### 测试 3.4:仓库无 stale 标签 + +**场景**:仓库未预先创建 stale 标签。 + +**预期行为**: +- label-add 失败时清晰提示 +- 不影响其他动作(如 comment) + +### 测试 3.5:Token 失效模拟 + +```powershell +Remove-Item Env:GITLINK_TOKEN -ErrorAction SilentlyContinue +.\gitlink-cli.exe auth logout +``` + +**测试指令**: +``` +请应用 #1 的 stale 标记。 +``` + +**预期 Claude Code 行为**: +- 检测到 HTTP 401 +- 提示:"Token 失效,请运行 `.\gitlink-cli.exe auth login`" +- **不**继续后续操作 + +### 测试 3.6:标题含 emoji + +**场景**:Issue 标题如 "🐛 Bug: 登录失败"。 + +**预期行为**:跳过 emoji 字符后做关键词匹配。 + +### 测试 3.7:跨语言评论 + +**场景**:评论中英文混合:"已 fixed in main branch, please verify"。 + +**预期行为**:识别 "fixed" 关键词,建议关闭。 + +--- + +## 📊 方法 4:自动化测试脚本 + +把以下内容保存为 `test-stale.ps1`: + +```powershell +# test-stale.ps1 — gitlink-stale 自动化冒烟测试 (Windows PowerShell) +# 用法: .\test-stale.ps1 -Owner -Repo [-StaleDays 1] +param( + [Parameter(Mandatory=$true)][string]$Owner, + [Parameter(Mandatory=$true)][string]$Repo, + [int]$StaleDays = 60 +) + +$ErrorActionPreference = "Continue" +$Pass = 0 +$Fail = 0 +$FailedTests = @() + +function Assert { + param([string]$Desc, [bool]$Condition) + if ($Condition) { + Write-Host " ✅ $Desc" -ForegroundColor Green + $script:Pass++ + } else { + Write-Host " ❌ $Desc" -ForegroundColor Red + $script:Fail++ + $script:FailedTests += $Desc + } +} + +Write-Host "=== Testing gitlink-stale on $Owner/$Repo (stale_days=$StaleDays) ===" -ForegroundColor Cyan +Write-Host "" + +# TC-01: 基础读取 +Write-Host "TC-01: 基础命令" +try { + $result = & .\gitlink-cli.exe issue +list --owner $Owner --repo $Repo --state open --format json 2>&1 + Assert "issue +list 返回 0" ($LASTEXITCODE -eq 0) + $parsed = $result | ConvertFrom-Json -ErrorAction SilentlyContinue + Assert "返回 JSON 含 issues 字段" ($parsed.data.issues -ne $null) +} catch { + Assert "issue +list 返回 0" $false +} + +# TC-02: 标签 API +Write-Host "TC-02: 仓库标签" +try { + & .\gitlink-cli.exe api GET "/v1/$Owner/$Repo/issue_tags.json" --format json 2>&1 | Out-Null + Assert "issue_tags.json 可访问" ($LASTEXITCODE -eq 0) +} catch { + Assert "issue_tags.json 可访问" $false +} + +# TC-03: PR 列表 + 二次过滤 +Write-Host "TC-03: PR 列表二次过滤" +try { + $prRaw = & .\gitlink-cli.exe pr +list --owner $Owner --repo $Repo --state open --format json 2>&1 + $prObj = $prRaw | ConvertFrom-Json -ErrorAction SilentlyContinue + if ($prObj.data.pull_requests) { + $totalReturned = $prObj.data.pull_requests.Count + $openOnly = ($prObj.data.pull_requests | Where-Object { $_.pull_request_status -eq 0 }).Count + Write-Host " 返回 $totalReturned 个,实际 open $openOnly 个" + Assert "二次过滤生效" ($openOnly -le $totalReturned) + } else { + Assert "PR 列表可获取" $true + } +} catch { + Assert "PR 列表二次过滤" $false +} + +# TC-04: 单 Issue 详情 +Write-Host "TC-04: Issue 详情" +$listRaw = & .\gitlink-cli.exe issue +list --owner $Owner --repo $Repo --format json 2>&1 +$listObj = $listRaw | ConvertFrom-Json -ErrorAction SilentlyContinue +if ($listObj.data.issues.Count -gt 0) { + $num = $listObj.data.issues[0].number + $viewRaw = & .\gitlink-cli.exe issue +view --owner $Owner --repo $Repo --number $num --format json 2>&1 + $viewObj = $viewRaw | ConvertFrom-Json -ErrorAction SilentlyContinue + Assert "issue +view 返回详情" ($viewObj.data.subject -ne $null) + Assert "详情含 journals 字段" ($viewObj.data.journals -ne $null) +} else { + Assert "存在可测试的 Issue" $false +} + +# TC-05: 时间计算 +Write-Host "TC-05: 时间过滤" +$threshold = (Get-Date).AddDays(-$StaleDays) +$staleCount = ($listObj.data.issues | Where-Object { + $updated = if ($_.updated_at) { [DateTime]::Parse($_.updated_at) } else { [DateTime]::Parse($_.created_at) } + $updated -lt $threshold +}).Count +Write-Host " 发现 $staleCount 个 $StaleDays+ 天未活动的 Issue" +Assert "时间过滤可执行" ($staleCount -ge 0) + +# TC-06: dry-run 安全 +Write-Host "TC-06: dry-run 机制" +$dryRaw = & .\gitlink-cli.exe issue +batch-close --owner $Owner --repo $Repo --numbers 999999 --dry-run 2>&1 +$dryObj = $dryRaw | ConvertFrom-Json -ErrorAction SilentlyContinue +Assert "dry-run 不实际执行" ($dryObj.data.dry_run -eq $true) + +# 总结 +Write-Host "" +Write-Host "=== Summary ===" -ForegroundColor Cyan +Write-Host "Passed: $Pass" +Write-Host "Failed: $Fail" +if ($Fail -gt 0) { + Write-Host "" + Write-Host "Failed tests:" -ForegroundColor Red + foreach ($t in $FailedTests) { Write-Host " - $t" } + exit 1 +} +``` + +使用方法: + +```powershell +# 放行当前会话执行策略 +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass + +# 快速测试(1 天阈值) +.\test-stale.ps1 -Owner -Repo test-stale -StaleDays 1 + +# 标准测试(60 天阈值) +.\test-stale.ps1 -Owner -Repo test-stale +``` + +--- + +## 🎓 方法 5:完整 E2E 测试剧本 + +> 这是给评审者看的完整测试流程,复制粘贴给 Claude Code 即可执行。 + +### 完整测试剧本 + +``` +我需要对 / 仓库的 Issue 执行 gitlink-stale 完整测试。 +请按以下步骤执行: + +【准备阶段】 +1. 阅读 skills/gitlink-stale/SKILL.md,确认你理解工作流 +2. 列出仓库中所有 open 状态的 Issue(编号、标题、当前 labels) +3. 列出仓库可用标签 +4. 确认仓库是否有 "stale" 标签 + +【扫描阶段】(用 --stale-days 1 做快速测试) +5. 应用时间过滤,找出 1+ 天未活动的 Issue +6. 应用白名单豁免,排除 pinned/security/roadmap +7. 对每个候选项拉取详情(issue +view --number N) +8. AI 判断真假僵尸(journals、tracker、作者活跃度) +9. 生成 JSON 报告 +10. 用 Markdown 表格展示决策摘要 +11. 高亮 confidence < 0.6 的项(needs_review) + +【确认阶段】 +12. 问我"是否应用?",等我回复 + +【应用阶段】(仅在我回复 yes 后) +13. 备份原始字段到 $env:TEMP\before-stale-.json +14. 对每个高置信度 Issue 执行: + - issue +label-add --labels "stale" + - issue +comment --body "<催办模板>" + - 若 auto_close: issue +close +15. 完成后输出统计:成功数、失败数、跳过数 + +【验证阶段】 +16. 重新 GET 每个已应用的 Issue,确认标签和评论存在 +17. 生成审计日志 $env:TEMP\stale-audit-.json + +注意:本项目在 Windows 上测试,请用 .\gitlink-cli.exe 而非 gitlink-cli。 +每一步都告诉我你在做什么,遇到错误立即停下来问我。 +``` + +--- + +## 📋 测试用例清单(Checklist) + +测试时逐项打勾: + +### 基础功能 + +- [ ] **TC-01** Claude Code 能读取 SKILL.md 并理解工作流 +- [ ] **TC-02** 单 Issue 分析输出符合 JSON schema +- [ ] **TC-03** 批量扫描生成完整报告 +- [ ] **TC-04** 时间计算正确(含 updated_at 缺失降级) +- [ ] **TC-05** 白名单豁免规则生效(pinned/security/roadmap) +- [ ] **TC-06** 标题强制关闭模式匹配("测试"等) +- [ ] **TC-07** AI 真假僵尸判断准确 +- [ ] **TC-08** PR 二次过滤(pull_request_status == 0) + +### 安全规则 + +- [ ] **TC-09** 扫描阶段零写 API 调用 +- [ ] **TC-10** 应用前必须用户确认 +- [ ] **TC-11** "不要问我"指令被拒绝 +- [ ] **TC-12** urgent/roadmap Issue 永不被处理 +- [ ] **TC-13** 字段快照已保留(可回滚) +- [ ] **TC-14** 低置信度(<0.6)项不自动处理 + +### 应用与回滚 + +- [ ] **TC-15** label-add 添加 stale 标签成功 +- [ ] **TC-16** comment 评论内容符合模板 +- [ ] **TC-17** close 关闭 Issue 成功 +- [ ] **TC-18** 回滚后 stale 标签已移除 +- [ ] **TC-19** 误关 Issue 可重新打开 + +### 边界情况 + +- [ ] **TC-20** updated_at 缺失时降级到 journals/created_at +- [ ] **TC-21** 超大 journals(100+ 条)不超时 +- [ ] **TC-22** 标题含 emoji 正常处理 +- [ ] **TC-23** 跨语言评论正常识别 +- [ ] **TC-24** 仓库无 stale 标签时优雅提示 + +### 错误处理 + +- [ ] **TC-25** HTTP 401 → 提示重新登录 +- [ ] **TC-26** HTTP 403 → 提示权限不足 +- [ ] **TC-27** HTTP 404 → 跳过并记录 +- [ ] **TC-28** 网络错误 → 重试或停止 +- [ ] **TC-29** API 限流(429)→ 退避 + +### 性能 + +- [ ] **TC-30** 单 Issue 分析 < 10s +- [ ] **TC-31** 20 Issue 批量分析 < 3 分钟 +- [ ] **TC-32** 应用 20 Issue < 1 分钟 +- [ ] **TC-33** 无 API 限流(429) + +--- + +## 📝 测试报告模板 + +完成测试后,填写以下报告(保存到 `doc\stale-test-result-.md`): + +```markdown +# gitlink-stale 测试报告 + +**测试日期**: YYYY-MM-DD +**测试者**: +**测试仓库**: / +**Agent 平台**: Claude Code v +**操作系统**: Windows + PowerShell + +## 测试结果 + +| 类别 | 总数 | 通过 | 失败 | +|------|------|------|------| +| 基础功能 | 8 | ? | ? | +| 安全规则 | 6 | ? | ? | +| 应用与回滚 | 5 | ? | ? | +| 边界情况 | 5 | ? | ? | +| 错误处理 | 5 | ? | ? | +| 性能 | 4 | ? | ? | +| **总计** | **33** | **?** | **?** | + +## 关键发现 + +(记录测试中观察到的问题或亮点) + +## AI 判断准确率 + +- 真僵尸识别准确率:?% +- 误关率:?% +- 漏关率:?% + +## 截图证据 + +(附 Claude Code 对话截图、GitLink 网页字段变更截图) + +## 结论 + +- [ ] 生产就绪 +- [ ] 需要修复后再测 +- [ ] 严重问题,重新设计 +``` + +--- + +## 🚨 常见测试陷阱 + +### 陷阱 1:在公开仓库测试污染 + +❌ **错误做法**:直接在 `Gitlink/forgeplus` 等公开仓库测试写操作。 + +✅ **正确做法**:使用自己的测试仓库(建议私有)。 + +### 陷阱 2:忘记 dry-run 导致 Issue 被关 + +❌ **错误做法**:直接让 Claude 应用,结果发现误关。 + +✅ **正确做法**:始终先要求"只生成报告",确认后再应用。 + +### 陷阱 3:PR 二次过滤缺失 + +❌ **错误做法**:信任 `pr +list --state open` 的过滤,把 merged PR 也纳入候选。 + +✅ **正确做法**:客户端按 `pull_request_status == 0` 二次过滤。 + +### 陷阱 4:备份文件被覆盖 + +❌ **错误做法**:所有备份都写到 `$env:TEMP\before.json`,多次测试后丢失。 + +✅ **正确做法**:备份文件名加时间戳: +```powershell +$ts = Get-Date -Format "yyyyMMddHHmmss" +.\gitlink-cli.exe issue +list ... | + Out-File -Encoding utf8 "$env:TEMP\before-stale-$ts.json" +``` + +### 陷阱 5:测试后忘记清理 stale 标签 + +❌ **错误做法**:测试 Issue 留着 stale 标签,下次扫描会再次处理。 + +✅ **正确做法**:测试结束后回滚(label-remove)或关闭测试 Issue。 + +### 陷阱 6:PowerShell 执行策略阻止脚本 + +❌ **错误做法**:直接 `.\test-stale.ps1` 报"无法加载,未签名"。 + +✅ **正确做法**:放行当前会话执行策略: +```powershell +Set-ExecutionPolicy -Scope Process -ExecutionPolicy Bypass +``` + +### 陷阱 7:忘记 `.\` 前缀 + +❌ **错误做法**:在项目根目录下输入 `gitlink-cli version`,报"未识别命令"。 + +✅ **正确做法**:PowerShell 不从当前目录加载命令,必须 `.\gitlink-cli.exe version`。 + +### 陷阱 8:stale 阈值设置过严 + +❌ **错误做法**:用默认 60 天阈值,测试仓库的所有 Issue 都未达阈值。 + +✅ **正确做法**:快速测试时传 `--stale-days 1`,或在 AI 提示词中明确说"用 1 天阈值"。 + +--- + +## 🎯 推荐测试顺序 + +``` +1. 准备环境(5 分钟) + ↓ +2. 命令行冒烟测试(10 分钟)—— 方法 2 + 方法 4 脚本 + ↓ +3. Claude Code 单 Issue 测试(5 分钟)—— 方法 1 Step 1-2 + ↓ +4. Claude Code 批量测试(15 分钟)—— 方法 1 Step 3-5 + ↓ +5. 安全规则测试(5 分钟)—— 方法 1 Step 4 + ↓ +6. 边界情况测试(15 分钟)—— 方法 3 + ↓ +7. AI 判断测试(10 分钟)—— 方法 1 Step 7 + ↓ +8. 回滚测试(5 分钟)—— 方法 1 Step 6 + ↓ +9. 填写测试报告(10 分钟) +``` + +**总耗时**:约 80 分钟 + +--- + +## 📞 测试支持 + +遇到问题时: + +1. **查阅文档**: + - [SKILL.md](./SKILL.md) — 工作流总览 + - [references/gitlink-stale-scan.md](./references/gitlink-stale-scan.md) — 扫描算法 + - [references/gitlink-stale-judge.md](./references/gitlink-stale-judge.md) — AI 判断规则 + - [references/gitlink-stale-actions.md](./references/gitlink-stale-actions.md) — 应用手册 + - [references/gitlink-stale-exempt.md](./references/gitlink-stale-exempt.md) — 豁免规则 + +2. **查阅示例**: + - [examples/weekly-cleanup-workflow.md](./examples/weekly-cleanup-workflow.md) — 完整工作流 + - [examples/pr-stale-workflow.md](./examples/pr-stale-workflow.md) — PR 处理 + - [examples/ai-judgment-demo.md](./examples/ai-judgment-demo.md) — AI 判断演示 + +3. **运行自动化脚本**: + - 见本文档 §方法 4 + +4. **直接询问 Claude Code**: + ``` + 我在测试 gitlink-stale 时遇到 <具体问题>,请帮我诊断。 + ``` + +--- + +## ✅ 通过标准 + +测试要算"通过",必须满足: + +- [ ] **33 个测试用例**全部通过(或失败项有合理的 workaround) +- [ ] **无安全规则违反**(dry-run 被绕过、未确认就写入等) +- [ ] **白名单豁免有效**(pinned/security/roadmap Issue 永不处理) +- [ ] **AI 判断准确率 ≥ 80%**(20+ Issue 上测试) +- [ ] **Claude Code 集成可用**(自然语言指令能触发完整工作流) +- [ ] **测试报告完整填写**(含截图证据) + +达到以上标准即可认为是生产就绪的 Skill。 diff --git a/skills/gitlink-webhook/SKILL.md b/skills/gitlink-webhook/SKILL.md new file mode 100644 index 0000000..d75035f --- /dev/null +++ b/skills/gitlink-webhook/SKILL.md @@ -0,0 +1,233 @@ +--- +name: gitlink-webhook +version: 1.0.0 +description: "Webhook 管理:创建、查看、更新、删除、测试 Webhook,配置自动化触发器。当用户需要配置 GitLink 仓库的 Webhook 自动化通知时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli webhook --help" +--- + +# gitlink-webhook(Webhook 操作) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) 了解认证和全局参数。 + +## Shortcuts + +| Shortcut | 说明 | 需要认证 | +|----------|------|----------| +| `webhook +list` | 列出仓库的所有 Webhook | 是 | +| `webhook +create` | 创建新 Webhook | 是 | +| `webhook +update` | 更新 Webhook 配置 | 是 | +| `webhook +delete` | 删除 Webhook | 是 | +| `webhook +test` | 测试 Webhook 推送(发送 ping 事件) | 是 | +| `webhook +info` | 查看 Webhook 详情 | 是 | +| `webhook +events` | 列出所有支持的事件类型 | 否 | + +## 支持的事件类型 + +| 事件类型 | 说明 | 触发时机 | +|----------|------|----------| +| `push` | 代码推送事件 | 向仓库推送代码时 | +| `pull_request` | Pull 请求事件 | 创建、更新、关闭 PR 时 | +| `issue` | Issue 事件 | 创建、更新、关闭 Issue 时 | +| `issue_assign` | Issue 指派事件 | Issue 被指派给用户时 | +| `issue_comment` | Issue 评论事件 | Issue 添加评论时 | +| `pull_request_assign` | PR 指派事件 | PR 被指派给审查者时 | +| `pull_request_comment` | PR 评论事件 | PR 添加评论时 | +| `merge_request` | 合并请求事件 | PR 被合并时 | +| `repository` | 仓库事件 | 仓库设置变更时 | +| `branch` | 分支事件 | 创建或删除分支时 | +| `tag` | 标签事件 | 创建或删除标签时 | + +## 使用示例 + +### 基本操作 + +```bash +# 列出仓库的所有 Webhook +gitlink-cli webhook +list --owner myuser --repo myrepo + +# 查看 Webhook 详情 +gitlink-cli webhook +info --owner myuser --repo myrepo --id 123 + +# 列出所有支持的事件类型 +gitlink-cli webhook +events +``` + +### 创建 Webhook + +```bash +# 创建基本的 Webhook(仅监听 push 事件) +gitlink-cli webhook +create --owner myuser --repo myrepo --url https://example.com/webhook + +# 创建多事件 Webhook +gitlink-cli webhook +create --owner myuser --repo myrepo --url https://example.com/webhook --events push,pull_request,issue + +# 创建带密钥的 Webhook +gitlink-cli webhook +create --owner myuser --repo myrepo --url https://example.com/webhook --secret my-secret-key --events push + +# 创建带描述的 Webhook +gitlink-cli webhook +create --owner myuser --repo myrepo --url https://example.com/webhook --description "CI/CD automation" +``` + +### 更新 Webhook + +```bash +# 更新 Webhook URL +gitlink-cli webhook +update --owner myuser --repo myrepo --id 123 --url https://new-url.com/webhook + +# 更新监听事件 +gitlink-cli webhook +update --owner myuser --repo myrepo --id 123 --events push,pull_request + +# 激活/停用 Webhook +gitlink-cli webhook +update --owner myuser --repo myrepo --id 123 --active false + +# 更新多个属性 +gitlink-cli webhook +update --owner myuser --repo myrepo --id 123 --url https://new-url.com/webhook --events push,pull_request --secret new-secret +``` + +### 测试和删除 + +```bash +# 测试 Webhook(发送 ping 事件) +gitlink-cli webhook +test --owner myuser --repo myrepo --id 123 + +# 测试特定事件类型 +gitlink-cli webhook +test --owner myuser --repo myrepo --id 123 --event push + +# 删除 Webhook +gitlink-cli webhook +delete --owner myuser --repo myrepo --id 123 +``` + +## 典型使用场景 + +### 场景1: 配置 CI/CD 自动化 + +```bash +# 为 CI/CD 系统创建 Webhook +gitlink-cli webhook +create \ + --owner myuser --repo myrepo \ + --url https://ci.example.com/gitlink/webhook \ + --events push,pull_request \ + --secret ci-secret-key \ + --description "Trigger CI/CD pipeline" +``` + +### 场景2: 配置 Issue 通知 + +```bash +# 创建 Issue 通知 Webhook +gitlink-cli webhook +create \ + --owner myuser --repo myrepo \ + --url https://notification.example.com/issues \ + --events issue,issue_comment,issue_assign \ + --description "Issue notifications" +``` + +### 场景3: 配置 PR 审查通知 + +```bash +# 创建 PR 审查 Webhook +gitlink-cli webhook +create \ + --owner myuser --repo myrepo \ + --url https://review.example.com/prs \ + --events pull_request,pull_request_comment,pull_request_assign \ + --description "PR review notifications" +``` + +## 错误处理 + +### 常见错误 + +#### 1. 认证错误 +```bash +Error: [401] Authentication failed +``` +**解决方案**: 运行 `gitlink-cli auth login` 重新认证 + +#### 2. 权限不足 +```bash +Error: [403] You are not authorized to manage webhooks +``` +**解决方案**: 确认您对仓库有管理员权限 + +#### 3. 无效的事件类型 +```bash +Error: no valid events specified +``` +**解决方案**: 使用 `gitlink-cli webhook +events` 查看支持的事件类型 + +#### 4. Webhook 不存在 +```bash +Error: [404] Webhook not found +``` +**解决方案**: 使用 `gitlink-cli webhook +list` 确认 Webhook ID 是否正确 + +## AI Agent 使用指南 + +### 检查现有 Webhook +```bash +# 1. 列出所有 Webhook +gitlink-cli webhook +list --owner $OWNER --repo $REPO --format json + +# 2. 检查是否有特定类型的 Webhook +gitlink-cli webhook +list --owner $OWNER --repo $REPO --format json | jq '.data.webhooks[] | select(.hook_url | contains("ci-system"))' +``` + +### 创建 Webhook 的最佳实践 +```bash +# 1. 先查看支持的事件 +gitlink-cli webhook +events + +# 2. 创建 Webhook 并验证 +gitlink-cli webhook +create --owner $OWNER --repo $REPO --url $URL --events $EVENTS + +# 3. 测试 Webhook 是否正常工作 +gitlink-cli webhook +test --owner $OWNER --repo $REPO --id $WEBHOOK_ID +``` + +### 安全建议 +- **使用密钥**: 为 Webhook 设置密钥以验证请求来源 +- **HTTPS**: 始终使用 HTTPS URL 作为 Webhook 回调地址 +- **最小权限**: 只监听必要的事件类型 +- **定期轮换**: 定期更新 Webhook 密钥 + +## 参考文档 + +- [`webhook-list.md`](references/webhook-list.md) - 列出 Webhook 详细说明 +- [`webhook-create.md`](references/webhook-create.md) - 创建 Webhook 详细说明 +- [`webhook-update.md`](references/webhook-update.md) - 更新 Webhook 详细说明 +- [`webhook-delete.md`](references/webhook-delete.md) - 删除 Webhook 详细说明 +- [`webhook-test.md`](references/webhook-test.md) - 测试 Webhook 详细说明 +- [`webhook-info.md`](references/webhook-info.md) - 查看 Webhook 详细说明 +- [`examples/webhook-workflow.md`](examples/webhook-workflow.md) - 完整工作流示例 + +## 注意事项 + +1. **API 限制**: GitLink 对 Webhook 数量有限制,通常每个仓库不超过 20 个 +2. **URL 要求**: Webhook URL 必须是公网可访问的 HTTPS 地址 +3. **超时设置**: Webhook 请求超时时间为 10 秒 +4. **重试机制**: GitLink 会在失败时重试 3 次,间隔分别为 1s、5s、10s +5. **事件顺序**: 同一事件的多个 Webhook 按创建顺序依次触发 +6. **测试限制**: 测试 Webhook 不会触发实际的业务逻辑,仅验证连通性 + +## 故障排除 + +### Webhook 未触发 +1. 检查 Webhook 是否激活:`gitlink-cli webhook +info --id --active true` +2. 验证事件类型是否正确:`gitlink-cli webhook +info --id ` +3. 测试 Webhook 连通性:`gitlink-cli webhook +test --id ` + +### Webhook 响应异常 +1. 检查回调服务器是否正常运行 +2. 验证 Webhook URL 是否可访问 +3. 查看 GitLink 服务器日志确认请求是否发送 + +### 权限问题 +1. 确认当前用户是仓库管理员或所有者 +2. 检查 Token 是否有足够权限:`gitlink-cli auth status` diff --git a/skills/gitlink-webhook/examples/webhook-workflow.md b/skills/gitlink-webhook/examples/webhook-workflow.md new file mode 100644 index 0000000..bcb1b61 --- /dev/null +++ b/skills/gitlink-webhook/examples/webhook-workflow.md @@ -0,0 +1,694 @@ +# Webhook 完整工作流示例 + +本文档提供了 GitLink Webhook 的完整使用场景和最佳实践示例。 + +## 目录 + +- [场景1: CI/CD 自动化](#场景1-cicd-自动化) +- [场景2: Issue 和 PR 通知](#场景2-issue-和-pr-通知) +- [场景3: 多环境部署](#场景3-多环境部署) +- [场景4: Webhook 迁移](#场景4-webhook-迁移) +- [场景5: 故障排查](#场景5-故障排查) +- [场景6: 安全最佳实践](#场景6-安全最佳实践) + +--- + +## 场景1: CI/CD 自动化 + +### 目标 +为 Jenkins CI/CD 系统配置 Webhook,实现代码推送时自动触发构建。 + +### 完整流程 + +```bash +#!/bin/bash +# cicd-webhook-setup.sh + +PROJECT_OWNER="mycompany" +PROJECT_REPO="main-app" +JENKINS_URL="https://jenkins.example.com/gitlink-webhook" +WEBHOOK_SECRET="jenkins-secret-key-2024" + +echo "=== Setting up CI/CD Webhook for $PROJECT_OWNER/$PROJECT_REPO ===" + +# 1. 检查是否已存在 CI/CD Webhook +echo "1. Checking existing webhooks..." +existing=$(gitlink-cli webhook +list \ + --owner $PROJECT_OWNER \ + --repo $PROJECT_REPO \ + --format json | \ + jq -r ".data.webhooks[] | select(.hook_url | contains(\"jenkins\")) | .id") + +if [ -n "$existing" ]; then + echo "Found existing CI/CD webhook: $existing" + read -p "Delete existing webhook? (y/n) " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + gitlink-cli webhook +delete --owner $PROJECT_OWNER --repo $PROJECT_REPO --id $existing + echo "Existing webhook deleted" + else + echo "Aborting setup" + exit 1 + fi +fi + +# 2. 创建新的 Webhook +echo "2. Creating new CI/CD webhook..." +WEBHOOK_INFO=$(gitlink-cli webhook +create \ + --owner $PROJECT_OWNER \ + --repo $PROJECT_REPO \ + --url "$JENKINS_URL" \ + --events push,pull_request \ + --secret "$WEBHOOK_SECRET" \ + --description "Jenkins CI/CD automation" \ + --format json) + +if [ $? -eq 0 ]; then + WEBHOOK_ID=$(echo $WEBHOOK_INFO | jq -r '.data.id') + echo "✓ Webhook created successfully: $WEBHOOK_ID" +else + echo "✗ Failed to create webhook" + exit 1 +fi + +# 3. 测试 Webhook +echo "3. Testing webhook..." +if gitlink-cli webhook +test --owner $PROJECT_OWNER --repo $PROJECT_REPO --id $WEBHOOK_ID; then + echo "✓ Webhook test successful" +else + echo "⚠ Webhook test failed, please check Jenkins server" + read -p "Continue anyway? (y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + gitlink-cli webhook +delete --id $WEBHOOK_ID + echo "Webhook deleted due to test failure" + exit 1 + fi +fi + +# 4. 验证配置 +echo "4. Verifying configuration..." +gitlink-cli webhook +info --owner $PROJECT_OWNER --repo $PROJECT_REPO --id $WEBHOOK_ID + +echo "=== CI/CD Webhook Setup Complete ===" +echo "Webhook ID: $WEBHOOK_ID" +echo "Jenkins URL: $JENKINS_URL" +echo "Events: push, pull_request" +``` + +### 使用说明 + +```bash +# 1. 设置脚本权限 +chmod +x cicd-webhook-setup.sh + +# 2. 运行脚本 +./cicd-webhook-setup.sh + +# 3. 验证 Webhook 是否正常工作 +# 在 Jenkins 中检查是否收到 Webhook 事件 +``` + +--- + +## 场景2: Issue 和 PR 通知 + +### 目标 +配置 Slack 通知,在 Issue 和 PR 活动时发送消息到团队频道。 + +### 完整流程 + +```bash +#!/bin/bash +# notification-webhook-setup.sh + +PROJECT_OWNER="myteam" +PROJECT_REPO="project-x" +SLACK_WEBHOOK_URL="https://hooks.slack.com/services/YOUR/WEBHOOK/URL" + +echo "=== Setting up Notification Webhooks ===" + +# Issue 通知 Webhook +echo "1. Creating Issue notification webhook..." +ISSUE_WEBHOOK_ID=$(gitlink-cli webhook +create \ + --owner $PROJECT_OWNER \ + --repo $PROJECT_REPO \ + --url "$SLACK_WEBHOOK_URL" \ + --events issue,issue_comment,issue_assign \ + --description "Issue notifications to #dev-team" \ + --format json | jq -r '.data.id') + +echo "✓ Issue webhook created: $ISSUE_WEBHOOK_ID" + +# PR 通知 Webhook +echo "2. Creating PR notification webhook..." +PR_WEBHOOK_ID=$(gitlink-cli webhook +create \ + --owner $PROJECT_OWNER \ + --repo $PROJECT_REPO \ + --url "$SLACK_WEBHOOK_URL" \ + --events pull_request,pull_request_comment,pull_request_assign \ + --description "PR notifications to #dev-team" \ + --format json | jq -r '.data.id') + +echo "✓ PR webhook created: $PR_WEBHOOK_ID" + +# 测试两个 Webhook +echo "3. Testing webhooks..." +gitlink-cli webhook +test --id $ISSUE_WEBHOOK_ID --event issue +gitlink-cli webhook +test --id $PR_WEBHOOK_ID --event pull_request + +# 查看配置 +echo "4. Webhook summary:" +echo "Issue Webhook: $ISSUE_WEBHOOK_ID" +gitlink-cli webhook +info --id $ISSUE_WEBHOOK_ID +echo +echo "PR Webhook: $PR_WEBHOOK_ID" +gitlink-cli webhook +info --id $PR_WEBHOOK_ID + +echo "=== Notification Setup Complete ===" +``` + +### 多团队通知 + +```bash +#!/bin/bash +# multi-team-notifications.sh + +# 为不同团队配置不同的通知 +declare -A TEAM_WEBHOOKS=( + ["dev-team"]="https://hooks.slack.com/services/DEV/TEAM/WEBHOOK" + ["ops-team"]="https://hooks.slack.com/services/OPS/TEAM/WEBHOOK" + ["security-team"]="https://hooks.slack.com/services/SECURITY/TEAM/WEBHOOK" +) + +for team in "${!TEAM_WEBHOOKS[@]}"; do + webhook_url="${TEAM_WEBHOOKS[$team]}" + + echo "Setting up webhook for $team..." + + gitlink-cli webhook +create \ + --owner $PROJECT_OWNER \ + --repo $PROJECT_REPO \ + --url "$webhook_url" \ + --events push,pull_request,issue \ + --description "Notifications for #$team" +done +``` + +--- + +## 场景3: 多环境部署 + +### 目标 +为不同环境(开发、测试、生产)配置独立的 Webhook。 + +### 完整流程 + +```bash +#!/bin/bash +# multi-env-webhook-setup.sh + +PROJECT_OWNER="mycompany" +PROJECT_REPO="main-app" + +# 环境配置 +declare -A ENVIRONMENTS=( + ["development"]="https://ci-dev.example.com/webhook" + ["testing"]="https://ci-test.example.com/webhook" + ["production"]="https:ci-prod.example.com/webhook" +) + +# 为每个环境创建 Webhook +for env in "${!ENVIRONMENTS[@]}"; do + webhook_url="${ENVIRONMENTS[$env]}" + secret="${env}-secret-$(date +%Y%m%d)" + + echo "=== Setting up $env environment webhook ===" + + # 创建 Webhook + webhook_id=$(gitlink-cli webhook +create \ + --owner $PROJECT_OWNER \ + --repo $PROJECT_REPO \ + --url "$webhook_url" \ + --events push,pull_request \ + --secret "$secret" \ + --description "$env environment CI/CD" \ + --format json | jq -r '.data.id') + + echo "✓ $env webhook created: $webhook_id" + + # 根据环境设置不同的激活状态 + if [ "$env" = "production" ]; then + # 生产环境默认激活 + echo "Production webhook is active" + else + # 其他环境暂时停用,需要时手动激活 + gitlink-cli webhook +update --id $webhook_id --active false + echo "$env webhook created but inactive (activate manually when needed)" + fi + + echo +done + +echo "=== Multi-environment setup complete ===" +echo "Review created webhooks:" +gitlink-cli webhook +list +``` + +### 环境切换 + +```bash +#!/bin/bash +# switch-active-environment.sh + +# 切换激活的环境 +TARGET_ENV=$1 + +if [ -z "$TARGET_ENV" ]; then + echo "Usage: $0 " + echo "Available environments: development, testing, production" + exit 1 +fi + +echo "=== Switching to $TARGET_ENV environment ===" + +# 停用所有环境 Webhook +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + webhook_url=$(gitlink-cli webhook +info --id $id --format json | jq -r '.data.hook_url') + + if [[ "$webhook_url" == *"ci-"* ]]; then + echo "Deactivating webhook $id..." + gitlink-cli webhook +update --id $id --active false + fi +done + +# 激活目标环境 Webhook +target_webhook_id=$(gitlink-cli webhook +list --format json | \ + jq -r ".data.webhooks[] | select(.hook_url | contains(\"$TARGET_ENV\")) | .id") + +if [ -n "$target_webhook_id" ]; then + echo "Activating $TARGET_ENV webhook: $target_webhook_id" + gitlink-cli webhook +update --id $target_webhook_id --active true + + # 测试激活的 Webhook + gitlink-cli webhook +test --id $target_webhook_id + + echo "✓ Switched to $TARGET_ENV environment" +else + echo "✗ No webhook found for $TARGET_ENV environment" + exit 1 +fi +``` + +--- + +## 场景4: Webhook 迁移 + +### 目标 +将 Webhook 从旧服务器迁移到新服务器。 + +### 完整流程 + +```bash +#!/bin/bash +# webhook-migration.sh + +OLD_SERVER="old-ci.example.com" +NEW_SERVER="new-ci.example.com" +PROJECT_OWNER="mycompany" +PROJECT_REPO="main-app" + +echo "=== Webhook Migration: $OLD_SERVER → $NEW_SERVER ===" + +# 1. 查找需要迁移的 Webhook +echo "1. Finding webhooks to migrate..." +webhooks_to_migrate=$(gitlink-cli webhook +list \ + --owner $PROJECT_OWNER \ + --repo $PROJECT_REPO \ + --format json | \ + jq -r ".data.webhooks[] | select(.hook_url | contains(\"$OLD_SERVER\"))") + +webhook_count=$(echo "$webhooks_to_migrate" | jq -r '. | length') + +if [ "$webhook_count" -eq 0 ]; then + echo "No webhooks found for $OLD_SERVER" + exit 0 +fi + +echo "Found $webhook_count webhook(s) to migrate" + +# 2. 为每个 Webhook 创建迁移记录 +echo "$webhooks_to_migrate" | jq -c '.[]' | while read -r webhook; do + old_id=$(echo $webhook | jq -r '.id') + old_url=$(echo $webhook | jq -r '.hook_url') + events=$(echo $webhook | jq -r '.events | join(",")') + description=$(echo $webhook | jq -r '.description') + + # 生成新 URL + new_url=$(echo $old_url | sed "s/$OLD_SERVER/$NEW_SERVER/g") + + echo "=== Migrating webhook $old_id ===" + echo "Old URL: $old_url" + echo "New URL: $new_url" + echo "Events: $events" + + # 创建新 Webhook + echo "Creating new webhook..." + new_id=$(gitlink-cli webhook +create \ + --owner $PROJECT_OWNER \ + --repo $PROJECT_REPO \ + --url "$new_url" \ + --events "$events" \ + --description "$description (migrated)" \ + --format json | jq -r '.data.id') + + if [ $? -eq 0 ]; then + echo "✓ New webhook created: $new_id" + + # 测试新 Webhook + echo "Testing new webhook..." + if gitlink-cli webhook +test --id $new_id; then + echo "✓ New webhook test successful" + + # 备份旧 Webhook 配置 + echo "$webhook" > "webhook_backup_${old_id}.json" + + # 删除旧 Webhook + echo "Deleting old webhook: $old_id" + gitlink-cli webhook +delete --id $old_id + + echo "✓ Migration complete for webhook $old_id" + else + echo "⚠ New webhook test failed, keeping old webhook" + gitlink-cli webhook +delete --id $new_id + fi + else + echo "✗ Failed to create new webhook" + fi + + echo +done + +echo "=== Migration Complete ===" +echo "Current webhooks:" +gitlink-cli webhook +list --owner $PROJECT_OWNER --repo $PROJECT_REPO +``` + +### 回滚迁移 + +```bash +#!/bin/bash +# rollback-migration.sh + +echo "=== Webhook Migration Rollback ===" + +# 从备份文件恢复 Webhook +for backup_file in webhook_backup_*.json; do + old_id=$(echo $backup_file | sed 's/webhook_backup_\([0-9]*\)\.json/\1/') + + echo "Restoring webhook: $old_id" + + # 读取备份配置 + webhook_config=$(cat "$backup_file") + old_url=$(echo $webhook_config | jq -r '.hook_url') + events=$(echo $webhook_config | jq -r '.events | join(",")') + description=$(echo $webhook_config | jq -r '.description') + + # 重新创建 Webhook + restored_id=$(gitlink-cli webhook +create \ + --url "$old_url" \ + --events "$events" \ + --description "$description (restored)" \ + --format json | jq -r '.data.id') + + echo "✓ Webhook restored: $restored_id" +done + +echo "=== Rollback Complete ===" +``` + +--- + +## 场景5: 故障排查 + +### 目标 +诊断和修复 Webhook 问题。 + +### 故障排查脚本 + +```bash +#!/bin/bash +# webhook-troubleshooting.sh + +WEBHOOK_ID=$1 + +if [ -z "$WEBHOOK_ID" ]; then + echo "Usage: $0 " + exit 1 +fi + +echo "=== Webhook Troubleshooting for ID: $WEBHOOK_ID ===" +echo + +# 1. 检查 Webhook 是否存在 +echo "1. Checking webhook existence..." +if ! gitlink-cli webhook +info --id $WEBHOOK_ID >/dev/null 2>&1; then + echo "✗ Webhook not found" + echo "Available webhooks:" + gitlink-cli webhook +list + exit 1 +fi +echo "✓ Webhook exists" + +# 2. 获取 Webhook 详细信息 +echo "2. Webhook configuration:" +webhook_info=$(gitlink-cli webhook +info --id $WEBHOOK_ID --format json) +echo "$webhook_info" | jq -r '.data | { + URL: .hook_url, + Active: .is_active, + Events: .events | join(", "), + "Last Delivery": .last_delivery.timestamp, + "Success Rate": (.delivery_statistics.success_rate // "N/A") +}' + +# 3. 检查 Webhook 是否激活 +is_active=$(echo $webhook_info | jq -r '.data.is_active') +if [ "$is_active" != "true" ]; then + echo "⚠ Webhook is not active" + read -p "Activate webhook now? (y/n) " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + gitlink-cli webhook +update --id $WEBHOOK_ID --active true + echo "✓ Webhook activated" + fi +fi + +# 4. 测试网络连通性 +echo "3. Testing network connectivity..." +webhook_url=$(echo $webhook_info | jq -r '.data.hook_url') +if curl -s -o /dev/null -w "%{http_code}" "$webhook_url" | grep -q "200\|301\|302"; then + echo "✓ URL is accessible (HTTP $(curl -s -o /dev/null -w "%{http_code}" "$webhook_url"))" +else + echo "✗ URL is not accessible" + echo "Testing with curl:" + curl -v "$webhook_url" 2>&1 | head -20 +fi + +# 5. 测试 Webhook +echo "4. Testing webhook delivery..." +if gitlink-cli webhook +test --id $WEBHOOK_ID; then + echo "✓ Webhook test successful" +else + echo "✗ Webhook test failed" + echo "Common issues:" + echo " - URL is not reachable" + echo " - Server is not responding" + echo " - Firewall blocking requests" + echo " - SSL certificate issues" +fi + +# 6. 检查成功率 +echo "5. Checking delivery statistics..." +success_rate=$(echo $webhook_info | jq -r '.data.delivery_statistics.success_rate // "N/A"') +if [ "$success_rate" != "N/A" ]; then + if (( $(echo "$success_rate < 90" | bc -l) )); then + echo "⚠ Low success rate: $success_rate%" + echo "Recommendation: Check webhook server logs for errors" + else + echo "✓ Good success rate: $success_rate%" + fi +else + echo "No delivery statistics available (webhook may be new)" +fi + +# 7. 诊断建议 +echo "6. Troubleshooting recommendations:" +echo " - Check webhook server logs: tail -f /var/log/webhook-server.log" +echo " - Test webhook URL manually: curl -X POST $webhook_url" +echo " - Verify SSL certificate: openssl s_client -connect $(echo $webhook_url | sed 's/https:\/\///' | sed 's/:443//')" + +echo "=== Troubleshooting Complete ===" +``` + +### 常见问题解决 + +```bash +#!/bin/bash +# common-webhook-fixes.sh + +# 问题1: Webhook 未触发 +fix_inactive_webhook() { + WEBHOOK_ID=$1 + echo "Fixing inactive webhook: $WEBHOOK_ID" + gitlink-cli webhook +update --id $WEBHOOK_ID --active true + gitlink-cli webhook +test --id $WEBHOOK_ID +} + +# 问题2: URL 配置错误 +fix_webhook_url() { + WEBHOOK_ID=$1 + CORRECT_URL=$2 + echo "Fixing webhook URL for: $WEBHOOK_ID" + gitlink-cli webhook +update --id $WEBHOOK_ID --url "$CORRECT_URL" + gitlink-cli webhook +test --id $WEBHOOK_ID +} + +# 问题3: 事件配置不完整 +fix_webhook_events() { + WEBHOOK_ID=$1 + DESIRED_EVENTS=$2 + echo "Updating webhook events for: $WEBHOOK_ID" + gitlink-cli webhook +update --id $WEBHOOK_ID --events "$DESIRED_EVENTS" +} + +# 问题4: 密钥过期 +rotate_webhook_secret() { + WEBHOOK_ID=$1 + NEW_SECRET=$(openssl rand -hex 32) + echo "Rotating secret for webhook: $WEBHOOK_ID" + gitlink-cli webhook +update --id $WEBHOOK_ID --secret "$NEW_SECRET" + echo "New secret: $NEW_SECRET" + echo "Please update the receiving server with the new secret" +} +``` + +--- + +## 场景6: 安全最佳实践 + +### 目标 +确保 Webhook 配置符合安全最佳实践。 + +### 安全配置检查 + +```bash +#!/bin/bash +# webhook-security-audit.sh + +echo "=== Webhook Security Audit ===" + +# 1. 检查所有 Webhook 是否使用 HTTPS +echo "1. Checking HTTPS usage..." +insecure_count=0 +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + url=$(gitlink-cli webhook +info --id $id --format json | jq -r '.data.hook_url') + if [[ ! $url =~ ^https:// ]]; then + echo "⚠ Insecure URL found: $url (webhook $id)" + insecure_count=$((insecure_count + 1)) + fi +done +if [ $insecure_count -eq 0 ]; then + echo "✓ All webhooks use HTTPS" +else + echo "✗ Found $insecure_count webhook(s) using non-HTTPS URLs" +fi + +# 2. 检查是否设置了密钥 +echo "2. Checking secret usage..." +no_secret_count=0 +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + has_secret=$(gitlink-cli webhook +info --id $id --format json | jq -r '.data.has_secret // false') + if [ "$has_secret" = "false" ]; then + echo "⚠ Webhook without secret: $id" + no_secret_count=$((no_secret_count + 1)) + fi +done +if [ $no_secret_count -eq 0 ]; then + echo "✓ All webhooks have secrets configured" +else + echo "⚠ $no_secret_count webhook(s) without secrets" +fi + +# 3. 检查 Webhook 数量 +echo "3. Checking webhook count..." +webhook_count=$(gitlink-cli webhook +list --format json | jq -r '.data.total_count') +if [ $webhook_count -gt 15 ]; then + echo "⚠ High webhook count: $webhook_count (consider cleanup)" +else + echo "✓ Reasonable webhook count: $webhook_count" +fi + +# 4. 检查不活跃的 Webhook +echo "4. Checking inactive webhooks..." +inactive_count=$(gitlink-cli webhook +list --format json | jq -r '[.data.webhooks[] | select(.is_active == false)] | length') +if [ $inactive_count -gt 0 ]; then + echo "⚠ Found $inactive_count inactive webhook(s)" + echo "Consider removing inactive webhooks:" + gitlink-cli webhook +list --format json | jq -r '.data.webhooks[] | select(.is_active == false) | "\(.id): \(.hook_url)"' +else + echo "✓ All webhooks are active" +fi + +echo "=== Security Audit Complete ===" +``` + +### 安全加固脚本 + +```bash +#!/bin/bash +# webhook-security-hardening.sh + +echo "=== Webhook Security Hardening ===" + +# 1. 为所有 Webhook 添加密钥 +echo "1. Adding secrets to webhooks without them..." +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + has_secret=$(gitlink-cli webhook +info --id $id --format json | jq -r '.data.has_secret // false') + if [ "$has_secret" = "false" ]; then + echo "Adding secret to webhook $id..." + new_secret=$(openssl rand -hex 32) + gitlink-cli webhook +update --id $id --secret "$new_secret" + echo "✓ Secret added. Save this secret: $new_secret" + fi +done + +# 2. 停用不必要的 Webhook +echo "2. Reviewing webhooks for necessity..." +gitlink-cli webhook +list --format json | jq -r '.data.webhooks[] | "\(.id): \(.description"' | while read -r webhook; do + echo "Webhook: $webhook" + read -p "Is this webhook still needed? (y/n) " -n 1 -r + echo + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + webhook_id=$(echo $webhook | cut -d':' -f1) + gitlink-cli webhook +delete --id $webhook_id + echo "✓ Webhook deleted" + fi +done + +echo "=== Security Hardening Complete ===" +``` + +--- + +## 总结 + +这些工作流示例涵盖了 Webhook 管理的主要场景: + +1. **CI/CD 自动化** - 配置持续集成/部署 +2. **通知系统** - Issue 和 PR 消息通知 +3. **多环境部署** - 为不同环境配置独立 Webhook +4. **Webhook 迁移** - 安全地迁移 Webhook 配置 +5. **故障排查** - 诊断和修复 Webhook 问题 +6. **安全最佳实践** - 确保 Webhook 配置安全 + +使用这些示例作为起点,根据您的具体需求进行调整和扩展。 diff --git a/skills/gitlink-webhook/references/webhook-create.md b/skills/gitlink-webhook/references/webhook-create.md new file mode 100644 index 0000000..2fb8951 --- /dev/null +++ b/skills/gitlink-webhook/references/webhook-create.md @@ -0,0 +1,274 @@ +# gitlink-cli webhook +create + +创建新的 Webhook,用于自动化通知和集成。 + +## 命令格式 + +```bash +gitlink-cli webhook +create \ + --owner OWNER \ + --repo REPO \ + --url URL \ + [--events EVENTS] \ + [--active ACTIVE] \ + [--content_type CONTENT_TYPE] \ + [--secret SECRET] \ + [--description DESCRIPTION] +``` + +## 参数说明 + +| 参数 | 短参数 | 说明 | 是否必须 | 默认值 | +|------|--------|------|----------|--------| +| `--owner` | `-o` | 仓库所有者 | 否 | 自动从 git remote 解析 | +| `--repo` | `-r` | 仓库名称 | 否 | 自动从 git remote 解析 | +| `--url` | `-u` | Webhook 回调 URL | **是** | - | +| `--events` | `-e` | 触发事件(逗号分隔) | 否 | `push` | +| `--active` | - | 是否激活 | 否 | `true` | +| `--content_type` | - | 内容类型 | 否 | `json` | +| `--secret` | - | HMAC 验证密钥 | 否 | 空 | +| `--description` | `-d` | Webhook 描述 | 否 | 空 | + +### 事件类型 +支持的事件类型(多个事件用逗号分隔): +- `push` - 代码推送 +- `pull_request` - Pull 请求 +- `issue` - Issue 事件 +- `issue_assign` - Issue 指派 +- `issue_comment` - Issue 评论 +- `pull_request_assign` - PR 指派 +- `pull_request_comment` - PR 评论 +- `merge_request` - 合并请求 +- `repository` - 仓库事件 +- `branch` - 分支事件 +- `tag` - 标签事件 + +## 返回值 + +### 成功返回 +```json +{ + "ok": true, + "data": { + "id": "456", + "hook_url": "https://example.com/webhook", + "events": ["push", "pull_request"], + "is_active": true, + "content_type": "json", + "description": "CI/CD webhook", + "created_at": "2024-01-01T00:00:00Z", + "project": { + "owner": "myuser", + "repo": "myrepo" + } + }, + "meta": { + "identity": "user:myuser" + } +} +``` + +### 错误返回 +```json +{ + "ok": false, + "error": { + "code": 400, + "message": "Invalid webhook URL", + "suggestion": "Please provide a valid HTTPS URL" + } +} +``` + +## 使用示例 + +### 基本 Webhook +```bash +# 创建最简单的 Webhook(仅监听 push 事件) +gitlink-cli webhook +create --owner myuser --repo myrepo --url https://example.com/webhook + +# 在 git 仓库目录中创建(自动解析 owner/repo) +gitlink-cli webhook +create --url https://example.com/webhook +``` + +### 多事件 Webhook +```bash +# 监听多个事件 +gitlink-cli webhook +create \ + --owner myuser --repo myrepo \ + --url https://ci.example.com/webhook \ + --events push,pull_request,issue + +# 监听所有 PR 相关事件 +gitlink-cli webhook +create \ + --url https://review.example.com/webhook \ + --events pull_request,pull_request_assign,pull_request_comment +``` + +### 带密钥的 Webhook +```bash +# 创建带 HMAC 验证密钥的 Webhook +gitlink-cli webhook +create \ + --url https://ci.example.com/webhook \ + --events push \ + --secret my-secret-key-12345 + +# CI/CD 系统的 Webhook(推荐) +gitlink-cli webhook +create \ + --url https://jenkins.example.com/gitlink-webhook \ + --events push,pull_request \ + --secret jenkins-webhook-secret \ + --description "Jenkins CI trigger" +``` + +### 带描述的 Webhook +```bash +# 创建带描述的 Webhook +gitlink-cli webhook +create \ + --url https://notification.example.com/webhook \ + --events issue,issue_comment \ + --description "Issue notifications to Slack" +``` + +### 不激活的 Webhook +```bash +# 创建 Webhook 但暂时不激活 +gitlink-cli webhook +create \ + --url https://example.com/webhook \ + --events push \ + --active false \ + --description "Webhook for testing" +``` + +### 不同内容类型 +```bash +# JSON 格式(默认) +gitlink-cli webhook +create --url https://example.com/webhook --content-type json + +# Form 格式 +gitlink-cli webhook +create --url https://example.com/webhook --content-type form +``` + +## 错误处理 + +### 常见错误 + +#### 1. URL 无效 +```bash +Error: Invalid webhook URL format +``` +**原因**: URL 格式不正确或不是 HTTPS +**解决方案**: +```bash +# 使用 HTTPS URL +gitlink-cli webhook +create --url https://example.com/webhook +``` + +#### 2. 无效的事件类型 +```bash +Error: no valid events specified. Supported events: push, pull_request, issue, ... +``` +**原因**: 指定了不支持的事件类型 +**解决方案**: +```bash +# 查看支持的事件 +gitlink-cli webhook +events + +# 使用正确的事件类型 +gitlink-cli webhook +create --url https://example.com/webhook --events push,pull_request +``` + +#### 3. 权限不足 +```bash +Error: [403] You don't have permission to create webhooks +``` +**原因**: 用户不是仓库管理员 +**解决方案**: 确认您有仓库管理员权限 + +#### 4. Webhook 数量超限 +```bash +Error: [400] Webhook limit reached (maximum 20 webhooks per repository) +``` +**原因**: 仓库的 Webhook 数量已达上限 +**解决方案**: +```bash +# 删除不需要的 Webhook +gitlink-cli webhook +delete --id +``` + +## 最佳实践 + +### 1. 安全性 +```bash +# 始终为 Webhook 设置密钥 +gitlink-cli webhook +create \ + --url https://ci.example.com/webhook \ + --events push \ + --secret $(openssl rand -hex 32) + +# 使用 HTTPS URL +gitlink-cli webhook +create --url https://example.com/webhook +``` + +### 2. 事件选择 +```bash +# 只监听必要的事件 +gitlink-cli webhook +create \ + --url https://ci.example.com/webhook \ + --events push # CI 只需要 push 事件 +``` + +### 3. 描述清晰 +```bash +# 添加清晰的描述便于管理 +gitlink-cli webhook +create \ + --url https://jenkins.example.com/webhook \ + --events push,pull_request \ + --description "Production CI - Jenkins Pipeline" +``` + +## AI Agent 使用建议 + +### 验证 Webhook 创建 +```bash +# 创建后立即测试 +WEBHOOK_ID=$(gitlink-cli webhook +create --url $URL --events $EVENTS --format json | jq -r '.data.id') +gitlink-cli webhook +test --id $WEBHOOK_ID + +# 验证 Webhook 配置 +gitlink-cli webhook +info --id $WEBHOOK_ID +``` + +### 检查重复 Webhook +```bash +# 检查是否已存在相同 URL 的 Webhook +existing=$(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[] | select(.hook_url == "https://example.com/webhook") | .id') +if [ -n "$existing" ]; then + echo "Webhook already exists: $existing" +else + gitlink-cli webhook +create --url https://example.com/webhook +fi +``` + +## 安全建议 + +1. **使用密钥**: 始终设置 `--secret` 参数以验证请求来源 +2. **HTTPS**: 确保使用 HTTPS URL 保护数据传输 +3. **最小权限**: 只监听必要的事件类型 +4. **定期轮换**: 定期更新 Webhook 密钥 +5. **监控日志**: 监控 Webhook 请求日志以发现异常活动 + +## 注意事项 + +1. **URL 要求**: Webhook URL 必须是公网可访问的 HTTPS 地址 +2. **数量限制**: 每个仓库最多 20 个 Webhook +3. **权限要求**: 需要仓库管理员权限 +4. **事件格式**: 多个事件用逗号分隔,不要有空格 +5. **立即生效**: 创建后立即可用,除非设置 `--active false` + +## 相关命令 + +- `webhook +list` - 列出所有 Webhook +- `webhook +update` - 更新 Webhook 配置 +- `webhook +test` - 测试 Webhook +- `webhook +events` - 查看支持的事件类型 diff --git a/skills/gitlink-webhook/references/webhook-delete.md b/skills/gitlink-webhook/references/webhook-delete.md new file mode 100644 index 0000000..9f77f25 --- /dev/null +++ b/skills/gitlink-webhook/references/webhook-delete.md @@ -0,0 +1,282 @@ +# gitlink-cli webhook +delete + +删除指定的 Webhook。 + +## 命令格式 + +```bash +gitlink-cli webhook +delete [--owner OWNER] [--repo REPO] --id WEBHOOK_ID +``` + +## 参数说明 + +| 参数 | 短参数 | 说明 | 是否必须 | 默认值 | +|------|--------|------|----------|--------| +| `--owner` | `-o` | 仓库所有者 | 否 | 自动从 git remote 解析 | +| `--repo` | `-r` | 仓库名称 | 否 | 自动从 git remote 解析 | +| `--id` | `-i` | Webhook ID | **是** | - | + +## 返回值 + +### 成功返回 +```json +{ + "ok": true, + "data": { + "message": "Webhook deleted successfully" + }, + "meta": { + "identity": "user:myuser" + } +} +``` + +### 错误返回 +```json +{ + "ok": false, + "error": { + "code": 404, + "message": "Webhook not found", + "suggestion": "Please check the webhook ID" + } +} +``` + +## 使用示例 + +### 基本用法 +```bash +# 删除指定 Webhook +gitlink-cli webhook +delete --owner myuser --repo myrepo --id 456 + +# 在 git 仓库目录中删除(自动解析 owner/repo) +gitlink-cli webhook +delete --id 456 + +# 使用短参数 +gitlink-cli webhook +delete -i 456 +``` + +### 删除多个 Webhook +```bash +# 批量删除多个 Webhook +for id in 123 456 789; do + gitlink-cli webhook +delete --id $id +done +``` + +### 交互式删除 +```bash +# 先查看 Webhook 详情确认 +gitlink-cli webhook +info --id 456 + +# 确认后删除 +gitlink-cli webhook +delete --id 456 +``` + +## 错误处理 + +### 常见错误 + +#### 1. Webhook 不存在 +```bash +Error: [404] Webhook not found +``` +**原因**: 指定的 Webhook ID 不存在或已被删除 +**解决方案**: +```bash +# 先列出所有 Webhook 确认 ID +gitlink-cli webhook +list +``` + +#### 2. 权限不足 +```bash +Error: [403] You don't have permission to delete webhooks +``` +**原因**: 用户不是仓库管理员 +**解决方案**: 确认您有仓库管理员权限 + +#### 3. ID 参数缺失 +```bash +Error: required flag --id is missing +``` +**原因**: 没有提供 Webhook ID +**解决方案**: 指定要删除的 Webhook ID + +## 最佳实践 + +### 1. 删除前确认 +```bash +# 删除前先查看 Webhook 详情 +WEBHOOK_ID=456 +echo "About to delete webhook:" +gitlink-cli webhook +info --id $WEBHOOK_ID + +# 确认后删除 +read -p "Confirm deletion? (y/n) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + gitlink-cli webhook +delete --id $WEBHOOK_ID +fi +``` + +### 2. 记录删除的 Webhook +```bash +# 删除前记录 Webhook 配置 +WEBHOOK_ID=456 +BACKUP_FILE="webhook_backup_$WEBHOOK_ID.json" +gitlink-cli webhook +info --id $WEBHOOK_ID --format json > $BACKUP_FILE +echo "Webhook config backed up to $BACKUP_FILE" + +# 然后删除 +gitlink-cli webhook +delete --id $WEBHOOK_ID +``` + +### 3. 批量清理不活跃的 Webhook +```bash +# 列出所有不活跃的 Webhook 并删除 +inactive_webhooks=$(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[] | select(.is_active == false) | .id') +for id in $inactive_webhooks; do + echo "Deleting inactive webhook: $id" + gitlink-cli webhook +delete --id $id +done +``` + +## AI Agent 使用建议 + +### 安全删除流程 +```bash +# AI Agent 删除 Webhook 的安全流程 +delete_webhook_safely() { + WEBHOOK_ID=$1 + + # 1. 检查 Webhook 是否存在 + if ! gitlink-cli webhook +info --id $WEBHOOK_ID --format json >/dev/null 2>&1; then + echo "Webhook $WEBHOOK_ID not found" + return 1 + fi + + # 2. 备份配置 + gitlink-cli webhook +info --id $WEBHOOK_ID --format json > "webhook_backup_$WEBHOOK_ID.json" + + # 3. 删除 Webhook + if gitlink-cli webhook +delete --id $WEBHOOK_ID; then + echo "Webhook $WEBHOOK_ID deleted successfully" + return 0 + else + echo "Failed to delete webhook $WEBHOOK_ID" + return 1 + fi +} +``` + +### 批量删除 Webhook +```bash +# 删除所有匹配特定条件的 Webhook +delete_webhooks_by_url() { + URL_PATTERN=$1 + + # 找到匹配的 Webhook + webhook_ids=$(gitlink-cli webhook +list --format json | \ + jq -r ".data.webhooks[] | select(.hook_url | contains(\"$URL_PATTERN\")) | .id") + + # 逐个删除 + for id in $webhook_ids; do + echo "Deleting webhook $id with URL matching $URL_PATTERN" + gitlink-cli webhook +delete --id $id + done +} + +# 使用示例:删除所有指向旧服务器的 Webhook +delete_webhooks_by_url "old-server.example.com" +``` + +### 验证删除 +```bash +# 删除 Webhook 并验证 +WEBHOOK_ID=456 + +# 删除前检查 +if gitlink-cli webhook +info --id $WEBHOOK_ID >/dev/null 2>&1; then + echo "Webhook exists, deleting..." + gitlink-cli webhook +delete --id $WEBHOOK_ID + + # 验证删除成功 + if ! gitlink-cli webhook +info --id $WEBHOOK_ID >/dev/null 2>&1; then + echo "Webhook deleted successfully" + else + echo "Webhook still exists after deletion" + fi +else + echo "Webhook not found" +fi +``` + +## 注意事项 + +1. **不可恢复**: 删除操作不可逆,请谨慎操作 +2. **立即生效**: 删除后立即停止接收事件 +3. **权限要求**: 需要仓库管理员权限 +4. **API 特性**: GitLink API 在删除时可能返回错误信息,但实际删除成功 +5. **验证删除**: 建议删除后验证 Webhook 是否已删除 + +## 常见使用场景 + +### 场景1: 清理测试 Webhook +```bash +# 删除所有测试环境的 Webhook +test_webhooks=$(gitlink-cli webhook +list --format json | \ + jq -r '.data.webhooks[] | select(.description | contains("test")) | .id') + +for id in $test_webhooks; do + echo "Deleting test webhook: $id" + gitlink-cli webhook +delete --id $id +done +``` + +### 场景2: 迁移到新 URL +```bash +# 迁移 Webhook 到新 URL +OLD_WEBHOOK_ID=456 +OLD_URL=$(gitlink-cli webhook +info --id $OLD_WEBHOOK_ID --format json | jq -r '.data.hook_url') +NEW_URL="https://new-server.example.com/webhook" + +# 创建新 Webhook +NEW_WEBHOOK_ID=$(gitlink-cli webhook +create --url $NEW_URL --events push --format json | jq -r '.data.id') + +# 测试新 Webhook +gitlink-cli webhook +test --id $NEW_WEBHOOK_ID + +# 确认新 Webhook 工作后删除旧 Webhook +gitlink-cli webhook +delete --id $OLD_WEBHOOK_ID +``` + +### 场景3: 批量重构 Webhook +```bash +# 重构所有 Webhook,重新创建后删除旧的 +# 1. 备份现有配置 +gitlink-cli webhook +list --format json > webhook_config_backup.json + +# 2. 根据备份创建新配置(可能使用不同的 URL 或事件) + +# 3. 删除旧的 Webhook +old_ids=$(jq -r '.data.webhooks[].id' webhook_config_backup.json) +for id in $old_ids; do + gitlink-cli webhook +delete --id $id +done +``` + +## 安全建议 + +1. **删除前备份**: 删除前备份 Webhook 配置 +2. **确认操作**: 删除前确认 Webhook ID 和配置 +3. **逐步删除**: 批量删除时逐步进行,避免误删 +4. **验证删除**: 删除后验证 Webhook 已被删除 +5. **权限控制**: 限制删除权限给授权用户 + +## 相关命令 + +- `webhook +list` - 列出所有 Webhook +- `webhook +info` - 查看 Webhook 详情 +- `webhook +create` - 创建新 Webhook +- `webhook +update` - 更新 Webhook(可以先用 `--active false` 停用) diff --git a/skills/gitlink-webhook/references/webhook-info.md b/skills/gitlink-webhook/references/webhook-info.md new file mode 100644 index 0000000..16833f4 --- /dev/null +++ b/skills/gitlink-webhook/references/webhook-info.md @@ -0,0 +1,382 @@ +# gitlink-cli webhook +info + +查看指定 Webhook 的详细信息。 + +## 命令格式 + +```bash +gitlink-cli webhook +info [--owner OWNER] [--repo REPO] --id WEBHOOK_ID +``` + +## 参数说明 + +| 参数 | 短参数 | 说明 | 是否必须 | 默认值 | +|------|--------|------|----------|--------| +| `--owner` | `-o` | 仓库所有者 | 否 | 自动从 git remote 解析 | +| `--repo` | `-r` | 仓库名称 | 否 | 自动从 git remote 解析 | +| `--id` | `-i` | Webhook ID | **是** | - | + +## 返回值 + +### 成功返回 +```json +{ + "ok": true, + "data": { + "id": "456", + "hook_url": "https://ci.example.com/webhook", + "events": ["push", "pull_request", "issue"], + "is_active": true, + "content_type": "json", + "description": "CI/CD automation webhook", + "project": { + "owner": "myuser", + "repo": "myrepo", + "identifier": "myuser/myrepo" + }, + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-15T12:30:00Z", + "last_delivery": { + "timestamp": "2024-01-15T14:25:00Z", + "status": "success", + "event": "push", + "duration_ms": 245 + }, + "delivery_statistics": { + "total_deliveries": 1523, + "successful_deliveries": 1498, + "failed_deliveries": 25, + "success_rate": 98.36 + } + }, + "meta": { + "identity": "user:myuser" + } +} +``` + +### 错误返回 +```json +{ + "ok": false, + "error": { + "code": 404, + "message": "Webhook not found", + "suggestion": "Please check the webhook ID" + } +} +``` + +## 使用示例 + +### 基本用法 +```bash +# 查看 Webhook 详情 +gitlink-cli webhook +info --owner myuser --repo myrepo --id 456 + +# 在 git 仓库目录中查看(自动解析 owner/repo) +gitlink-cli webhook +info --id 456 + +# 使用短参数 +gitlink-cli webhook +info -i 456 +``` + +### 不同输出格式 +```bash +# JSON 格式(默认,便于解析) +gitlink-cli webhook +info --id 456 --format json + +# Table 格式(更易阅读) +gitlink-cli webhook +info --id 456 --format table + +# YAML 格式 +gitlink-cli webhook +info --id 456 --format yaml +``` + +### 提取特定信息 +```bash +# 使用 jq 提取 Webhook URL +gitlink-cli webhook +info --id 456 --format json | jq -r '.data.hook_url' + +# 查看 Webhook 是否激活 +gitlink-cli webhook +info --id 456 --format json | jq -r '.data.is_active' + +# 查看监听的事件类型 +gitlink-cli webhook +info --id 456 --format json | jq -r '.data.events[]' + +# 查看统计信息 +gitlink-cli webhook +info --id 456 --format json | jq '.data.delivery_statistics' +``` + +### 比较两个 Webhook +```bash +# 比较两个 Webhook 的配置 +echo "=== Webhook 456 ===" +gitlink-cli webhook +info --id 456 + +echo "=== Webhook 789 ===" +gitlink-cli webhook +info --id 789 +``` + +## 错误处理 + +### 常见错误 + +#### 1. Webhook 不存在 +```bash +Error: [404] Webhook not found +``` +**原因**: 指定的 Webhook ID 不存在 +**解决方案**: +```bash +# 先列出所有 Webhook 找到正确 ID +gitlink-cli webhook +list +``` + +#### 2. 权限不足 +```bash +Error: [403] You don't have permission to view webhook details +``` +**原因**: 用户没有仓库访问权限 +**解决方案**: 确认您是仓库成员 + +#### 3. ID 参数缺失 +```bash +Error: required flag --id is missing +``` +**原因**: 没有提供 Webhook ID +**解决方案**: 指定要查看的 Webhook ID + +## 最佳实践 + +### 1. 更新前查看 +```bash +# 更新 Webhook 前先查看当前配置 +WEBHOOK_ID=456 +echo "Current configuration:" +gitlink-cli webhook +info --id $WEBHOOK_ID + +# 然后进行更新 +gitlink-cli webhook +update --id $WEBHOOK_ID --url $NEW_URL +``` + +### 2. 批量查看 Webhook 信息 +```bash +# 查看所有 Webhook 的简要信息 +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + echo "=== Webhook $id ===" + gitlink-cli webhook +info --id $WEBHOOK_ID --format json | jq -r '.data | "\(.hook_url) - \(.description)"' +done +``` + +### 3. 验证 Webhook 配置 +```bash +# 检查 Webhook 是否正确配置 +check_webhook_config() { + WEBHOOK_ID=$1 + + # 获取 Webhook 信息 + info=$(gitlink-cli webhook +info --id $WEBHOOK_ID --format json) + + # 检查是否激活 + is_active=$(echo $info | jq -r '.data.is_active') + if [ "$is_active" != "true" ]; then + echo "WARNING: Webhook is not active" + fi + + # 检查是否有事件 + events=$(echo $info | jq -r '.data.events | length') + if [ "$events" -eq 0 ]; then + echo "WARNING: No events configured" + fi + + # 检查 URL 是否有效 + url=$(echo $info | jq -r '.data.hook_url') + if [[ ! $url =~ ^https:// ]]; then + echo "WARNING: URL does not use HTTPS" + fi + + # 显示成功率 + success_rate=$(echo $info | jq -r '.data.delivery_statistics.success_rate') + echo "Success rate: $success_rate%" +} +``` + +## AI Agent 使用建议 + +### 自动化 Webhook 配置检查 +```bash +# AI Agent 检查 Webhook 配置的自动化脚本 +analyze_webhook() { + WEBHOOK_ID=$1 + OUTPUT_FORMAT="${2:-json}" + + info=$(gitlink-cli webhook +info --id $WEBHOOK_ID --format $OUTPUT_FORMAT) + + if [ "$OUTPUT_FORMAT" = "json" ]; then + # JSON 格式便于解析 + echo "$info" | jq '.data | { + id, + url: .hook_url, + active: .is_active, + events: .events, + success_rate: .delivery_statistics.success_rate, + last_delivery: .last_delivery.timestamp + }' + else + # 其他格式直接输出 + echo "$info" + fi +} + +# 批量分析所有 Webhook +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + analyze_webhook $id +done +``` + +### Webhook 健康检查 +```bash +# 检查 Webhook 健康状态 +check_webhook_health() { + WEBHOOK_ID=$1 + + info=$(gitlink-cli webhook +info --id $WEBHOOK_ID --format json) + + # 提取关键指标 + is_active=$(echo $info | jq -r '.data.is_active') + success_rate=$(echo $info | jq -r '.data.delivery_statistics.success_rate') + last_delivery=$(echo $info | jq -r '.data.last_delivery.timestamp') + + # 健康评分 + health_score=100 + issues=() + + if [ "$is_active" != "true" ]; then + health_score=$((health_score - 50)) + issues+=("Webhook is not active") + fi + + if (( $(echo "$success_rate < 95" | bc -l) )); then + health_score=$((health_score - 30)) + issues+=("Success rate below 95%: $success_rate%") + fi + + if [ -z "$last_delivery" ] || [ "$last_delivery" = "null" ]; then + health_score=$((health_score - 20)) + issues+=("No recent deliveries") + fi + + # 输出结果 + echo "Webhook $WEBHOOK_ID Health Check" + echo "Health Score: $health_score/100" + if [ ${#issues[@]} -gt 0 ]; then + echo "Issues found:" + printf '%s\n' "${issues[@]}" + else + echo "✓ Webhook is healthy" + fi +} +``` + +### 配置差异分析 +```bash +# 比较两个 Webhook 的配置差异 +compare_webhooks() { + ID1=$1 + ID2=$2 + + info1=$(gitlink-cli webhook +info --id $ID1 --format json) + info2=$(gitlink-cli webhook +info --id $ID2 --format json) + + echo "=== Webhook Comparison ===" + echo "Webhook 1: $ID1" + echo "Webhook 2: $ID2" + echo + + # 比较 URL + url1=$(echo $info1 | jq -r '.data.hook_url') + url2=$(echo $info2 | jq -r '.data.hook_url') + echo "URL:" + echo " $ID1: $url1" + echo " $ID2: $url2" + [ "$url1" = "$url2" ] && echo " Status: Same" || echo " Status: Different" + echo + + # 比较事件 + events1=$(echo $info1 | jq -r '.data.events | sort | join(",")') + events2=$(echo $info2 | jq -r '.data.events | sort | join(",")') + echo "Events:" + echo " $ID1: $events1" + echo " $ID2: $events2" + [ "$events1" = "$events2" ] && echo " Status: Same" || echo " Status: Different" + echo + + # 比较激活状态 + active1=$(echo $info1 | jq -r '.data.is_active') + active2=$(echo $info2 | jq -r '.data.is_active') + echo "Active Status:" + echo " $ID1: $active1" + echo " $ID2: $active2" + [ "$active1" = "$active2" ] && echo " Status: Same" || echo " Status: Different" +} +``` + +## 注意事项 + +1. **权限要求**: 至少需要仓库读取权限 +2. **详细信息**: 包含 Webhook 的所有配置和统计信息 +3. **统计数据**: 部分统计信息可能为空,特别是新创建的 Webhook +4. **时间格式**: 所有时间戳均为 ISO 8601 格式(UTC) +5. **敏感信息**: 输出可能包含敏感信息,注意保护 + +## 常见使用场景 + +### 场景1: 确认 Webhook 配置 +```bash +# 确认 Webhook 配置是否正确 +gitlink-cli webhook +info --id 456 + +# 检查关键配置 +gitlink-cli webhook +info --id 456 --format json | jq -r '{ + url: .data.hook_url, + events: .data.events, + active: .data.is_active, + success_rate: .data.delivery_statistics.success_rate +}' +``` + +### 场景2: 故障排查 +```bash +# Webhook 出问题时查看详细信息 +gitlink-cli webhook +info --id 456 + +# 检查最近一次投递情况 +gitlink-cli webhook +info --id 456 --format json | jq '.data.last_delivery' + +# 查看失败统计 +gitlink-cli webhook +info --id 456 --format json | jq '.data.delivery_statistics' +``` + +### 场景3: 配置审计 +```bash +# 审计所有 Webhook 配置 +echo "=== Webhook Configuration Audit ===" +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + echo "Webhook $id:" + gitlink-cli webhook +info --id $id --format json | jq -r '{ + url: .data.hook_url, + events: .data.events | join(","), + active: .data.is_active, + description: .description + }' + echo +done +``` + +## 相关命令 + +- `webhook +list` - 列出所有 Webhook +- `webhook +create` - 创建新 Webhook +- `webhook +update` - 更新 Webhook 配置 +- `webhook +test` - 测试 Webhook diff --git a/skills/gitlink-webhook/references/webhook-list.md b/skills/gitlink-webhook/references/webhook-list.md new file mode 100644 index 0000000..e08e7c0 --- /dev/null +++ b/skills/gitlink-webhook/references/webhook-list.md @@ -0,0 +1,162 @@ +# gitlink-cli webhook +list + +列出仓库的所有 Webhook。 + +## 命令格式 + +```bash +gitlink-cli webhook +list [--owner OWNER] [--repo REPO] [--page PAGE] [--limit LIMIT] +``` + +## 参数说明 + +| 参数 | 短参数 | 说明 | 是否必须 | 默认值 | +|------|--------|------|----------|--------| +| `--owner` | `-o` | 仓库所有者 | 否 | 自动从 git remote 解析 | +| `--repo` | `-r` | 仓库名称 | 否 | 自动从 git remote 解析 | +| `--page` | `-p` | 页码 | 否 | 1 | +| `--limit` | `-l` | 每页数量 | 否 | 20 | + +## 返回值 + +### 成功返回 +```json +{ + "ok": true, + "data": { + "webhooks": [ + { + "id": "123", + "hook_url": "https://example.com/webhook", + "events": ["push", "pull_request"], + "is_active": true, + "content_type": "json", + "description": "CI/CD webhook", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + } + ], + "total_count": 5, + "page": 1, + "limit": 20 + }, + "meta": { + "page": 1, + "limit": 20, + "total_count": 5 + } +} +``` + +### 错误返回 +```json +{ + "ok": false, + "error": { + "code": 401, + "message": "Authentication failed", + "suggestion": "Please run 'gitlink-cli auth login' to authenticate" + } +} +``` + +## 使用示例 + +### 基本用法 +```bash +# 列出当前仓库的 Webhook(需要在 git 仓库目录中) +gitlink-cli webhook +list + +# 列出指定仓库的 Webhook +gitlink-cli webhook +list --owner myuser --repo myrepo + +# 分页显示 +gitlink-cli webhook +list --owner myuser --repo myrepo --page 2 --limit 10 +``` + +### JSON 格式输出(AI Agent 使用) +```bash +# 获取 JSON 格式输出便于解析 +gitlink-cli webhook +list --owner myuser --repo myrepo --format json + +# 使用 jq 处理输出 +gitlink-cli webhook +list --format json | jq '.data.webhooks[] | select(.is_active == true)' + +# 统计 Webhook 数量 +gitlink-cli webhook +list --format json | jq '.data.total_count' +``` + +### Table 格式输出 +```bash +# 表格格式更易阅读(默认) +gitlink-cli webhook +list --format table + +# 指定表格格式 +gitlink-cli webhook +list --owner myuser --repo myrepo --format table +``` + +## 错误处理 + +### 常见错误 + +#### 1. 认证失败 +```bash +Error: [401] Authentication failed +``` +**原因**: Token 过期或无效 +**解决方案**: +```bash +gitlink-cli auth login +``` + +#### 2. 权限不足 +```bash +Error: [403] You don't have permission to view webhooks +``` +**原因**: 用户没有仓库访问权限 +**解决方案**: 确认您是仓库成员或公开项目 + +#### 3. 仓库不存在 +```bash +Error: [404] Repository not found +``` +**原因**: 仓库名称或所有者错误 +**解决方案**: 使用 `gitlink-cli repo +list` 确认仓库名称 + +## AI Agent 使用建议 + +### 检查 Webhook 配置 +```bash +# 检查是否已配置特定类型的 Webhook +gitlink-cli webhook +list --format json | jq '.data.webhooks[] | select(.hook_url | contains("ci-system"))' + +# 检查是否有激活的 Webhook +gitlink-cli webhook +list --format json | jq '.data.webhooks[] | select(.is_active == true)' + +# 获取所有 Webhook 的 URL +gitlink-cli webhook +list --format json | jq '.data.webhooks[].hook_url' +``` + +### 批量操作 +```bash +# 获取所有 Webhook ID +webhook_ids=$(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id') + +# 批量测试所有 Webhook +for id in $webhook_ids; do + gitlink-cli webhook +test --id $id +done +``` + +## 注意事项 + +1. **分页查询**: 默认每页显示 20 个,使用 `--limit` 可调整 +2. **权限要求**: 至少需要仓库读取权限 +3. **自动解析**: 在 git 仓库目录中可省略 `--owner` 和 `--repo` +4. **格式选择**: AI Agent 建议使用 `--format json` 便于解析 + +## 相关命令 + +- `webhook +create` - 创建新 Webhook +- `webhook +info` - 查看特定 Webhook 详情 +- `webhook +events` - 查看支持的事件类型 diff --git a/skills/gitlink-webhook/references/webhook-test.md b/skills/gitlink-webhook/references/webhook-test.md new file mode 100644 index 0000000..eec922c --- /dev/null +++ b/skills/gitlink-webhook/references/webhook-test.md @@ -0,0 +1,378 @@ +# gitlink-cli webhook +test + +测试 Webhook 连接性,发送测试事件验证 Webhook 是否正常工作。 + +## 命令格式 + +```bash +gitlink-cli webhook +test \ + [--owner OWNER] \ + [--repo REPO] \ + --id WEBHOOK_ID \ + [--event EVENT_TYPE] +``` + +## 参数说明 + +| 参数 | 短参数 | 说明 | 是否必须 | 默认值 | +|------|--------|------|----------|--------| +| `--owner` | `-o` | 仓库所有者 | 否 | 自动从 git remote 解析 | +| `--repo` | `-r` | 仓库名称 | 否 | 自动从 git remote 解析 | +| `--id` | `-i` | Webhook ID | **是** | - | +| `--event` | `-e` | 测试的事件类型 | 否 | `push` | + +### 支持的测试事件 +- `push` - 推送事件(默认) +- `pull_request` - Pull 请求事件 +- `issue` - Issue 事件 +- 其他支持的事件类型 + +## 返回值 + +### 成功返回 +```json +{ + "ok": true, + "data": { + "message": "Webhook test triggered successfully", + "webhook_id": "456", + "event_type": "push", + "delivered": true, + "response_status": 200, + "response_body": "Webhook received" + }, + "meta": { + "identity": "user:myuser" + } +} +``` + +### Webhook 不可达 +```json +{ + "ok": true, + "data": { + "message": "Webhook test completed with warnings", + "webhook_id": "456", + "event_type": "push", + "delivered": false, + "error": "Connection timeout", + "suggestion": "Please check if the webhook URL is accessible" + } +} +``` + +### 错误返回 +```json +{ + "ok": false, + "error": { + "code": 404, + "message": "Webhook not found", + "suggestion": "Please check the webhook ID" + } +} +``` + +## 使用示例 + +### 基本测试 +```bash +# 测试 Webhook(默认使用 push 事件) +gitlink-cli webhook +test --owner myuser --repo myrepo --id 456 + +# 在 git 仓库目录中测试 +gitlink-cli webhook +test --id 456 + +# 使用短参数 +gitlink-cli webhook +test -i 456 +``` + +### 测试特定事件类型 +```bash +# 测试 pull_request 事件 +gitlink-cli webhook +test --id 456 --event pull_request + +# 测试 issue 事件 +gitlink-cli webhook +test --id 456 --event issue + +# 测试多种事件类型 +for event in push pull_request issue; do + echo "Testing event: $event" + gitlink-cli webhook +test --id 456 --event $event +done +``` + +### 批量测试所有 Webhook +```bash +# 测试仓库的所有 Webhook +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + echo "Testing webhook: $id" + gitlink-cli webhook +test --id $id +done +``` + +### 测试新创建的 Webhook +```bash +# 创建后立即测试 +WEBHOOK_ID=$(gitlink-cli webhook +create --url https://example.com/webhook --events push --format json | jq -r '.data.id') +echo "Testing new webhook: $WEBHOOK_ID" +gitlink-cli webhook +test --id $WEBHOOK_ID +``` + +## 错误处理 + +### 常见错误 + +#### 1. Webhook 不存在 +```bash +Error: [404] Webhook not found +``` +**原因**: 指定的 Webhook ID 不存在 +**解决方案**: +```bash +# 先列出所有 Webhook 找到正确 ID +gitlink-cli webhook +list +``` + +#### 2. 无效的事件类型 +```bash +Error: unsupported event type: custom_event +``` +**原因**: 指定了不支持的事件类型 +**解决方案**: +```bash +# 查看支持的事件类型 +gitlink-cli webhook +events + +# 使用支持的事件类型 +gitlink-cli webhook +test --id 456 --event push +``` + +#### 3. Webhook URL 不可达 +```bash +Warning: Webhook delivery failed - Connection timeout +``` +**原因**: Webhook URL 无法访问或服务器无响应 +**解决方案**: +```bash +# 1. 检查 URL 是否正确 +gitlink-cli webhook +info --id 456 + +# 2. 手动测试 URL +curl -X POST https://your-webhook-url.com/test + +# 3. 检查服务器防火墙和网络设置 +``` + +#### 4. SSL 证书问题 +```bash +Warning: Webhook delivery failed - SSL certificate verify failed +``` +**原因**: Webhook 服务器的 SSL 证书有问题 +**解决方案**: +```bash +# 检查 SSL 证书 +curl -v https://your-webhook-url.com/test + +# 更新服务器的 SSL 证书 +``` + +## 最佳实践 + +### 1. 创建后测试 +```bash +# 创建 Webhook 后立即测试 +WEBHOOK_ID=$(gitlink-cli webhook +create --url $URL --events $EVENTS --format json | jq -r '.data.id') +if gitlink-cli webhook +test --id $WEBHOOK_ID; then + echo "Webhook created and tested successfully" +else + echo "Webhook test failed, please check configuration" + gitlink-cli webhook +delete --id $WEBHOOK_ID +fi +``` + +### 2. 更新后测试 +```bash +# 更新 Webhook 后测试 +gitlink-cli webhook +update --id 456 --url $NEW_URL +gitlink-cli webhook +test --id 456 +``` + +### 3. 定期测试 +```bash +# 定期测试所有 Webhook 确保正常工作 +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + if ! gitlink-cli webhook +test --id $id; then + echo "WARNING: Webhook $id test failed" + fi +done +``` + +## AI Agent 使用建议 + +### 自动化测试流程 +```bash +# AI Agent 测试 Webhook 的完整流程 +test_and_fix_webhook() { + WEBHOOK_ID=$1 + MAX_RETRIES=3 + RETRY_COUNT=0 + + while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + echo "Testing webhook $WEBHOOK_ID (attempt $((RETRY_COUNT + 1))/$MAX_RETRIES)" + + # 测试 Webhook + if gitlink-cli webhook +test --id $WEBHOOK_ID; then + echo "✓ Webhook test successful" + return 0 + fi + + # 测试失败,等待后重试 + RETRY_COUNT=$((RETRY_COUNT + 1)) + if [ $RETRY_COUNT -lt $MAX_RETRIES ]; then + echo "Test failed, waiting 5 seconds before retry..." + sleep 5 + fi + done + + echo "✗ Webhook test failed after $MAX_RETRIES attempts" + return 1 +} +``` + +### 监控 Webhook 健康 +```bash +# 定期检查所有 Webhook 的健康状态 +check_all_webhooks_health() { + REPORT_FILE="webhook_health_report_$(date +%Y%m%d_%H%M%S).txt" + + echo "Webhook Health Check Report - $(date)" > $REPORT_FILE + echo "=================================" >> $REPORT_FILE + + for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + webhook_info=$(gitlink-cli webhook +info --id $id --format json) + webhook_url=$(echo $webhook_info | jq -r '.data.hook_url') + webhook_status=$(echo $webhook_info | jq -r '.data.is_active') + + echo -e "\nWebhook ID: $id" >> $REPORT_FILE + echo "URL: $webhook_url" >> $REPORT_FILE + echo "Active: $webhook_status" >> $REPORT_FILE + echo "Test Result:" >> $REPORT_FILE + + if gitlink-cli webhook +test --id $id >> $REPORT_FILE 2>&1; then + echo "Status: HEALTHY ✓" >> $REPORT_FILE + else + echo "Status: UNHEALTHY ✗" >> $REPORT_FILE + fi + done + + cat $REPORT_FILE +} +``` + +### 故障诊断 +```bash +# 诊断 Webhook 问题 +diagnose_webhook() { + WEBHOOK_ID=$1 + + echo "=== Webhook Diagnosis ===" + echo "Webhook ID: $WEBHOOK_ID" + echo + + # 1. 检查 Webhook 是否存在 + echo "1. Checking webhook existence..." + if ! gitlink-cli webhook +info --id $WEBHOOK_ID >/dev/null 2>&1; then + echo " ✗ Webhook not found" + return 1 + fi + echo " ✓ Webhook exists" + + # 2. 获取 Webhook 配置 + echo "2. Webhook configuration:" + gitlink-cli webhook +info --id $WEBHOOK_ID + + # 3. 测试网络连通性 + echo "3. Testing network connectivity..." + WEBHOOK_URL=$(gitlink-cli webhook +info --id $WEBHOOK_ID --format json | jq -r '.data.hook_url') + if curl -s -o /dev/null -w "%{http_code}" "$WEBHOOK_URL" | grep -q "200\|301\|302"; then + echo " ✓ URL is accessible" + else + echo " ✗ URL is not accessible" + fi + + # 4. 测试 Webhook + echo "4. Testing webhook delivery..." + if gitlink-cli webhook +test --id $WEBHOOK_ID; then + echo " ✓ Webhook test successful" + else + echo " ✗ Webhook test failed" + fi +} +``` + +## 注意事项 + +1. **测试限制**: 测试事件不会触发实际的业务逻辑,仅验证连通性 +2. **请求格式**: 测试请求的格式与真实事件略有不同 +3. **响应时间**: Webhook 应在 10 秒内响应,否则超时 +4. **重试机制**: 测试失败不会触发 GitLink 的重试机制 +5. **权限要求**: 需要仓库管理员权限 + +## 常见使用场景 + +### 场景1: 验证新 Webhook +```bash +# 创建 Webhook 后验证配置 +WEBHOOK_ID=$(gitlink-cli webhook +create \ + --url https://ci.example.com/webhook \ + --events push,pull_request \ + --format json | jq -r '.data.id') + +# 测试各种事件类型 +for event in push pull_request; do + echo "Testing $event event..." + gitlink-cli webhook +test --id $WEBHOOK_ID --event $event +done +``` + +### 场景2: 故障排查 +```bash +# Webhook 未触发时进行测试 +# 1. 检查 Webhook 是否激活 +gitlink-cli webhook +info --id 456 + +# 2. 测试 Webhook 连通性 +gitlink-cli webhook +test --id 456 + +# 3. 查看详细错误信息 +gitlink-cli webhook +test --id 456 --debug +``` + +### 场景3: 批量验证 +```bash +# 验证所有 Webhook 在服务器迁移后是否正常 +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + echo "Testing webhook $id..." + if ! gitlink-cli webhook +test --id $id; then + echo "WARNING: Webhook $id needs attention" + # 可以在这里添加自动修复逻辑 + fi +done +``` + +## 安全建议 + +1. **避免敏感数据**: 测试事件可能包含真实数据,注意隐私保护 +2. **测试频率**: 不要过于频繁测试,避免对服务器造成压力 +3. **错误信息**: 测试失败时的错误信息可能暴露系统细节 +4. **访问控制**: 确保测试 URL 只暴露必要的信息 + +## 相关命令 + +- `webhook +list` - 列出所有 Webhook +- `webhook +info` - 查看 Webhook 详情 +- `webhook +create` - 创建新 Webhook +- `webhook +update` - 更新 Webhook 配置 +- `webhook +events` - 查看支持的事件类型 diff --git a/skills/gitlink-webhook/references/webhook-update.md b/skills/gitlink-webhook/references/webhook-update.md new file mode 100644 index 0000000..7958419 --- /dev/null +++ b/skills/gitlink-webhook/references/webhook-update.md @@ -0,0 +1,277 @@ +# gitlink-cli webhook +update + +更新现有 Webhook 的配置。 + +## 命令格式 + +```bash +gitlink-cli webhook +update \ + --owner OWNER \ + --repo REPO \ + --id WEBHOOK_ID \ + [--url URL] \ + [--events EVENTS] \ + [--active ACTIVE] \ + [--content_type CONTENT_TYPE] \ + [--secret SECRET] \ + [--description DESCRIPTION] +``` + +## 参数说明 + +| 参数 | 短参数 | 说明 | 是否必须 | 默认值 | +|------|--------|------|----------|--------| +| `--owner` | `-o` | 仓库所有者 | 否 | 自动从 git remote 解析 | +| `--repo` | `-r` | 仓库名称 | 否 | 自动从 git remote 解析 | +| `--id` | `-i` | Webhook ID | **是** | - | +| `--url` | `-u` | 新的 Webhook URL | 否 | 不修改 | +| `--events` | `-e` | 新的触发事件 | 否 | 不修改 | +| `--active` | - | 是否激活 | 否 | 不修改 | +| `--content_type` | - | 内容类型 | 否 | 不修改 | +| `--secret` | - | 新的密钥 | 否 | 不修改 | +| `--description` | `-d` | 新的描述 | 否 | 不修改 | + +## 返回值 + +### 成功返回 +```json +{ + "ok": true, + "data": { + "id": "456", + "hook_url": "https://new-url.example.com/webhook", + "events": ["push", "pull_request", "issue"], + "is_active": false, + "content_type": "json", + "description": "Updated webhook description", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-02T12:00:00Z" + }, + "meta": { + "identity": "user:myuser" + } +} +``` + +### 错误返回 +```json +{ + "ok": false, + "error": { + "code": 404, + "message": "Webhook not found", + "suggestion": "Please check the webhook ID" + } +} +``` + +## 使用示例 + +### 更新 URL +```bash +# 修改 Webhook 回调地址 +gitlink-cli webhook +update --owner myuser --repo myrepo --id 456 --url https://new-url.example.com/webhook + +# 在 git 仓库目录中更新 +gitlink-cli webhook +update --id 456 --url https://new-url.example.com/webhook +``` + +### 更新事件 +```bash +# 添加更多事件类型 +gitlink-cli webhook +update --id 456 --events push,pull_request,issue,issue_comment + +# 减少事件类型(只监听 push) +gitlink-cli webhook +update --id 456 --events push +``` + +### 激活/停用 Webhook +```bash +# 停用 Webhook +gitlink-cli webhook +update --id 456 --active false + +# 重新激活 Webhook +gitlink-cli webhook +update --id 456 --active true +``` + +### 更新密钥 +```bash +# 更新 Webhook 密钥(推荐定期轮换) +gitlink-cli webhook +update --id 456 --secret new-secret-key-2024 +``` + +### 更新描述 +```bash +# 更新 Webhook 描述 +gitlink-cli webhook +update --id 456 --description "Updated for new CI/CD pipeline" +``` + +### 批量更新多个属性 +```bash +# 同时更新多个属性 +gitlink-cli webhook +update \ + --id 456 \ + --url https://new-url.example.com/webhook \ + --events push,pull_request,issue \ + --secret new-secret \ + --description "Comprehensive webhook update" +``` + +## 错误处理 + +### 常见错误 + +#### 1. Webhook 不存在 +```bash +Error: [404] Webhook not found +``` +**原因**: 指定的 Webhook ID 不存在 +**解决方案**: +```bash +# 先列出所有 Webhook 找到正确 ID +gitlink-cli webhook +list +``` + +#### 2. 无效的事件类型 +```bash +Error: no valid events specified +``` +**原因**: 指定了不支持的事件类型 +**解决方案**: +```bash +# 查看支持的事件 +gitlink-cli webhook +events +``` + +#### 3. 权限不足 +```bash +Error: [403] You don't have permission to update webhooks +``` +**原因**: 用户不是仓库管理员 +**解决方案**: 确认您有仓库管理员权限 + +#### 4. 没有指定更新字段 +```bash +Error: no fields specified for update +``` +**原因**: 没有提供任何要更新的字段 +**解决方案**: 至少指定一个要更新的字段 + +## 最佳实践 + +### 1. 密钥轮换 +```bash +# 定期更新密钥(建议每3个月) +gitlink-cli webhook +update --id 456 --secret $(openssl rand -hex 32) +``` + +### 2. 临时停用 +```bash +# 临时停用 Webhook 进行维护 +gitlink-cli webhook +update --id 456 --active false + +# 维护完成后重新激活 +gitlink-cli webhook +update --id 456 --active true +``` + +### 3. 渐进式更新 +```bash +# 先测试新配置 +gitlink-cli webhook +update --id 456 --url https://new-url.example.com/webhook --active false +gitlink-cli webhook +test --id 456 + +# 确认无误后激活 +gitlink-cli webhook +update --id 456 --active true +``` + +## AI Agent 使用建议 + +### 批量更新 Webhook +```bash +# 为所有 Webhook 添加新事件 +for id in $(gitlink-cli webhook +list --format json | jq -r '.data.webhooks[].id'); do + # 获取当前事件 + current_events=$(gitlink-cli webhook +info --id $id --format json | jq -r '.data.events | join(",")') + # 添加新事件 + gitlink-cli webhook +update --id $id --events "$current_events,issue_comment" +done +``` + +### 验证更新 +```bash +# 更新后立即验证 +WEBHOOK_ID=456 +gitlink-cli webhook +update --id $WEBHOOK_ID --url $NEW_URL +gitlink-cli webhook +info --id $WEBHOOK_ID +gitlink-cli webhook +test --id $WEBHOOK_ID +``` + +### 检查更新前后差异 +```bash +# 查看更新前后配置差异 +BEFORE=$(gitlink-cli webhook +info --id 456 --format json) +gitlink-cli webhook +update --id 456 --url $NEW_URL +AFTER=$(gitlink-cli webhook +info --id 456 --format json) + +# 对比差异(需要 jq 工具) +echo "Before:" && echo "$BEFORE" | jq '.data' +echo "After:" && echo "$AFTER" | jq '.data' +``` + +## 安全建议 + +1. **密钥轮换**: 定期更新 Webhook 密钥,建议每3个月一次 +2. **测试新配置**: 更新重要配置前先停用,测试后再激活 +3. **备份配置**: 更新前记录原配置,便于回滚 +4. **权限验证**: 确保只有授权用户能修改 Webhook +5. **审计日志**: 记录所有 Webhook 配置变更 + +## 注意事项 + +1. **部分更新**: 只更新指定的字段,未指定的字段保持不变 +2. **ID 不变**: 更新不会改变 Webhook ID +3. **立即生效**: 更新后立即生效,除非停用 Webhook +4. **测试验证**: 建议更新后测试 Webhook 是否正常工作 +5. **权限要求**: 需要仓库管理员权限 + +## 常见使用场景 + +### 场景1: 迁移 Webhook 到新服务器 +```bash +# 更新 Webhook URL 到新服务器 +gitlink-cli webhook +update --id 456 --url https://new-server.example.com/webhook +# 测试新地址 +gitlink-cli webhook +test --id 456 +``` + +### 场景2: 调整事件监听 +```bash +# 原来只监听 push,现在增加 PR 监听 +gitlink-cli webhook +update --id 456 --events push,pull_request +``` + +### 场景3: 安全密钥轮换 +```bash +# 定期更新密钥提高安全性 +NEW_SECRET=$(openssl rand -hex 32) +gitlink-cli webhook +update --id 456 --secret "$NEW_SECRET" +# 更新接收服务器的密钥配置 +# 然后测试 +gitlink-cli webhook +test --id 456 +``` + +### 场景4: 临时维护 +```bash +# 临时停用 Webhook +gitlink-cli webhook +update --id 456 --active false --description "Maintenance in progress" + +# 维护完成后重新激活 +gitlink-cli webhook +update --id 456 --active true --description "Production webhook" +``` + +## 相关命令 + +- `webhook +list` - 列出所有 Webhook +- `webhook +create` - 创建新 Webhook +- `webhook +info` - 查看 Webhook 详情 +- `webhook +test` - 测试 Webhook diff --git a/skills/gitlink-wiki/SKILL.md b/skills/gitlink-wiki/SKILL.md new file mode 100644 index 0000000..0eb61b4 --- /dev/null +++ b/skills/gitlink-wiki/SKILL.md @@ -0,0 +1,89 @@ +--- +name: gitlink-wiki +version: 1.1.0 +description: "Wiki 管理:查看、创建、更新、删除 Wiki 页面、质量检查(lint)。当用户需要操作 GitLink Wiki 时触发。" +metadata: + requires: + bins: ["gitlink-cli"] + cliHelp: "gitlink-cli wiki --help" +--- + +# gitlink-wiki(Wiki 操作) + +**CRITICAL — 开始前必须先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md),其中包含认证、权限处理和 API 注意事项。** +**CRITICAL — 所有 Shortcuts 在执行写入/删除操作前,务必先确认用户意图。** +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。`gh` 仅适用于 GitHub 平台。** +**注意:`wiki +lint` 当前仅在本地编译版本中可用,需 `go build -o gitlink-cli.exe .` 后使用 `./gitlink-cli.exe`。** + +> **前置条件:** 先阅读 [`../gitlink-shared/SKILL.md`](../gitlink-shared/SKILL.md) + +## Shortcuts + +| Shortcut | 说明 | 需要认证 | +|----------|------|----------| +| `wiki +list` | 列出所有 Wiki 页面 | 否(公开项目) | +| `wiki +view` | 查看 Wiki 页面内容 | 否(公开项目) | +| `wiki +create` | 创建 Wiki 页面,支持 `--dry-run` 预览 | 是 | +| `wiki +update` | 更新 Wiki 页面,支持 `--dry-run` 预览 | 是 | +| `wiki +delete` | 删除 Wiki 页面,支持 `--dry-run` 预览 | 是 | +| `wiki +lint` | 检查 Wiki 页面质量问题(链接、标题、图片、空白页) | 否 | + +## 使用示例 + +```bash +# 列出所有 Wiki 页面 +gitlink-cli wiki +list --owner Gitlink --repo forgeplus + +# 查看 Wiki 页面内容(自动解码 base64 并输出 content_decoded 字段) +gitlink-cli wiki +view --owner Gitlink --repo forgeplus --title "Home" + +# 创建 Wiki 页面(从命令行内容) +gitlink-cli wiki +create --owner myuser --repo myrepo --title "设计文档" --content "# 架构设计\n\n## 概述\n..." + +# 创建 Wiki 页面(从文件) +gitlink-cli wiki +create --owner myuser --repo myrepo --title "API 文档" --file ./api-docs.md + +# 预览创建操作(不实际执行) +gitlink-cli wiki +create --owner myuser --repo myrepo --title "测试页面" --content "test" --dry-run + +# 更新 Wiki 页面(覆盖整个内容) +gitlink-cli wiki +update --owner myuser --repo myrepo --title "设计文档" --cover "# 新内容\n..." + +# 更新 Wiki 页面(追加内容) +gitlink-cli wiki +update --owner myuser --repo myrepo --title "设计文档" --add "\n\n## 新增章节\n..." + +# 更新 Wiki 页面(从文件追加) +gitlink-cli wiki +update --owner myuser --repo myrepo --title "设计文档" --file ./new-section.md --add + +# 重命名 Wiki 页面(--page 指定当前标题,--title 指定新标题) +gitlink-cli wiki +update --owner myuser --repo myrepo --page "旧标题" --title "新标题" + +# 预览更新操作 +gitlink-cli wiki +update --owner myuser --repo myrepo --title "设计文档" --cover "新内容" --dry-run + +# 删除 Wiki 页面 +gitlink-cli wiki +delete --owner myuser --repo myrepo --title "废弃页面" + +# 预览删除操作 +gitlink-cli wiki +delete --owner myuser --repo myrepo --title "废弃页面" --dry-run + +# 检查 Wiki 页面质量问题(全部检查) +gitlink-cli wiki +lint --owner myuser --repo myrepo + +# 只检查链接和空白页 +gitlink-cli wiki +lint --owner myuser --repo myrepo --check links,empty +``` + +## Wiki 页面内容格式 + +- Wiki 内容以 **base64** 编码传输,CLI 已自动处理编码/解码 +- `wiki +view` 返回结果中包含 `content_decoded` 字段(原始文本内容) +- `--content` 和 `--file` 参数接受原始文本,CLI 会自动 base64 编码后发送 + +## API 注意事项 + +- Wiki 使用**独立的 Gateway API**(`https://gateway.gitlink.org.cn/api`),不走主 API +- 所有 Wiki 操作需要先解析 `project_id`(通过主 API 的 `/{owner}/{repo}/detail` 获取) +- `project_id` 会在当前会话中缓存,避免重复请求 +- `wiki +update` 的 `--page` 参数用于指定要查找的页面标题(不指定时默认等于 `--title`) +- `wiki +delete` 有容错逻辑:如果删除接口返回错误,会二次验证页面是否已不存在 diff --git a/skills/gitlink-wiki/examples/wiki-workflow.md b/skills/gitlink-wiki/examples/wiki-workflow.md new file mode 100644 index 0000000..18138f7 --- /dev/null +++ b/skills/gitlink-wiki/examples/wiki-workflow.md @@ -0,0 +1,959 @@ +# Wiki 工作流示例 + +本文档提供了使用 `gitlink-cli wiki` 命令的完整工作流示例,涵盖从简单到复杂的各种场景。 + +## 目录 + +- [基础工作流](#基础工作流) +- [项目文档初始化](#项目文档初始化) +- [文档维护工作流](#文档维护工作流) +- [批量操作](#批量操作) +- [AI Agent 集成](#ai-agent-集成) +- [故障排除](#故障排除) + +--- + +## 基础工作流 + +### 工作流 1: 创建单个 Wiki 页面 + +**场景**: 为项目创建首页 + +```bash +#!/bin/bash +# 1. 创建首页 +gitlink-cli wiki +create --title "Home" --content '# Project Home + +## Overview +This project is a CLI tool for GitLink platform. + +## Features +- Repository management +- Issue tracking +- Pull requests + +## Documentation +- [Getting Started](Getting-Started) +- [API Reference](API-Reference) +- [Contributing](Contributing) + +## Support +- [FAQ](FAQ) +- [Contact Us](Contact-Us)' + +# 2. 验证创建结果 +gitlink-cli wiki +view --title "Home" + +# 3. 列出所有页面 +gitlink-cli wiki +list +``` + +**预期结果**: +- 创建了标题为 "Home" 的 Wiki 页面 +- 页面包含导航链接和项目概述 +- 可通过 `wiki +list` 和 `wiki +view` 验证 + +--- + +## 项目文档初始化 + +### 工作流 2: 创建完整项目文档结构 + +**场景**: 为新项目创建完整的 Wiki 文档体系 + +```bash +#!/bin/bash +# init-project-wiki.sh + +set -e # 遇到错误立即退出 + +echo "=== Initializing Project Wiki ===" + +# 1. 创建首页 +echo "Creating Home page..." +gitlink-cli wiki +create --title "Home" --content '# Project Documentation + +Welcome to the project documentation! + +## Quick Links +- 📚 [Getting Started](Getting-Started) - New user guide +- 📖 [API Reference](API-Reference) - API documentation +- 🤝 [Contributing](Contributing) - Contribution guide +- ❓ [FAQ](FAQ) - Frequently asked questions + +## Overview +This project provides a comprehensive CLI tool for GitLink platform management. + +## Status +- Version: 1.0.0 +- License: MIT +- Support: See [Contact Us](Contact-Us)' + +# 2. 创建入门指南 +echo "Creating Getting Started guide..." +gitlink-cli wiki +create --title "Getting-Started" --content '# Getting Started + +## Installation + +### Prerequisites +- Node.js 14+ +- GitLink account + +### Install via npm +\`\`\`bash +npm install -g gitlink-cli +\`\`\` + +### Install from source +\`\`\`bash +git clone https://www.gitlink.org.cn/Gitlink/gitlink-cli.git +cd gitlink-cli +make install +\`\`\` + +## Configuration + +### Initialize config +\`\`\`bash +gitlink-cli config init +\`\`\` + +### Login +\`\`\`bash +gitlink-cli auth login +\`\`\` + +## Verify Installation +\`\`\`bash +gitlink-cli --version +gitlink-cli user +me +\`\`\`' + +# 3. 创建 API 文档 +echo "Creating API Reference..." +gitlink-cli wiki +create --title "API-Reference" --content '# API Reference + +## Repository Operations + +### List repositories +\`\`\`bash +gitlink-cli repo +list +\`\`\` + +### Create repository +\`\`\`bash +gitlink-cli repo +create -n my-project -d "Project description" +\`\`\` + +## Issue Operations + +### List issues +\`\`\`bash +gitlink-cli issue +list --owner user --repo project +\`\`\` + +### Create issue +\`\`\`bash +gitlink-cli issue +create -t "Bug title" -b "Bug description" +\`\`\` + +## Pull Request Operations + +### List PRs +\`\`\`bash +gitlink-cli pr +list --owner user --repo project +\`\`\` + +### Create PR +\`\`\`bash +gitlink-cli pr +create --head feature --base main -t "Feature title" +\`\`\`' + +# 4. 创建贡献指南 +echo "Creating Contributing guide..." +gitlink-cli wiki +create --title "Contributing" --content '# Contributing + +Thank you for your interest in contributing! + +## How to Contribute + +### Report Bugs +Create an issue with the bug report template. + +### Submit Changes +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Submit a pull request + +## Development Workflow + +### Setup Development Environment +\`\`\`bash +git clone https://www.gitlink.org.cn/YOUR_USERNAME/gitlink-cli.git +cd gitlink-cli +make install +\`\`\` + +### Run Tests +\`\`\`bash +make test +\`\`\` + +### Code Style +- Follow Go conventions +- Add tests for new features +- Update documentation + +## Pull Request Guidelines + +### PR Title Format +- \`feat: add new feature\` +- \`fix: fix bug description\` +- \`docs: update documentation\` + +### PR Description +Include: +- Problem statement +- Solution approach +- Testing performed +- Related issues' + +# 5. 创建 FAQ +echo "Creating FAQ..." +gitlink-cli wiki +create --title "FAQ" --content '# Frequently Asked Questions + +## General Questions + +### Q: What is gitlink-cli? +A: GitLink CLI is a command-line tool for managing GitLink platform resources. + +### Q: How do I install gitlink-cli? +A: Run \`npm install -g gitlink-cli\` or build from source. + +## Authentication + +### Q: How do I authenticate? +A: Run \`gitlink-cli auth login\` and provide your credentials. + +### Q: How long does the token last? +A: Tokens expire after 7 days. Re-authenticate when expired. + +## Troubleshooting + +### Q: Command not found +A: Ensure npm global bin is in your PATH: \`export PATH=\$PATH:\$(npm config get prefix)/bin\` + +### Q: Permission denied +A: Run \`gitlink-cli auth login\` to re-authenticate. + +## More Help +- See [Getting Started](Getting-Started) +- Check [API Reference](API-Reference) +- Contact: [Contact Us](Contact-Us)' + +# 6. 创建联系我们页面 +echo "Creating Contact Us page..." +gitlink-cli wiki +create --title "Contact-Us" --content '# Contact Us + +## Get Help + +### Documentation +- [Getting Started](Getting-Started) +- [API Reference](API-Reference) +- [FAQ](FAQ) + +### Community +- Forum: [GitLink Forum](https://forum.gitlink.org.cn) +- Chat: [Gitter Channel](https://gitter.im/gitlink-cli) + +### Report Issues +- Bug Reports: [Issue Tracker](https://www.gitlink.org.cn/Gitlink/gitlink-cli/issues) +- Feature Requests: [Issue Tracker](https://www.gitlink.org.cn/Gitlink/gitlink-cli/issues) + +## Development Team + +### Maintainers +- @maintainer1 - Project lead +- @maintainer2 - Core development + +### Contributors +See [CONTRIBUTORS.md](https://www.gitlink.org.cn/Gitlink/gitlink-cli/blob/master/CONTRIBUTORS.md) + +## License +This project is licensed under the MulanPSL-2.0 License. + +## Acknowledgments +Thanks to all contributors who have helped improve this project!' + +echo "=== Wiki Initialization Complete ===" +echo "Created 6 documentation pages:" +gitlink-cli wiki +list +``` + +**关键特性**: +- ✅ 创建了完整的文档结构 +- ✅ 页面之间有交叉引用链接 +- ✅ 包含代码示例和命令 +- ✅ 覆盖了项目的所有主要方面 + +--- + +## 文档维护工作流 + +### 工作流 3: 更新文档内容 + +**场景**: 文档需要定期更新以反映项目变化 + +```bash +#!/bin/bash +# update-documentation.sh + +page_title="API-Reference" +backup_file="wiki-backup-$(date '+%Y%m%d-%H%M%S').md" + +echo "=== Safe Wiki Update Workflow ===" + +# 1. 备份当前内容 +echo "Step 1: Backing up current content..." +gitlink-cli wiki +view --title "$page_title" --format json | \ + jq -r ".data.content_decoded" > "$backup_file" +echo "✓ Backup saved: $backup_file" + +# 2. 显示当前内容预览 +echo "" +echo "Step 2: Current content preview:" +head -n 10 "$backup_file" +echo "..." + +# 3. 编辑内容(使用临时文件) +temp_file="temp-wiki-update.md" +cp "$backup_file" "$temp_file" + +echo "" +echo "Step 3: Edit the content in: $temp_file" +echo "Press Enter when done editing..." +read + +# 4. 确认更新 +echo "" +echo "Step 4: Review changes:" +echo "--- Old content (first 5 lines) ---" +head -n 5 "$backup_file" +echo "--- New content (first 5 lines) ---" +head -n 5 "$temp_file" +echo "---" + +read -p "Apply changes? (y/N) " -n 1 -r +echo + +if [[ $REPLY =~ ^[Yy]$ ]]; then + # 5. 执行更新 + echo "Step 5: Applying update..." + gitlink-cli wiki +update --title "$page_title" --file "$temp_file" + + # 6. 验证结果 + echo "Step 6: Verifying update..." + gitlink-cli wiki +view --title "$page_title" --format json | \ + jq -r ".data.content_decoded" > "updated-content.md" + + if diff -q "$temp_file" "updated-content.md" >/dev/null; then + echo "✓ Update successful!" + rm "$temp_file" "updated-content.md" + else + echo "✗ Update verification failed!" + echo "Backup available at: $backup_file" + fi +else + echo "✗ Update cancelled." + echo "Backup available at: $backup_file" + rm "$temp_file" +fi +``` + +--- + +### 工作流 4: 追加更新日志 + +**场景**: 在文档末尾追加更新日志 + +```bash +#!/bin/bash +# append-changelog.sh + +page_title="Home" +changelog_content=" + +--- + +## Changelog + +### v$(date '+%Y.%m.%d') +- Updated documentation structure +- Added new examples +- Fixed typos and errors +- Improved API references" + +echo "=== Appending Changelog to $page_title ===" + +# 1. 查看当前末尾内容 +echo "Current page ending:" +gitlink-cli wiki +view --title "$page_title" --format json | \ + jq -r ".data.content_decoded" | tail -n 5 + +# 2. 确认追加 +echo "" +echo "Content to append:" +echo "$changelog_content" + +read -p "Append changelog? (y/N) " -n 1 -r +echo + +if [[ $REPLY =~ ^[Yy]$ ]]; then + # 3. 追加内容 + gitlink-cli wiki +update --title "$page_title" --add "$changelog_content" + echo "✓ Changelog appended successfully!" + + # 4. 验证 + echo "" + echo "Updated page ending:" + gitlink-cli wiki +view --title "$page_title" --format json | \ + jq -r ".data.content_decoded" | tail -n 10 +else + echo "✗ Append cancelled." +fi +``` + +--- + +## 批量操作 + +### 工作流 5: 从本地目录批量导入 Wiki + +**场景**: 将本地的 Markdown 文档批量导入到 Wiki + +```bash +#!/bin/bash +# batch-import-wiki.sh + +wiki_docs_dir="./wiki-docs" +backup_dir="wiki-import-backup-$(date '+%Y%m%d-%H%M%S')" + +echo "=== Batch Wiki Import ===" + +# 1. 检查目录 +if [ ! -d "$wiki_docs_dir" ]; then + echo "Error: Directory '$wiki_docs_dir' not found." + echo "Please create it and add your Markdown files." + exit 1 +fi + +# 2. 创建备份目录 +mkdir -p "$backup_dir" + +# 3. 统计文件 +md_files=("$wiki_docs_dir"/*.md) +total_files=${#md_files[@]} + +echo "Found $total_files Markdown files in '$wiki_docs_dir'" + +# 4. 遍历导入 +success_count=0 +skip_count=0 +error_count=0 + +for mdfile in "${md_files[@]}"; do + # 从文件名提取标题(去掉 .md 后缀) + filename=$(basename "$mdfile") + title="${filename%.md}" + + echo "" + echo "Processing: $filename" + + # 检查页面是否已存在 + if gitlink-cli wiki +view --title "$title" >/dev/null 2>&1; then + echo " ⚠️ Page '$title' already exists. Skipping." + ((skip_count++)) + + # 备份现有页面 + gitlink-cli wiki +view --title "$title" --format json | \ + jq -r ".data.content_decoded" > "$backup_dir/$filename" + continue + fi + + # 创建新页面 + if gitlink-cli wiki +create --title "$title" --file "$mdfile" 2>/dev/null; then + echo " ✓ Created: $title" + ((success_count++)) + else + echo " ✗ Failed: $title" + ((error_count++)) + + # 失败时备份文件 + cp "$mdfile" "$backup_dir/" + fi +done + +# 5. 显示统计 +echo "" +echo "=== Import Summary ===" +echo "Total files: $total_files" +echo "✓ Created: $success_count" +echo "⚠️ Skipped: $skip_count (already exists)" +echo "✗ Failed: $error_count" + +if [ $error_count -gt 0 ]; then + echo "" + echo "Failed files backed up to: $backup_dir" +fi + +# 6. 列出当前所有页面 +echo "" +echo "Current Wiki pages:" +gitlink-cli wiki +list +``` + +--- + +### 工作流 6: 批量导出 Wiki 为本地文件 + +**场景**: 将所有 Wiki 页面导出为本地 Markdown 文件 + +```bash +#!/bin/bash +# batch-export-wiki.sh + +export_dir="wiki-export-$(date '+%Y%m%d-%H%M%S')" + +echo "=== Batch Wiki Export ===" + +# 1. 创建导出目录 +mkdir -p "$export_dir" +echo "Export directory: $export_dir" + +# 2. 获取所有页面标题 +titles=$(gitlink-cli wiki +list --format json | jq -r '.data[].title') +total_titles=$(echo "$titles" | wc -l) + +echo "Found $total_titles Wiki pages" + +# 3. 遍历导出 +success_count=0 +error_count=0 + +for title in $titles; do + # 清理文件名(替换特殊字符) + filename=$(echo "$title" | sed 's/[^a-zA-Z0-9_-]/_/g').md + + echo "Exporting: $title -> $filename" + + # 导出页面内容 + if gitlink-cli wiki +view --title "$title" --format json | \ + jq -r '.data.content_decoded' > "$export_dir/$filename" 2>/dev/null; then + echo " ✓ Exported: $filename" + ((success_count++)) + else + echo " ✗ Failed: $title" + ((error_count++)) + fi +done + +# 4. 显示统计 +echo "" +echo "=== Export Summary ===" +echo "Total pages: $total_titles" +echo "✓ Exported: $success_count" +echo "✗ Failed: $error_count" + +# 5. 创建索引文件 +echo "# Wiki Export Index" > "$export_dir/README.md" +echo "" >> "$export_dir/README.md" +echo "Export Date: $(date)" >> "$export_dir/README.md" +echo "" >> "$export_dir/README.md" +echo "## Pages" >> "$export_dir/README.md" +echo "" >> "$export_dir/README.md" + +for title in $titles; do + filename=$(echo "$title" | sed 's/[^a-zA-Z0-9_-]/_/g').md + echo "- [$title]($filename)" >> "$export_dir/README.md" +done + +echo "" +echo "✓ Index created: $export_dir/README.md" +echo "Export completed: $export_dir" +``` + +--- + +### 工作流 7: 批量重命名页面 + +**场景**: 统一 Wiki 页面命名规范 + +```bash +#!/bin/bash +# batch-rename-wiki.sh + +# 定义重命名规则(旧标题 -> 新标题) +declare -A rename_rules=( + ["api"]="API-Reference" + ["getting started"]="Getting-Started" + ["user guide"]="User-Guide" + ["faq"]="FAQ" + ["home"]="Home" +) + +echo "=== Batch Wiki Rename ===" + +# 1. 显示重命名计划 +echo "Planned renames:" +for old_title in "${!rename_rules[@]}"; do + new_title="${rename_rules[$old_title]}" + echo " '$old_title' -> '$new_title'" +done + +# 2. 确认执行 +read -p "Proceed with renaming? (yes/NO) " -r +echo + +if [[ ! "$REPLY" == "yes" ]]; then + echo "✗ Renaming cancelled." + exit 0 +fi + +# 3. 执行重命名 +success_count=0 +skip_count=0 +error_count=0 + +for old_title in "${!rename_rules[@]}"; do + new_title="${rename_rules[$old_title]}" + + echo "" + echo "Renaming: '$old_title' -> '$new_title'" + + # 检查旧页面是否存在 + if ! gitlink-cli wiki +view --title "$old_title" >/dev/null 2>&1; then + echo " ⚠️ Old page '$old_title' not found. Skipping." + ((skip_count++)) + continue + fi + + # 检查新页面是否已存在 + if gitlink-cli wiki +view --title "$new_title" >/dev/null 2>&1; then + echo " ⚠️ Target page '$new_title' already exists. Skipping." + ((skip_count++)) + continue + fi + + # 执行重命名 + if gitlink-cli wiki +update --page "$old_title" --title "$new_title" 2>/dev/null; then + echo " ✓ Renamed successfully" + ((success_count++)) + else + echo " ✗ Rename failed" + ((error_count++)) + fi +done + +# 4. 显示统计 +echo "" +echo "=== Rename Summary ===" +echo "Total planned: ${#rename_rules[@]}" +echo "✓ Renamed: $success_count" +echo "⚠️ Skipped: $skip_count" +echo "✗ Failed: $error_count" + +# 5. 列出当前所有页面 +echo "" +echo "Current Wiki pages:" +gitlink-cli wiki +list +``` + +--- + +## AI Agent 集成 + +### 工作流 8: AI Agent 自动文档管理 + +**场景**: AI Agent 自动维护项目文档 + +```python +#!/usr/bin/env python3 +# ai_wiki_manager.py - AI Agent Wiki 管理示例 + +import subprocess +import json +import os +from datetime import datetime + +class WikiManager: + """GitLink Wiki 管理器 - 为 AI Agent 设计""" + + def __init__(self, owner, repo): + self.owner = owner + self.repo = repo + self.base_cmd = ["gitlink-cli", "--owner", owner, "--repo", repo] + + def run_command(self, command): + """执行 gitlink-cli 命令并返回结果""" + try: + full_cmd = self.base_cmd + command + result = subprocess.run( + full_cmd, + capture_output=True, + text=True, + check=True + ) + return result.stdout + except subprocess.CalledProcessError as e: + print(f"Command failed: {' '.join(full_cmd)}") + print(f"Error: {e.stderr}") + return None + + def list_pages(self): + """列出所有 Wiki 页面""" + output = self.run_command(["wiki", "+list", "--format", "json"]) + if output: + data = json.loads(output) + return data.get("data", []) + return [] + + def get_page_content(self, title): + """获取指定页面的内容""" + output = self.run_command( + ["wiki", "+view", "--title", title, "--format", "json"] + ) + if output: + data = json.loads(output) + return data.get("data", {}).get("content_decoded", "") + return None + + def create_page(self, title, content): + """创建新页面""" + # 创建临时文件 + temp_file = f"/tmp/wiki_{title}.md" + with open(temp_file, 'w') as f: + f.write(content) + + # 从文件创建 + result = self.run_command( + ["wiki", "+create", "--title", title, "--file", temp_file] + ) + + # 清理临时文件 + os.remove(temp_file) + return result is not None + + def update_page(self, title, content, mode="cover"): + """更新页面内容 + + Args: + title: 页面标题 + content: 新内容 + mode: 更新模式 ("cover" 或 "add") + """ + temp_file = f"/tmp/wiki_update_{title}.md" + with open(temp_file, 'w') as f: + f.write(content) + + if mode == "cover": + result = self.run_command( + ["wiki", "+update", "--title", title, "--file", temp_file] + ) + else: # add mode + result = self.run_command( + ["wiki", "+update", "--title", title, "--add", "", + "--file", temp_file] + ) + + os.remove(temp_file) + return result is not None + + def delete_page(self, title): + """删除页面""" + result = self.run_command(["wiki", "+delete", "--title", title]) + return result is not None + + def search_in_pages(self, keyword): + """在所有页面中搜索关键词""" + pages = self.list_pages() + results = [] + + for page in pages: + title = page.get("title", "") + content = self.get_page_content(title) + + if content and keyword.lower() in content.lower(): + results.append({ + "title": title, + "url": page.get("sub_url", ""), + "preview": self.get_preview(content, keyword) + }) + + return results + + def get_preview(self, content, keyword, context=50): + """获取关键词周围的预览文本""" + index = content.lower().find(keyword.lower()) + if index == -1: + return "" + + start = max(0, index - context) + end = min(len(content), index + len(keyword) + context) + return content[start:end] + + +# AI Agent 使用示例 +def ai_agent_example(): + """AI Agent 自动维护文档的示例""" + + # 初始化 Wiki 管理器 + wiki = WikiManager("Gitlink", "forgeplus") + + print("=== AI Agent Wiki Manager ===") + + # 1. 检查文档完整性 + print("\n1. Checking documentation completeness...") + required_pages = ["Home", "Getting-Started", "API-Reference", "FAQ"] + current_pages = [p.get("title") for p in wiki.list_pages()] + + missing_pages = set(required_pages) - set(current_pages) + if missing_pages: + print(f" ⚠️ Missing pages: {missing_pages}") + # AI Agent 可以自动创建缺失的页面 + else: + print(" ✓ All required pages exist") + + # 2. 检查过时内容 + print("\n2. Checking for outdated content...") + outdated_keywords = ["version 0.9", "deprecated", "coming soon"] + for keyword in outdated_keywords: + results = wiki.search_in_pages(keyword) + if results: + print(f" ⚠️ Found '{keyword}' in:") + for result in results: + print(f" - {result['title']}") + # AI Agent 可以标记这些页面需要更新 + + # 3. 自动更新版本信息 + print("\n3. Auto-updating version information...") + home_content = wiki.get_page_content("Home") + if home_content and "Version: 1.0.0" in home_content: + new_version = "1.0.1" + updated_content = home_content.replace("1.0.0", new_version) + if wiki.update_page("Home", updated_content, "cover"): + print(f" ✓ Updated version to {new_version}") + + # 4. 生成统计报告 + print("\n4. Generating statistics...") + pages = wiki.list_pages() + total_pages = len(pages) + + print(f" Total pages: {total_pages}") + print(f" Last updated: {datetime.now().strftime('%Y-%m-%d')}") + + # 计算每个页面的字符数 + for page in pages: + title = page['title'] + content = wiki.get_page_content(title) + if content: + char_count = len(content) + print(f" - {title}: {char_count} characters") + + +if __name__ == "__main__": + ai_agent_example() +``` + +--- + +## 故障排除 + +### 工作流 9: 常见问题诊断 + +```bash +#!/bin/bash +# wiki-diagnose.sh - Wiki 问题诊断工具 + +echo "=== Wiki Diagnostic Tool ===" + +# 1. 检查认证状态 +echo "1. Checking authentication..." +if gitlink-cli auth status 2>/dev/null | grep -q "Logged in"; then + echo " ✓ Authentication OK" +else + echo " ✗ Authentication failed" + echo " Solution: Run 'gitlink-cli auth login'" + exit 1 +fi + +# 2. 检查网络连接 +echo "2. Checking network connectivity..." +if curl -s -o /dev/null -w "%{http_code}" https://www.gitlink.org.cn | grep -q "200\|301\|302"; then + echo " ✓ Network connectivity OK" +else + echo " ✗ Network connectivity failed" + echo " Solution: Check your internet connection" +fi + +# 3. 检查 Gateway API 可用性 +echo "3. Checking Gateway API..." +if curl -s -o /dev/null -w "%{http_code}" https://gateway.gitlink.org.cn/api | grep -q "200\|301\|302"; then + echo " ✓ Gateway API available" +else + echo " ✗ Gateway API unavailable" + echo " Solution: Gateway API may be down, try again later" +fi + +# 4. 检查项目权限 +echo "4. Checking project permissions..." +if gitlink-cli repo +info >/dev/null 2>&1; then + echo " ✓ Project access OK" +else + echo " ✗ Project access failed" + echo " Solution: Check if --owner and --repo are correct" +fi + +# 5. 测试 Wiki 功能 +echo "5. Testing Wiki functionality..." +page_count=$(gitlink-cli wiki +list --format json 2>/dev/null | jq '.meta.total_count // 0') +if [ "$page_count" -ge 0 ]; then + echo " ✓ Wiki功能正常 (当前页面数: $page_count)" +else + echo " ✗ Wiki功能异常" + echo " Solution: Wiki may not be enabled for this project" +fi + +# 6. 显示诊断总结 +echo "" +echo "=== Diagnostic Summary ===" +echo "如果以上检查都通过,Wiki 功能应该可以正常使用。" +echo "如果仍有问题,请检查:" +echo " 1. 页面标题是否正确(区分大小写)" +echo " 2. 是否有足够的权限操作 Wiki" +echo " 3. 网络连接是否稳定" +echo " 4. GitLink 平台是否正常运行" +``` + +--- + +## 总结 + +本文档提供了从基础到高级的 Wiki 工作流示例,涵盖: + +- ✅ **基础操作**: 创建、查看、更新、删除 +- ✅ **项目初始化**: 完整的文档结构建立 +- ✅ **文档维护**: 安全的更新和追加工作流 +- ✅ **批量处理**: 导入、导出、重命名批量操作 +- ✅ **AI 集成**: Python 实现的自动化管理 +- ✅ **故障排除**: 诊断和问题解决 + +这些工作流可以直接使用或根据具体需求调整。 + +## 相关文档 + +- [gitlink-wiki](../SKILL.md) — Wiki 功能总览 +- [wiki +list](../references/wiki-list.md) — 列出页面 +- [wiki +create](../references/wiki-create.md) — 创建页面 +- [wiki +update](../references/wiki-update.md) — 更新页面 +- [wiki +delete](../references/wiki-delete.md) — 删除页面 diff --git a/skills/gitlink-wiki/references/wiki-create.md b/skills/gitlink-wiki/references/wiki-create.md new file mode 100644 index 0000000..bc0b071 --- /dev/null +++ b/skills/gitlink-wiki/references/wiki-create.md @@ -0,0 +1,423 @@ +# wiki +create + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **⚠️ 写入操作** — 执行前必须确认用户意图。 + +创建新的 Wiki 页面。支持直接提供内容或从文件读取。 + +## 命令 + +```bash +# 创建简单页面(使用 --content) +gitlink-cli wiki +create --title "Home" --content "# Welcome\n\nThis is the home page." + +# 创建页面(从文件读取) +gitlink-cli wiki +create --title "API Reference" --file api.md + +# 创建页面并添加提交消息 +gitlink-cli wiki +create --title "Getting Started" \ + --content "# Getting Started\n\n..." \ + --message "Initial documentation" + +# 创建多行内容页面 +gitlink-cli wiki +create --title "Guide" --content "# User Guide + +## Installation +Run the following command: + +\`\`\`bash +npm install +\`\`\` + +## Usage +\`\`\`bash +npm start +\`\`\`" +``` + +## 参数 + +| 参数 | Short | 必填 | 说明 | +|------|-------|------|------| +| `--title` | `-t` | **是** | Wiki 页面标题 | +| `--content` | `-c` | **是*** | Wiki 页面内容(纯文本,与 `--file` 二选一) | +| `--file` | `-f` | **是*** | 从文件读取内容(与 `--content` 二选一) | +| `--message` | `-m` | 否 | 提交消息(可选) | +| `--owner` | | 否 | 仓库所有者(自动从 git remote 解析) | +| `--repo` | | 否 | 仓库名称(自动从 git remote 解析) | +| `--format` | | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | | 否 | 开启调试输出 | + +* `--content` 和 `--file` 必须指定其中一个 + +## 返回字段 + +### Table 格式 + +| 列名 | 说明 | +|------|------| +| `title` | 创建的页面标题 | +| `message` | 操作结果消息 | + +### JSON 格式 + +```json +{ + "ok": true, + "data": { + "title": "Home", + "message": "Wiki page created successfully" + } +} +``` + +## Workflow + +1. **Confirm** the page title and content with the user. +2. **Check** if the page already exists (optional, use `wiki +view`). +3. **Execute** `gitlink-cli wiki +create --title "" --content "<content>"`. +4. **Report** the creation result and page URL. + +> [!CAUTION] +> This is a **Write Operation** — confirm user intent before executing. + +## API + +``` +POST https://gateway.gitlink.org.cn/api/wiki/open/createWiki +Body: { + "owner": "...", + "repo": "...", + "projectId": 123, + "pageName": "<title>", + "title": "<title>", + "content_base64": "<base64-encoded-content>", + "message": "<optional-message>" +} +``` + +**工作流程**: +1. CLI 获取 `project_id` +2. 将内容 Base64 编码为 `content_base64` +3. 调用 Gateway API 创建页面 +4. 返回创建结果 + +## 使用场景 + +### 场景 1: 创建首页 + +当用户请求"创建项目首页"时: + +```bash +gitlink-cli wiki +create --title "Home" \ + --content "# Project Home + +## Overview +This project is a CLI tool for GitLink platform. + +## Features +- Repository management +- Issue tracking +- Pull requests + +## Getting Started +See the [Getting Started](Getting-Started) page." +``` + +### 场景 2: 从现有文件创建 + +```bash +# 从 README.md 创建 Wiki +gitlink-cli wiki +create --title "Home" --file README.md + +# 从多个文件创建多个页面 +gitlink-cli wiki +create --title "API Reference" --file docs/api.md +gitlink-cli wiki +create --title "User Guide" --file docs/guide.md +``` + +### 场景 3: 创建代码文档 + +```bash +gitlink-cli wiki +create --title "CLI Reference" --content "# CLI Commands + +## Repository Commands +\`\`\`bash +gitlink-cli repo +list +gitlink-cli repo +create -n my-project +\`\`\` + +## Issue Commands +\`\`\`bash +gitlink-cli issue +list +gitlink-cli issue +create -t \"Bug: ...\" +\`\`\`" +``` + +### 场景 4: 批量创建 Wiki 页面 + +```bash +#!/bin/bash +# 从 docs/ 目录批量创建 Wiki 页面 + +for mdfile in docs/*.md; do + # 从文件名提取标题(去掉 .md 后缀) + title=$(basename "$mdfile" .md) + + echo "Creating Wiki page: $title" + gitlink-cli wiki +create --title "$title" --file "$mdfile" +done +``` + +## 内容编码 + +### Base64 自动处理 + +**无需手动编码** — CLI 自动处理: + +```bash +# CLI 会自动将以下内容 Base64 编码 +gitlink-cli wiki +create --title "Test" --content "Hello, World!" + +# 等效于手动编码(不推荐) +gitlink-cli api POST "https://gateway.gitlink.org.cn/api/wiki/open/createWiki" \ + --body '{ + "owner": "...", + "repo": "...", + "projectId": 123, + "pageName": "Test", + "title": "Test", + "content_base64": "SGVsbG8sIFdvcmxkIQ==" + }' +``` + +### 多行内容处理 + +```bash +# 方法 1: 使用 \n 换行 +gitlink-cli wiki +create --title "Test" \ + --content "Line 1\nLine 2\nLine 3" + +# 方法 2: 使用 $'' 引号(支持 \n) +gitlink-cli wiki +create --title "Test" --content $'Line 1\nLine 2\nLine 3' + +# 方法 3: 从文件读取(推荐) +cat << 'EOF' > temp.md +Line 1 +Line 2 +Line 3 +EOF +gitlink-cli wiki +create --title "Test" --file temp.md +``` + +## 常见问题 + +### Q: 创建失败提示 "page already exists"? + +**A:** 页面标题已存在。解决方法: +```bash +# 查看现有页面 +gitlink-cli wiki +list + +# 使用不同的标题,或先删除现有页面 +gitlink-cli wiki +delete --title "Old Title" +gitlink-cli wiki +create --title "New Title" --content "..." +``` + +### Q: 内容显示格式错误? + +**A:** 确保: +1. Markdown 语法正确 +2. 使用 `\n` 表示换行(单行字符串) +3. 或从文件读取(保留原始格式) + +### Q: 如何创建包含代码块的页面? + +**A:** 使用正确的 Markdown 语法: +```bash +gitlink-cli wiki +create --title "Code Examples" \ + --content '# Code Examples + +## JavaScript +\`\`\`javascript +console.log("Hello"); +\`\`\` + +## Python +\`\`\`python +print("Hello") +\`\`\`' +``` + +### Q: 支持哪些 Markdown 语法? + +**A:** GitLink Wiki 支持: +- 标题 (`#`, `##`, `###`) +- 列表(有序、无序) +- 代码块(```) +- 链接 (`[text](url)`) +- 图片 (`![alt](url)`) +- 表格 +- 粗体、斜体、引用 + +### Q: 可以创建 HTML 内容吗? + +**A:** GitLink Wiki 主要支持 Markdown,部分 HTML 可能被过滤。建议使用标准 Markdown 语法。 + +## 错误处理 + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `required flag --title is missing` | 未指定标题 | 添加 `--title "Page Title"` | +| `--content or --file is required` | 未提供内容 | 添加 `--content "..."` 或 `--file file.md` | +| `failed to read file` | 文件不存在或无权限 | 检查文件路径和权限 | +| `page already exists` | 标题已存在 | 使用不同标题或先删除现有页面 | +| `401 Unauthorized` | 未登录或 Token 过期 | 运行 `gitlink-cli auth login` | +| `403 Forbidden` | 无权限创建 Wiki | 检查是否有项目写入权限 | + +## 最佳实践 + +### 1. 标题命名规范 + +```bash +# 推荐:使用连字符连接单词 +"Getting-Started" +"API-Reference" +"User-Guide" + +# 避免:空格和特殊字符 +"Getting Started" # 需要引号 +"API/Reference" # 斜杠可能被误解 +``` + +### 2. 内容模板 + +创建文档时使用标准模板: + +```bash +gitlink-cli wiki +create --title "Page Title" --content '# Page Title + +## Overview +Brief description of the page. + +## Details +Detailed content. + +## Examples +\`\`\`bash +Example code +\`\`\` + +## See Also +- [Related Page 1](Related-Page-1) +- [Related Page 2](Related-Page-2)' +``` + +### 3. 从文件创建 + +对于复杂内容,先创建文件再导入: + +```bash +# 1. 创建本地 Markdown 文件 +cat > home.md << 'EOF' +# Home + +Welcome to the project! +EOF + +# 2. 从文件创建 Wiki +gitlink-cli wiki +create --title "Home" --file home.md + +# 3. 清理临时文件 +rm home.md +``` + +### 4. 批量创建工作流 + +```bash +#!/bin/bash +# 批量创建项目文档 + +# 定义页面列表 +declare -A pages=( + ["Home"]="home.md" + ["Getting-Started"]="getting-started.md" + ["API-Reference"]="api.md" + ["FAQ"]="faq.md" +) + +# 遍历创建 +for title in "${!pages[@]}"; do + file="${pages[$title]}" + if [ -f "$file" ]; then + echo "Creating: $title from $file" + gitlink-cli wiki +create --title "$title" --file "$file" + else + echo "Warning: $file not found, skipping $title" + fi +done +``` + +## 完整示例 + +### 示例:创建完整项目 Wiki + +```bash +#!/bin/bash +# 为新项目创建完整的 Wiki 文档结构 + +# 1. 创建首页 +gitlink-cli wiki +create --title "Home" --content '# Project Home + +## Overview +This is a demonstration project for gitlink-cli Wiki. + +## Documentation +- [Getting Started](Getting-Started) +- [API Reference](API-Reference) +- [Contributing](Contributing) + +## Support +- [FAQ](FAQ) +- [Contact Us](Contact-Us)' + +# 2. 创建入门指南 +gitlink-cli wiki +create --title "Getting-Started" --content '# Getting Started + +## Installation +\`\`\`bash +npm install +\`\`\` + +## Configuration +\`\`\`bash +cp .env.example .env +\`\`\` + +## Running +\`\`\`bash +npm start +\`\`\`' + +# 3. 创建 API 文档 +gitlink-cli wiki +create --title "API-Reference" --content '# API Reference + +## Endpoints + +### GET /api/users +Get user information. + +### POST /api/issues +Create a new issue. + +## Examples +See the [Examples](Examples) page.' + +echo "Wiki documentation structure created successfully!" +``` + +## References + +- [gitlink-wiki](../SKILL.md) +- [wiki +update](wiki-update.md) — 更新 Wiki 页面 +- [wiki +delete](wiki-delete.md) — 删除 Wiki 页面 +- [gitlink-shared](../../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-wiki/references/wiki-delete.md b/skills/gitlink-wiki/references/wiki-delete.md new file mode 100644 index 0000000..75f0109 --- /dev/null +++ b/skills/gitlink-wiki/references/wiki-delete.md @@ -0,0 +1,539 @@ +# wiki +delete + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **⚠️ 危险操作** — 删除操作**无法撤销**,执行前必须确认用户意图。 + +删除指定的 Wiki 页面。⚠️ **此操作不可逆!** + +## 命令 + +```bash +# 删除 Wiki 页面 +gitlink-cli wiki +delete --title "Old Page" + +# 删除指定仓库的 Wiki 页面 +gitlink-cli wiki +delete --owner Gitlink --repo forgeplus --title "Outdated" + +# 删除前先确认(推荐) +gitlink-cli wiki +view --title "Page to Delete" # 先查看内容 +gitlink-cli wiki +delete --title "Page to Delete" # 再删除 +``` + +## 参数 + +| 参数 | Short | 必填 | 说明 | +|------|-------|------|------| +| `--title` | `-t` | **是** | 要删除的 Wiki 页面标题 | +| `--owner` | | 否 | 仓库所有者(自动从 git remote 解析) | +| `--repo` | | 否 | 仓库名称(自动从 git remote 解析) | +| `--format` | | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | | 否 | 开启调试输出 | + +## 返回字段 + +### Table 格式 + +| 列名 | 说明 | +|------|------| +| `message` | 操作结果消息 | + +### JSON 格式 + +```json +{ + "ok": true, + "data": { + "message": "Wiki page deleted successfully" + } +} +``` + +## Workflow + +1. **Confirm** the page title to delete. +2. **Warning** that this operation is **irreversible**. +3. **Optional**: View current content with `wiki +view` for final verification. +4. **Execute** `gitlink-cli wiki +delete --title "<page title>"`. +5. **Report** the deletion result. + +> [!DANGER] +> **删除操作无法撤销!** 建议执行前先备份内容: +> ```bash +> gitlink-cli wiki +view --title "Page" > backup.md +> gitlink-cli wiki +delete --title "Page" +> ``` + +## API + +``` +DELETE https://gateway.gitlink.org.cn/api/wiki/open/deleteWiki +Body: { + "owner": "...", + "repo": "...", + "projectId": 123, + "pageName": "<title>", + "message": "" +} +``` + +**工作流程**: +1. CLI 获取 `project_id` +2. 调用 Gateway API 删除页面 +3. 验证删除是否成功(尝试获取页面) +4. 返回删除结果 + +**删除验证逻辑**: +- 如果删除 API 返回成功 → 删除成功 +- 如果删除 API 失败,尝试获取页面: + - 页面不存在 → 删除成功 + - 页面仍存在 → 删除失败 + +## 使用场景 + +### 场景 1: 删除过时文档 + +```bash +# 查看过时内容 +gitlink-cli wiki +view --title "Old API Reference" + +# 确认后删除 +gitlink-cli wiki +delete --title "Old API Reference" +``` + +### 场景 2: 清理测试页面 + +```bash +# 列出所有页面 +gitlink-cli wiki +list + +# 删除测试页面 +gitlink-cli wiki +delete --title "Test Page 1" +gitlink-cli wiki +delete --title "Test Page 2" +gitlink-cli wiki +delete --title "Test Page 3" +``` + +### 场景 3: 批量删除(谨慎!) + +```bash +#!/bin/bash +# 批量删除包含特定关键词的页面 + +for title in $(gitlink-cli wiki +list --format json | jq -r ".data[].title"); do + # 删除包含 "Draft" 的页面 + if [[ "$title" == *"Draft"* ]]; then + echo "Deleting draft page: $title" + gitlink-cli wiki +delete --title "$title" + fi +done +``` + +### 场景 4: 删除前备份 + +```bash +#!/bin/bash +# 安全删除工作流:先备份再删除 + +title="Page to Delete" + +# 1. 备份内容 +gitlink-cli wiki +view --title "$title" --format json | \ + jq -r ".data.content_decoded" > "${title}-backup.md" + +# 2. 确认删除 +read -p "Backup created at ${title}-backup.md. Delete now? (y/N) " -n 1 -r +echo +if [[ $REPLY =~ ^[Yy]$ ]]; then + gitlink-cli wiki +delete --title "$title" + echo "Page deleted. Backup saved at ${title}-backup.md" +else + echo "Deletion cancelled." +fi +``` + +### 场景 5: 条件删除 + +```bash +#!/bin/bash +# 根据页面内容决定是否删除 + +title="Deprecated Feature" + +# 获取页面内容 +content=$(gitlink-cli wiki +view --title "$title" --format json | \ + jq -r ".data.content_decoded") + +# 检查是否包含"已废弃"标记 +if [[ "$content" == *"此功能已废弃"* ]]; then + echo "Page is deprecated. Deleting..." + gitlink-cli wiki +delete --title "$title" +else + echo "Page is still active. Not deleting." +fi +``` + +## 删除验证 + +### 方法 1: 尝试查看页面 + +```bash +# 删除后验证 +gitlink-cli wiki +delete --title "Test Page" + +# 尝试查看(应该返回 404) +gitlink-cli wiki +view --title "Test Page" +# 预期输出:Error: 404 Not Found +``` + +### 方法 2: 列出所有页面 + +```bash +# 删除前列出 +gitlink-cli wiki +list +# 包含: "Test Page" + +gitlink-cli wiki +delete --title "Test Page" + +# 删除后列出 +gitlink-cli wiki +list +# 不包含: "Test Page" +``` + +### 方法 3: 统计页面数量 + +```bash +# 删除前 +before=$(gitlink-cli wiki +list --format json | jq ".meta.total_count") +echo "Pages before: $before" + +# 删除 +gitlink-cli wiki +delete --title "Old Page" + +# 删除后 +after=$(gitlink-cli wiki +list --format json | jq ".meta.total_count") +echo "Pages after: $after" +echo "Deleted: $((before - after)) page(s)" +``` + +## 常见问题 + +### Q: 删除后能否恢复? + +**A:** **不能**。GitLink Wiki 不提供版本历史或回收站功能。 + +**建议**: +1. 删除前务必备份:`gitlink-cli wiki +view --title "Page" > backup.md` +2. 考虑使用重命名代替删除:`gitlink-cli wiki +update --page "Old" --title "Old-Archived"` +3. 如有备份,可重新创建:`gitlink-cli wiki +create --title "Page" --file backup.md` + +### Q: 删除失败提示 "page not found"? + +**A:** 页面不存在或已被删除。 + +**解决方法**: +```bash +# 查看现有页面 +gitlink-cli wiki +list + +# 确认页面标题正确(区分大小写) +gitlink-cli wiki +delete --title "Correct-Title" +``` + +### Q: 删除后页面链接还能访问吗? + +**A:** 访问已删除页面会返回 **404 Not Found**。 + +如果有外部链接指向该页面,需要: +1. 更新外部链接 +2. 或创建同名新页面 +3. 或设置重定向(GitLink Wiki 不支持,需手动更新) + +### Q: 能否批量删除所有页面? + +**A:** **可以,但极其危险!** + +```bash +#!/bin/bash +# ⚠️ 危险操作:删除所有 Wiki 页面 + +read -p "⚠️ This will delete ALL wiki pages. Continue? (yes/NO) " -r +if [[ "$REPLY" == "yes" ]]; then + for title in $(gitlink-cli wiki +list --format json | jq -r ".data[].title"); do + echo "Deleting: $title" + gitlink-cli wiki +delete --title "$title" + done + echo "All pages deleted." +else + echo "Cancelled." +fi +``` + +### Q: 删除操作需要什么权限? + +**A:** 需要: +- **项目写入权限**(Maintainer 或 Owner 角色) +- **有效的认证 Token** + +如果权限不足: +```bash +# 403 Forbidden → 检查权限 +# 401 Unauthorized → 运行 gitlink-cli auth login +``` + +### Q: 如何防止误删除? + +**A:** 建议: +1. **删除前备份**:总是先备份内容 +2. **使用别名**:创建安全删除别名 +3. **确认操作**:删除前再次确认 +4. **文档规范**:制定删除流程文档 + +## 错误处理 + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `required flag --title is missing` | 未指定页面标题 | 添加 `--title "Page Title"` | +| `page not found` | 页面不存在 | 使用 `wiki +list` 查看可用页面 | +| `401 Unauthorized` | 未登录或 Token 过期 | 运行 `gitlink-cli auth login` | +| `403 Forbidden` | 无权限删除 Wiki | 检查是否有项目写入权限 | +| `failed to delete` | 删除操作失败 | 检查网络连接和 API 可用性 | + +## 安全措施 + +### 1. 删除前备份脚本 + +```bash +#!/bin/bash +# safe-delete.sh - 安全删除 Wiki 页面 + +title="$1" + +if [ -z "$title" ]; then + echo "Usage: ./safe-delete.sh '<Page Title>'" + exit 1 +fi + +# 1. 检查页面是否存在 +if ! gitlink-cli wiki +view --title "$title" >/dev/null 2>&1; then + echo "Error: Page '$title' does not exist." + exit 1 +fi + +# 2. 备份内容 +backup_file="${title}-backup-$(date '+%Y%m%d-%H%M%S').md" +gitlink-cli wiki +view --title "$title" --format json | \ + jq -r ".data.content_decoded" > "$backup_file" + +echo "✓ Backup created: $backup_file" + +# 3. 显示预览 +echo "" +echo "Page content preview:" +head -n 10 "$backup_file" +echo "..." + +# 4. 确认删除 +read -p "Delete '$title' now? (yes/NO) " -r +echo +if [[ "$REPLY" == "yes" ]]; then + gitlink-cli wiki +delete --title "$title" + if [ $? -eq 0 ]; then + echo "✓ Page deleted. Backup saved at: $backup_file" + else + echo "✗ Deletion failed. Backup available at: $backup_file" + fi +else + echo "✗ Deletion cancelled. Backup saved at: $backup_file" +fi +``` + +### 2. 创建删除日志 + +```bash +#!/bin/bash +# deleted-pages.log - 记录所有删除操作 + +log_file="wiki-deletion-log.txt" +title="$1" + +# 记录删除操作 +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deleted: $title" >> "$log_file" + +# 执行删除 +gitlink-cli wiki +delete --title "$title" + +echo "Deletion logged to: $log_file" +``` + +### 3. 使用 Git 追踪删除 + +如果 Wiki 内容也在 Git 中管理: + +```bash +# 1. Git commit 删除前的状态 +git add docs/ +git commit -m "Backup before wiki deletion: $title" + +# 2. 执行删除 +gitlink-cli wiki +delete --title "$title" + +# 3. 记录删除 +echo "Deleted $title on $(date)" >> wiki-deletions.log +``` + +## 最佳实践 + +### 1. 删除前检查清单 + +在删除 Wiki 页面前,确保: + +- [ ] 已备份页面内容 +- [ ] 确认页面不再需要 +- [ ] 更新了相关链接 +- [ ] 通知了相关团队成员 +- [ ] 有权限执行删除操作 + +### 2. 替代删除的方案 + +**考虑使用重命名代替删除**: + +```bash +# 不删除,而是重命名为"已归档" +gitlink-cli wiki +update --page "Old Feature" --title "Archived-Old-Feature" + +# 或在页面顶部添加废弃标记 +gitlink-cli wiki +update --title "Old Feature" \ + --add "\n\n---\n\n⚠️ **此页面已废弃,请勿使用。**" +``` + +### 3. 批量删除的安全流程 + +```bash +#!/bin/bash +# 安全批量删除工作流 + +# 1. 列出待删除页面 +declare -a pages_to_delete=( + "Test-Page-1" + "Test-Page-2" + "Draft-Document" +) + +# 2. 创建备份目录 +backup_dir="wiki-backup-$(date '+%Y%m%d-%H%M%S')" +mkdir -p "$backup_dir" + +# 3. 备份所有页面 +for title in "${pages_to_delete[@]}"; do + filename=$(echo "$title" | sed 's/[^a-zA-Z0-9_-]/_/g').md + gitlink-cli wiki +view --title "$title" --format json | \ + jq -r ".data.content_decoded" > "$backup_dir/$filename" + echo "Backed up: $title" +done + +# 4. 确认删除 +echo "" +echo "Backup created at: $backup_dir" +read -p "Delete all ${#pages_to_delete[@]} pages now? (yes/NO) " -r +echo + +if [[ "$REPLY" == "yes" ]]; then + # 5. 执行删除 + for title in "${pages_to_delete[@]}"; do + gitlink-cli wiki +delete --title "$title" + echo "Deleted: $title" + done + echo "✓ All pages deleted. Backups saved at: $backup_dir" +else + echo "✗ Cancelled. Backups available at: $backup_dir" +fi +``` + +### 4. 监控删除操作 + +```bash +#!/bin/bash +# 监控 Wiki 页面数量变化 + +# 记录当前页面数量 +current_count=$(gitlink-cli wiki +list --format json | jq ".meta.total_count") +echo "[$(date '+%Y-%m-%d %H:%M:%S')] Current page count: $current_count" >> wiki-monitor.log + +# 如果页面数量异常减少,发出警告 +if [ -f "previous-count.txt" ]; then + previous_count=$(cat previous-count.txt) + if [ "$current_count" -lt "$previous_count" ]; then + echo "⚠️ Warning: Page count decreased from $previous_count to $current_count" + echo "[$(date '+%Y-%m-%d %H:%M:%S')] WARNING: Count decreased: $previous_count -> $current_count" >> wiki-monitor.log + fi +fi + +echo "$current_count" > previous-count.txt +``` + +## 完整示例 + +### 示例:清理过时文档 + +```bash +#!/bin/bash +# 完整的文档清理工作流 + +# 1. 定义过时页面列表 +declare -a outdated_pages=( + "Legacy-API-v1" + "Deprecated-Feature-X" + "Old-Installation-Guide" +) + +# 2. 创建备份 +backup_dir="wiki-cleanup-backup-$(date '+%Y%m%d')" +mkdir -p "$backup_dir" + +echo "=== Wiki Cleanup Process ===" +echo "Backing up outdated pages..." + +for title in "${outdated_pages[@]}"; do + if gitlink-cli wiki +view --title "$title" >/dev/null 2>&1; then + filename=$(echo "$title" | sed 's/[^a-zA-Z0-9_-]/_/g').md + gitlink-cli wiki +view --title "$title" --format json | \ + jq -r ".data.content_decoded" > "$backup_dir/$filename" + echo "✓ Backed up: $title" + else + echo "✗ Skipped (not found): $title" + fi +done + +# 3. 确认删除 +echo "" +echo "Outdated pages to delete:" +printf " - %s\n" "${outdated_pages[@]}" +echo "" +read -p "Proceed with deletion? (yes/NO) " -r +echo + +if [[ "$REPLY" == "yes" ]]; then + echo "Deleting outdated pages..." + + for title in "${outdated_pages[@]}"; do + if gitlink-cli wiki +delete --title "$title" 2>/dev/null; then + echo "✓ Deleted: $title" + else + echo "✗ Failed (already deleted?): $title" + fi + done + + echo "" + echo "✓ Cleanup completed. Backups saved at: $backup_dir" + echo "Remaining pages: $(gitlink-cli wiki +list --format json | jq '.meta.total_count')" +else + echo "✗ Cleanup cancelled. Backups available at: $backup_dir" +fi +``` + +## References + +- [gitlink-wiki](../SKILL.md) +- [wiki +create](wiki-create.md) — 创建 Wiki 页面 +- [wiki +update](wiki-update.md) — 更新 Wiki 页面 +- [wiki +list](wiki-list.md) — 列出 Wiki 页面 +- [gitlink-shared](../../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-wiki/references/wiki-list.md b/skills/gitlink-wiki/references/wiki-list.md new file mode 100644 index 0000000..e3b6ba4 --- /dev/null +++ b/skills/gitlink-wiki/references/wiki-list.md @@ -0,0 +1,136 @@ +# wiki +list + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +列出仓库的所有 Wiki 页面。返回页面标题、URL、更新时间等元信息。 + +## 命令 + +```bash +# 列出当前仓库的所有 Wiki 页面 +gitlink-cli wiki +list + +# 列出指定仓库的 Wiki 页面 +gitlink-cli wiki +list --owner Gitlink --repo forgeplus + +# 使用 JSON 格式输出 +gitlink-cli wiki +list --format json + +# 查看 Wiki 页面总数 +gitlink-cli wiki +list --format json | jq ".data | length" +``` + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--owner` | 否 | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否 | 仓库名称(自动从 git remote 解析) | +| `--format` | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | 否 | 开启调试输出 | + +## 返回字段 + +### Table 格式 + +| 列名 | 说明 | +|------|------| +| `title` | Wiki 页面标题 | +| `sub_url` | 页面访问路径(URL 编码) | +| `updated_at` | 最后更新时间 | + +### JSON 格式 + +```json +{ + "ok": true, + "data": [ + { + "title": "Home", + "sub_url": "https://www.gitlink.org.cn/Gitlink/forgeplus/wiki/Home", + "updated_at": "2026-06-01T10:30:00Z" + }, + { + "title": "API-Reference", + "sub_url": "https://www.gitlink.org.cn/Gitlink/forgeplus/wiki/API-Reference", + "updated_at": "2026-06-01T11:15:00Z" + } + ], + "meta": { + "total_count": 2 + } +} +``` + +## Workflow + +1. **Check** if `--owner` and `--repo` are provided or can be auto-resolved. +2. **Execute** `gitlink-cli wiki +list`. +3. **Display** the list of Wiki pages with titles and URLs. + +## API + +``` +GET https://gateway.gitlink.org.cn/api/wiki/open/wikiPages +Query: owner={owner}&repo={repo}&projectId={project_id} +``` + +**注意**: +- CLI 自动获取 `project_id` +- 响应会被清理:移除 `wiki_clone_link` 字段 +- `sub_url` 会被 URL 解码以便阅读 + +## 使用场景 + +### 场景 1: 发现项目文档 + +当用户询问"这个项目有什么文档"时: + +```bash +gitlink-cli wiki +list +``` + +### 场景 2: 检查 Wiki 是否启用 + +当返回空列表时,说明项目未启用 Wiki 或没有创建任何页面。 + +### 场景 3: 批量处理所有 Wiki 页面 + +```bash +# 获取所有 Wiki 页面标题 +titles=$(gitlink-cli wiki +list --format json | jq -r ".data[].title") + +# 遍历每个页面 +for title in $titles; do + echo "Processing: $title" + gitlink-cli wiki +view --title "$title" +done +``` + +## 常见问题 + +### Q: 返回空列表? + +**A:** 可能原因: +1. 项目没有创建任何 Wiki 页面 +2. 项目未启用 Wiki 功能 +3. `owner/repo` 指定错误 + +### Q: `sub_url` 字段是什么? + +**A:** Wiki 页面的完整访问 URL,格式为: +``` +https://www.gitlink.org.cn/{owner}/{repo}/wiki/{page_title} +``` + +### Q: 如何获取页面总数? + +**A:** 使用 JSON 格式并查看 `meta.total_count`: +```bash +gitlink-cli wiki +list --format json | jq ".meta.total_count" +``` + +## References + +- [gitlink-wiki](../SKILL.md) +- [gitlink-shared](../../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-wiki/references/wiki-update.md b/skills/gitlink-wiki/references/wiki-update.md new file mode 100644 index 0000000..f0f8aa1 --- /dev/null +++ b/skills/gitlink-wiki/references/wiki-update.md @@ -0,0 +1,492 @@ +# wiki +update + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **⚠️ 写入操作** — 执行前必须确认用户意图。 + +更新现有 Wiki 页面。支持三种模式:**覆盖**、**追加**、**重命名**。 + +## 命令 + +### 覆盖模式(完全替换内容) + +```bash +# 覆盖整个页面内容 +gitlink-cli wiki +update --title "Home" --cover "# New Content\n\nThis replaces everything." + +# 从文件覆盖 +gitlink-cli wiki +update --title "API Reference" --file new-api.md +``` + +### 追加模式(在现有内容后追加) + +```bash +# 追加内容到现有页面 +gitlink-cli wiki +update --title "Home" --add "\n\n## New Section\n\nAdditional content." + +# 从文件追加 +gitlink-cli wiki +update --title "Guide" --add "" --file appendix.md +``` + +### 重命名模式(更改页面标题) + +```bash +# 重命名页面(保留原内容) +gitlink-cli wiki +update --page "Old-Title" --title "New-Title" + +# 重命名并更新内容 +gitlink-cli wiki +update --page "Old-Title" --title "New-Title" --cover "Updated content" +``` + +## 参数 + +| 参数 | Short | 必填 | 说明 | +|------|-------|------|------| +| `--title` | `-t` | **是** | 目标页面标题(更新后的标题,用于重命名) | +| `--page` | `-p` | 否 | 当前页面标题(用于查找和重命名,默认同 `--title`) | +| `--cover` | `-c` | 否* | 覆盖整个页面内容(纯文本) | +| `--add` | `-a` | 否* | 追加内容到现有页面(纯文本) | +| `--file` | `-f` | 否 | 从文件读取内容(配合 `--cover` 或 `--add` 使用) | +| `--message` | `-m` | 否 | 提交消息(可选) | +| `--owner` | | 否 | 仓库所有者(自动从 git remote 解析) | +| `--repo` | | 否 | 仓库名称(自动从 git remote 解析) | +| `--format` | | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | | 否 | 开启调试输出 | + +* `--cover` 或 `--add` 必须指定其中一个,或都不指定(仅重命名) + +## 返回字段 + +### Table 格式 + +| 列名 | 说明 | +|------|------| +| `title` | 更新后的页面标题 | +| `message` | 操作结果消息 | + +### JSON 格式 + +```json +{ + "ok": true, + "data": { + "title": "Updated Title", + "message": "Wiki page updated successfully" + } +} +``` + +## 三种更新模式 + +### 1. 覆盖模式 (`--cover`) + +**完全替换页面内容**: + +```bash +gitlink-cli wiki +update --title "Home" --cover "# New Home Page + +This completely replaces the old content." +``` + +**工作流程**: +1. 用户提供新内容 +2. CLI 直接用新内容替换整个页面 +3. 旧内容**完全丢失** + +**使用场景**: +- 完全重写页面 +- 修正错误内容 +- 大规模内容更新 + +### 2. 追加模式 (`--add`) + +**在现有内容基础上追加**: + +```bash +gitlink-cli wiki +update --title "Home" --add "\n\n## Changelog + +### v1.0.0 (2026-06-01) +- Initial release" +``` + +**工作流程**: +1. CLI 获取当前页面内容 +2. 将新内容追加到现有内容后 +3. 提交更新后的完整内容 + +**使用场景**: +- 添加新章节 +- 追加更新日志 +- 补充补充说明 + +### 3. 重命名模式 (`--page` + `--title`) + +**更改页面标题**: + +```bash +# 仅重命名(保留原内容) +gitlink-cli wiki +update --page "Old-Title" --title "New-Title" + +# 重命名并更新内容 +gitlink-cli wiki +update --page "Old-Title" --title "New-Title" --cover "Updated" +``` + +**工作流程**: +1. `--page` 指定当前页面标题(用于查找) +2. `--title` 指定新标题 +3. 如果指定 `--cover` 或 `--add`,同时更新内容 + +**使用场景**: +- 修正页面标题拼写 +- 调整命名规范 +- 页面重组 + +## Workflow + +### 覆盖模式 + +1. **Confirm** the new content with the user. +2. **Warning** that this will replace all existing content. +3. **Execute** `gitlink-cli wiki +update --title "<title>" --cover "<new content>"`. +4. **Report** the update result. + +### 追加模式 + +1. **Confirm** the content to append. +2. **Execute** `gitlink-cli wiki +update --title "<title>" --add "<content>"`. +3. **Report** the update result. + +### 重命名模式 + +1. **Confirm** the old title (`--page`) and new title (`--title`). +2. **Execute** `gitlink-cli wiki +update --page "<old>" --title "<new>"`. +3. **Report** the rename result. + +> [!CAUTION] +> **覆盖模式** 会完全替换页面内容,无法撤销!建议先使用 `wiki +view` 查看当前内容,必要时手动备份。 + +## API + +``` +PUT https://gateway.gitlink.org.cn/api/wiki/open/updateWiki +Body: { + "owner": "...", + "repo": "...", + "projectId": 123, + "pageName": "<current-title>", + "title": "<new-title>", + "content_base64": "<base64-encoded-content>", + "message": "<optional-message>" +} +``` + +**工作流程**: +1. CLI 获取 `project_id` +2. 如果是追加模式,先获取当前页面内容 +3. 将内容 Base64 编码 +4. 调用 Gateway API 更新页面 +5. 返回更新结果 + +## 使用场景 + +### 场景 1: 修正文档错误 + +```bash +# 查看当前内容 +gitlink-cli wiki +view --title "API Reference" + +# 修正错误 +gitlink-cli wiki +update --title "API Reference" \ + --file corrected-api.md +``` + +### 场景 2: 添加更新日志 + +```bash +# 追加更新日志到首页 +gitlink-cli wiki +update --title "Home" --add ' +## Changelog + +### v2.0.0 (2026-06-01) +- Added new feature X +- Fixed bug Y +- Improved performance Z' +``` + +### 场景 3: 重命名页面 + +```bash +# 将 "api" 重命名为 "API Reference" +gitlink-cli wiki +update --page "api" --title "API Reference" + +# 重命名并更新内容 +gitlink-cli wiki +update --page "old-guide" --title "User-Guide" \ + --cover "# User Guide\n\nUpdated content" +``` + +### 场景 4: 批量更新多个页面 + +```bash +#!/bin/bash +# 批量更新所有页面的页脚 + +declare -A pages=( + ["Home"]="home.md" + ["API-Reference"]="api.md" + ["Guide"]="guide.md" +) + +for title in "${!pages[@]}"; do + file="${pages[$title]}" + + # 读取文件内容作为覆盖内容 + echo "Updating: $title from $file" + gitlink-cli wiki +update --title "$title" --file "$file" +done +``` + +### 场景 5: 增量更新文档 + +```bash +#!/bin/bash +# 为所有页面添加"最后更新"时间戳 + +for title in $(gitlink-cli wiki +list --format json | jq -r ".data[].title"); do + current_date=$(date "+%Y-%m-%d") + + gitlink-cli wiki +update --title "$title" \ + --add "\n\n---\n\n*Last updated: $current_date*" +done +``` + +## 模式选择指南 + +### 何时使用覆盖模式? + +✅ **使用覆盖模式**: +- 完全重写页面内容 +- 修正严重错误 +- 大规模内容更新 +- 从文件导入新版本 + +❌ **避免使用覆盖模式**: +- 只需添加小段内容 +- 需要保留部分现有内容 +- 不确定要修改的具体内容 + +### 何时使用追加模式? + +✅ **使用追加模式**: +- 添加新章节 +- 追加更新日志 +- 补充补充说明 +- 保持历史内容 + +❌ **避免使用追加模式**: +- 需要修正现有内容 +- 页面内容过长 +- 需要结构性修改 + +### 何时使用重命名模式? + +✅ **使用重命名模式**: +- 修正拼写错误 +- 统一命名规范 +- 页面重组 +- 调整文档结构 + +## 常见问题 + +### Q: 覆盖模式能否撤销? + +**A:** **不能**。覆盖模式会完全替换内容,无法自动撤销。 + +**建议**: +1. 先使用 `wiki +view` 查看当前内容 +2. 必要时手动备份:`gitlink-cli wiki +view --title "Page" > backup.md` +3. 再执行覆盖更新 + +### Q: 追加模式的内容位置? + +**A:** 追加的内容会添加到现有内容的**末尾**。 + +如果需要精确控制位置: +1. 先查看当前内容 +2. 手动编辑(合并旧内容 + 新内容) +3. 使用覆盖模式更新 + +### Q: 重命名后旧标题还能访问吗? + +**A:** **不能**。重命名后: +- 旧标题页面不存在 +- 使用旧标题访问会返回 404 +- 需要更新所有指向旧页面的链接 + +### Q: 如何同时修改标题和内容? + +**A:** 使用 `--page` + `--title` + `--cover`: +```bash +gitlink-cli wiki +update \ + --page "Old-Title" \ + --title "New-Title" \ + --cover "New content" +``` + +### Q: 更新失败提示 "page not found"? + +**A:** 可能原因: +1. `--title` 指定的页面不存在 +2. 如果使用 `--page`,当前页面不存在 +3. `owner/repo` 指定错误 + +**解决方法**: +```bash +# 先列出所有页面 +gitlink-cli wiki +list + +# 确认页面标题正确(区分大小写) +gitlink-cli wiki +update --title "Correct-Title" --cover "..." +``` + +### Q: 追加模式获取旧内容失败? + +**A:** 可能原因: +1. 页面不存在 +2. 网络问题 +3. 权限不足 + +**解决方法**: +```bash +# 检查页面是否存在 +gitlink-cli wiki +view --title "Page-Name" + +# 如果页面不存在,先创建 +gitlink-cli wiki +create --title "Page-Name" --content "..." +``` + +## 错误处理 + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `required flag --title is missing` | 未指定目标标题 | 添加 `--title "Page Title"` | +| `--cover or --file is required` | 未提供更新内容 | 添加 `--cover "..."` 或 `--file file.md` | +| `failed to fetch current page content` | 追加模式下页面不存在 | 先创建页面或检查标题 | +| `page not found` | 指定页面不存在 | 使用 `wiki +list` 查看可用页面 | +| `401 Unauthorized` | 未登录或 Token 过期 | 运行 `gitlink-cli auth login` | +| `403 Forbidden` | 无权限更新 Wiki | 检查是否有项目写入权限 | + +## 最佳实践 + +### 1. 更新前备份 + +```bash +# 更新前先备份当前内容 +gitlink-cli wiki +view --title "Important Page" --format json | \ + jq -r ".data.content_decoded" > backup.md + +# 然后执行更新 +gitlink-cli wiki +update --title "Important Page" --file new-content.md +``` + +### 2. 验证更新结果 + +```bash +# 更新后查看新内容 +gitlink-cli wiki +view --title "Page" --format json | \ + jq -r ".data.content_decoded" +``` + +### 3. 使用文件进行复杂更新 + +```bash +# 1. 导出当前内容 +gitlink-cli wiki +view --title "Page" --format json | \ + jq -r ".data.content_decoded" > temp.md + +# 2. 手动编辑 temp.md + +# 3. 更新回 Wiki +gitlink-cli wiki +update --title "Page" --file temp.md + +# 4. 清理 +rm temp.md +``` + +### 4. 批量重命名规范 + +```bash +#!/bin/bash +# 统一命名规范:将空格替换为连字符 + +for title in $(gitlink-cli wiki +list --format json | jq -r ".data[].title"); do + # 如果标题包含空格 + if [[ "$title" =~ " " ]]; then + # 生成新标题(空格替换为连字符) + new_title=$(echo "$title" | sed 's/ /-/g') + + echo "Renaming: '$title' -> '$new_title'" + gitlink-cli wiki +update --page "$title" --title "$new_title" + fi +done +``` + +### 5. 增量更新工作流 + +```bash +#!/bin/bash +# 安全的追加模式工作流 + +title="Home" +new_content="## New Section\n\nNew content here." + +# 1. 先检查页面是否存在 +if gitlink-cli wiki +view --title "$title" >/dev/null 2>&1; then + # 2. 追加内容 + gitlink-cli wiki +update --title "$title" --add "\n\n$new_content" + echo "Content appended to $title" +else + # 3. 页面不存在,创建新页面 + gitlink-cli wiki +create --title "$title" --content "$new_content" + echo "New page $title created" +fi +``` + +## 完整示例 + +### 示例:重构项目文档 + +```bash +#!/bin/bash +# 文档重构工作流 + +# 1. 备份所有页面 +mkdir -p wiki-backup +for title in $(gitlink-cli wiki +list --format json | jq -r ".data[].title"); do + filename=$(echo "$title" | sed 's/[^a-zA-Z0-9_-]/_/g').md + gitlink-cli wiki +view --title "$title" --format json | \ + jq -r ".data.content_decoded" > "wiki-backup/$filename" + echo "Backed up: $title -> $filename" +done + +# 2. 重命名页面(统一命名规范) +gitlink-cli wiki +update --page "api" --title "API-Reference" +gitlink-cli wiki +update --page "user guide" --title "User-Guide" + +# 3. 更新首页内容 +gitlink-cli wiki +update --title "Home" --file new-home.md + +# 4. 为所有页面添加页脚 +for title in $(gitlink-cli wiki +list --format json | jq -r ".data[].title"); do + gitlink-cli wiki +update --title "$title" \ + --add "\n\n---\n\n*Updated: $(date '+%Y-%m-%d')*" +done + +echo "Wiki restructuring completed!" +``` + +## References + +- [gitlink-wiki](../SKILL.md) +- [wiki +create](wiki-create.md) — 创建 Wiki 页面 +- [wiki +view](wiki-view.md) — 查看 Wiki 页面 +- [wiki +delete](wiki-delete.md) — 删除 Wiki 页面 +- [gitlink-shared](../../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-wiki/references/wiki-view.md b/skills/gitlink-wiki/references/wiki-view.md new file mode 100644 index 0000000..073b29e --- /dev/null +++ b/skills/gitlink-wiki/references/wiki-view.md @@ -0,0 +1,213 @@ +# wiki +view + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 + +查看指定 Wiki 页面的完整内容,包括 Markdown 源文本。 + +## 命令 + +```bash +# 查看 Wiki 页面内容 +gitlink-cli wiki +view --title "Home" + +# 查看指定仓库的 Wiki 页面 +gitlink-cli wiki +view --owner Gitlink --repo forgeplus --title "API-Reference" + +# 使用 JSON 格式查看(包含 base64 和 decoded 内容) +gitlink-cli wiki +view --title "Home" --format json + +# 将 Wiki 内容保存到文件 +gitlink-cli wiki +view --title "Home" --format json | jq -r ".data.content_decoded" > home.md +``` + +## 参数 + +| 参数 | Short | 必填 | 说明 | +|------|-------|------|------| +| `--title` | `-t` | **是** | Wiki 页面标题(区分大小写) | +| `--owner` | | 否 | 仓库所有者(自动从 git remote 解析) | +| `--repo` | | 否 | 仓库名称(自动从 git remote 解析) | +| `--format` | | 否 | 输出格式: `json`/`table`/`yaml` | +| `--debug` | | 否 | 开启调试输出 | + +## 返回字段 + +### Table 格式 + +| 列名 | 说明 | +|------|------| +| `title` | Wiki 页面标题 | +| `content` | 页面内容(自动解码后的文本) | +| `updated_at` | 最后更新时间 | + +### JSON 格式 + +```json +{ + "ok": true, + "data": { + "title": "Home", + "content_base64": "I0hvbWUKCisqKldlbGNvbWUgdG8gdGhlIHByb2plY3QgIWJqKio=", + "content_decoded": "# Home\n\n**Welcome to the project!**\n\n## Getting Started\n...", + "updated_at": "2026-06-01T10:30:00Z" + } +} +``` + +**字段说明**: +- `content_base64` - 原始 Base64 编码内容(API 返回) +- `content_decoded` - 自动解码后的文本内容(CLI 提供) + +## Workflow + +1. **Confirm** the page title with the user. +2. **Execute** `gitlink-cli wiki +view --title "<page title>"`. +3. **Display** the page content (auto-decoded). +4. **Optional**: Save content to file if requested. + +## API + +``` +GET https://gateway.gitlink.org.cn/api/wiki/open/getWiki +Query: owner={owner}&repo={repo}&projectId={project_id}&pageName={title} +``` + +**工作流程**: +1. CLI 获取 `project_id` +2. 调用 Gateway API 获取页面 +3. 解码 `content_base64` 为 `content_decoded` +4. 返回解码后的内容 + +## 使用场景 + +### 场景 1: 查看单个页面 + +当用户询问"查看 API 文档页面"时: + +```bash +gitlink-cli wiki +view --title "API Reference" +``` + +### 场景 2: 导出 Wiki 页面 + +```bash +# 导出为 Markdown 文件 +gitlink-cli wiki +view --title "Home" --format json | \ + jq -r ".data.content_decoded" > home.md +``` + +### 场景 3: 批量导出所有 Wiki 页面 + +```bash +#!/bin/bash +# 导出所有 Wiki 页面为 Markdown 文件 + +titles=$(gitlink-cli wiki +list --format json | jq -r ".data[].title") + +for title in $titles; do + # 清理文件名(替换空格和特殊字符) + filename=$(echo "$title" | sed 's/[^a-zA-Z0-9_-]/_/g').md + + echo "Exporting: $title -> $filename" + gitlink-cli wiki +view --title "$title" --format json | \ + jq -r ".data.content_decoded" > "$filename" +done +``` + +### 场景 4: 检查页面是否存在 + +```bash +# 检查页面是否存在(退出码 0=存在,非0=不存在) +if gitlink-cli wiki +view --title "Some Page" >/dev/null 2>&1; then + echo "Page exists" +else + echo "Page does not exist" +fi +``` + +## 常见问题 + +### Q: 提示 "page not found"? + +**A:** 可能原因: +1. 页面标题不匹配(区分大小写) +2. 页面不存在 +3. `owner/repo` 指定错误 + +**解决方法**: +```bash +# 先列出所有页面确认标题 +gitlink-cli wiki +list +``` + +### Q: 内容显示为乱码? + +**A:** 确保: +1. 内容是有效的 UTF-8 编码 +2. 使用 `--format json` 查看 `content_decoded` 字段 +3. 终端支持 UTF-8 显示 + +### Q: 如何获取原始 Base64 内容? + +**A:** 使用 JSON 格式查看 `content_base64` 字段: +```bash +gitlink-cli wiki +view --title "Home" --format json | jq ".data.content_base64" +``` + +### Q: 支持哪些 Markdown 语法? + +**A:** GitLink Wiki 支持 CommonMark 标准,包括: +- 标题、列表、代码块 +- 链接、图片、表格 +- 粗体、斜体、引用 + +## 错误处理 + +| 错误 | 原因 | 解决方案 | +|------|------|----------| +| `required flag --title is missing` | 未指定页面标题 | 添加 `--title "Page Title"` | +| `failed to fetch project_id` | 项目不存在或无权限 | 检查 `--owner` 和 `--repo` | +| `failed to decode content` | Base64 解码失败 | 检查内容是否为有效 Base64 | +| `404 Not Found` | 页面不存在 | 使用 `wiki +list` 查看可用页面 | + +## 最佳实践 + +### 1. 页面标题规范 + +使用一致的命名规范: +```bash +# 推荐:使用连字符 +"Getting-Started" +"API-Reference" + +# 避免:空格和特殊字符 +"Getting Started" # 需要引号 +"API/Reference" # 斜杠可能被误解为路径 +``` + +### 2. 内容验证 + +查看页面后验证内容完整性: +```bash +gitlink-cli wiki +view --title "Home" --format json | \ + jq -r ".data.content_decoded" | wc -l +``` + +### 3. 批量操作 + +结合其他命令批量处理 Wiki: +```bash +# 查看所有页面的行数 +for title in $(gitlink-cli wiki +list --format json | jq -r ".data[].title"); do + lines=$(gitlink-cli wiki +view --title "$title" --format json | \ + jq -r ".data.content_decoded" | wc -l) + echo "$title: $lines lines" +done +``` + +## References + +- [gitlink-wiki](../SKILL.md) +- [wiki +list](wiki-list.md) — 列出 Wiki 页面 +- [wiki +create](wiki-create.md) — 创建 Wiki 页面 +- [gitlink-shared](../../gitlink-shared/SKILL.md) diff --git a/skills/gitlink-workflow/SKILL.md b/skills/gitlink-workflow/SKILL.md index 9997883..75d23df 100644 --- a/skills/gitlink-workflow/SKILL.md +++ b/skills/gitlink-workflow/SKILL.md @@ -1,11 +1,16 @@ --- name: gitlink-workflow -version: 1.0.0 -description: "AI 自动化工作流:Issue 分类、PR Review、Release Notes 生成、仓库初始化、Sprint 报告等。当用户需要 AI 自动化 GitLink 操作时触发。" +version: 2.0.0 +description: "AI 自动化工作流:社区运营、代码审查、项目初始化、多仓库协同、贡献者成长。当用户需要 AI 自动化 GitLink 操作时触发。" metadata: requires: bins: ["gitlink-cli"] - cliHelp: "gitlink-cli workflow --help" + triggers: + - "工作流" + - "workflow" + - "自动化" + - "帮我跑" + - "执行" --- # gitlink-workflow(AI 自动化工作流) @@ -16,90 +21,69 @@ metadata: 本技能提供 Claude Code 可直接执行的高级工作流模板。 -## 工作流 1:Issue Triage(Issue 自动分类) +## 功能菜单 -**场景**:自动为新 Issue 添加标签分类。 +``` +====== GitLink 自动化工作流 ====== -```bash -# 1. 获取未标记的 Issue 列表 -gitlink-cli issue +list --state open --format json +请选择要执行的工作流: -# 2. 逐个查看 Issue 详情 -gitlink-cli issue +view --id <issue_id> --format json + 1. 社区运营自动化 + → Issue 自动分类、负责人分配、周报生成、Release Notes -# 3. 根据内容分析,通过 Raw API 添加标签 -gitlink-cli api POST /:owner/:repo/issues/:id --body '{"issue_tag_ids":[<tag_id>]}' + 2. 代码质量审查 + → PR Review、AI 四维度评分、自动合并 + + 3. 项目一键初始化 + → 创建仓库、README、CI 配置、初始 Issues、分支保护 + + 4. 多仓库协同 + → 跨仓库 Issue/PR 追踪、状态 Dashboard、协同发版 + + 5. 贡献者成长体系 + → 数据收集、AHP 评分、排行榜、Wiki 发布、Badge 颁发 + +请输入编号(1-5)或功能名称: ``` -**分类规则建议**: -- 标题/描述包含 "bug"、"错误"、"失败" → bug 标签 -- 标题/描述包含 "feature"、"新增"、"建议" → enhancement 标签 -- 标题/描述包含 "question"、"如何"、"怎么" → question 标签 +## 用户输入 → 工作流映射 -## 工作流 2:PR Review(代码审查辅助) +| 用户输入 | 执行的工作流 | Reference 文件 | +|----------|-------------|---------------| +| `1` 或 "社区运营" | 社区运营自动化 | [`workflow-community-ops.md`](references/workflow-community-ops.md) | +| `2` 或 "代码审查" | 代码质量审查 | [`workflow-pr-review.md`](references/workflow-pr-review.md) | +| `3` 或 "项目初始化" | 项目一键初始化 | [`workflow-repo-setup.md`](references/workflow-repo-setup.md) | +| `4` 或 "多仓库" | 多仓库协同 | [`workflow-multi-repo.md`](references/workflow-multi-repo.md) | +| `5` 或 "贡献者" | 贡献者成长体系 | [`workflow-contributor-growth.md`](references/workflow-contributor-growth.md) | -**场景**:获取 PR 变更,分析代码质量,添加 Review 评论。 +## 执行流程 -```bash -# 1. 获取 PR 详情 -gitlink-cli pr +view --id <pr_id> --format json +1. **展示菜单** — 列出所有可用工作流 +2. **获取用户选择** — 用户输入编号或功能名称 +3. **读取 Reference** — 根据选择读取对应的 reference 文件 +4. **确认参数** — 询问必要的参数(owner/repo 等) +5. **执行工作流** — 按照 reference 文件的步骤执行 +6. **展示结果** — 输出执行结果和摘要 -# 2. 获取变更文件列表 -gitlink-cli pr +files --id <pr_id> --format json +## 快捷触发 -# 3. 获取 PR 提交列表 -gitlink-cli pr +diff --id <pr_id> --format json +用户也可以直接说特定意图,跳过菜单直接执行: -# 4. 添加 Review 评论 -gitlink-cli api POST /:owner/:repo/pulls/:id/reviews --body '{"body":"代码审查意见...","event":"COMMENT"}' -``` +| 用户说的话 | 直接执行 | +|------------|----------| +| "帮我跑一下社区运营" | 社区运营自动化 | +| "审查一下这个 PR" | 代码质量审查 | +| "创建一个新项目" | 项目一键初始化 | +| "看看组织下所有仓库" | 多仓库协同 | +| "生成贡献者排行榜" | 贡献者成长体系 | -## 工作流 3:Release Notes 生成 +## 参数说明 -**场景**:从提交历史自动生成版本发布说明。 - -```bash -# 1. 获取两个版本之间的提交 -gitlink-cli api GET /:owner/:repo/compare/:base...:head --format json - -# 2. 获取已关闭的 Issue -gitlink-cli issue +list --state closed --format json - -# 3. 生成 Release Notes 并创建发布 -gitlink-cli release +create --tag v1.2.0 --name "v1.2.0" --body "## What's Changed\n- feat: 新功能 (#123)\n- fix: 修复问题 (#456)" -``` - -## 工作流 4:Repo Setup(仓库初始化) - -**场景**:创建仓库并完成基础配置。 - -```bash -# 1. 创建仓库 -gitlink-cli repo +create --name my-project --description "项目描述" - -# 2. 设置分支保护 -gitlink-cli branch +protect --name main --owner myuser --repo my-project - -# 3. 创建初始 Issue -gitlink-cli issue +create --title "项目初始化" --body "- [ ] 完善 README\n- [ ] 配置 CI\n- [ ] 添加 License" --owner myuser --repo my-project -``` - -## 工作流 5:Sprint Report(Sprint 报告) - -**场景**:汇总 Issue/PR 统计,生成周报。 - -```bash -# 1. 获取 Issue 统计 -gitlink-cli issue +list --state open --format json -gitlink-cli issue +list --state closed --format json - -# 2. 获取 PR 统计 -gitlink-cli pr +list --state open --format json -gitlink-cli pr +list --state merged --format json - -# 3. 获取项目动态 -gitlink-cli api GET /:owner/:repo/activity --format json -``` +| 参数 | 说明 | 获取方式 | +|------|------|----------| +| `--owner` | 仓库所有者 | 自动从 git remote 解析,或询问用户 | +| `--repo` | 仓库名称 | 自动从 git remote 解析,或询问用户 | +| `--org` | 组织名称 | 询问用户(多仓库协同时需要) | ## 最佳实践 @@ -107,3 +91,10 @@ gitlink-cli api GET /:owner/:repo/activity --format json - 写入操作前确认用户意图 - 批量操作建议先用小范围测试 - 保存工作流执行结果以便回溯 +- 自动从 git remote 解析 owner/repo,解析失败时询问用户 +- 支持 `--dry-run` 预览模式(部分工作流) + +## References + +- [gitlink-shared](../gitlink-shared/SKILL.md) — 认证和全局参数 +- [gitlink-workflows](../gitlink-workflows/SKILL.md) — 工作流总入口(含可执行脚本) diff --git a/skills/gitlink-workflow/references/workflow-community-ops.md b/skills/gitlink-workflow/references/workflow-community-ops.md new file mode 100644 index 0000000..75c88c6 --- /dev/null +++ b/skills/gitlink-workflow/references/workflow-community-ops.md @@ -0,0 +1,206 @@ +# Workflow: Community Ops(社区运营自动化) + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **AI Agent 工作流**:此工作流专为 AI Agent 设计,用于自动化社区运营。 + +AI Agent 自动完成社区运营任务,包括 Issue 分类、负责人分配、周报生成、Release Notes 发布。 + +## 工作流概述 + +Community Ops 工作流自动化社区运营的四个核心环节:Issue 分析 → 负责人分配 → 周报生成 → Release Notes 发布。 + +## 适用场景 + +- **Issue 管理**:自动分类和分配新 Issue +- **周报生成**:汇总本周数据生成社区周报 +- **发版管理**:生成 Release Notes 并发布 +- **定期运营**:每周/每月定期执行社区运营 + +## 触发词 + +- "社区运营" / "community ops" +- "周报" / "weekly report" +- "发版" / "release notes" +- "Issue 分类" / "issue triage" + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--owner` | 否 | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否 | 仓库名称(自动从 git remote 解析) | +| `--weeks-ago` | 否 | 生成 N 周前的周报(默认 0 = 本周) | +| `--dry-run` | 否 | 预览模式,不执行写入操作 | + +## 工作流步骤 + +### 阶段 1:Issue 自动分类 + +**步骤 1.1** — 获取 open issues: + +```bash +gitlink-cli issue +list --owner {OWNER} --repo {REPO} --state open --limit 100 --format json +``` + +**步骤 1.2** — 分类规则: + +| 类型 | 标签 | 关键词 | +|------|------|--------| +| Bug | `bug` | bug, error, crash, fault, fix | +| Feature | `feature` | feature, enhancement, add, support, request | +| Question | `question` | how, question, help | +| Docs | `documentation` | doc, readme, guide, tutorial, example | + +**步骤 1.3** — 添加标签: + +```bash +gitlink-cli issue +label-add --owner {OWNER} --repo {REPO} --number {ISSUE_ID} --labels {LABEL} +``` + +### 阶段 2:负责人分配(基于贡献度 AHP 评分) + +**引用 [`workflow-contributor-growth.md`](workflow-contributor-growth.md) 的 AHP 评分模型** + +负责人分配基于贡献者的 AHP 评分,优先分配给贡献度高的成员。 + +**步骤 2.1** — 收集数据: + +```bash +# 获取仓库成员 +gitlink-cli repo +members --owner {OWNER} --repo {REPO} --limit 50 --format json + +# 获取 merged PR(统计贡献) +gitlink-cli pr +list --owner {OWNER} --repo {REPO} --state merged --limit 100 --format json + +# 获取 closed Issues(统计参与) +gitlink-cli issue +list --owner {OWNER} --repo {REPO} --state closed --limit 100 --format json +``` + +**步骤 2.2** — 计算每个成员的 AHP 分数: + +| 维度 | 权重 | 计算方式 | +|------|------|----------| +| Issues Created | 15% | 该成员创建的 Issue 数 / 最大值 | +| PRs Merged | 25% | 该成员合并的 PR 数 / 最大值 | +| Code Changes | 30% | 该成员代码变更行数 / 最大值 | +| Issue Comments | 15% | 该成员评论数 / 最大值 | +| Team Member | 15% | 是成员=1,非成员=0 | + +``` +Score = NI×15 + NM×25 + NL×30 + NC×15 + MS×15 +``` + +**步骤 2.3** — 筛选待分配 Issue 并分配: + +```bash +# 只分配 Bug 和 Feature 类型的 Issue +# Bug → 标签含 "bug"/"缺陷" +# Feature → 标签含 "feature"/"功能" + +# 按分数从高到低排序成员 +# Bug:优先分配给最高分成员(确保快速解决) +# Feature:按分数轮流分配(鼓励参与) + +# 分配命令 +gitlink-cli issue +batch-assign --owner {OWNER} --repo {REPO} --numbers {ISSUE_NUMBERS} --assignee {LOGIN} +``` + +**分配策略**: +- Bug 类型:优先分配给贡献度最高的成员(快速响应) +- Feature 类型:按贡献度轮流分配(鼓励更多人参与) +- 成员不足时用 Round-robin 补充 + +### 阶段 3:周报生成 + +**步骤 3.1** — 收集数据: + +```bash +# Closed issues +gitlink-cli issue +list --owner {OWNER} --repo {REPO} --state closed --limit 100 --format json + +# Merged PRs +gitlink-cli pr +list --owner {OWNER} --repo {REPO} --state merged --limit 100 --format json +``` + +**步骤 3.2** — 周报模板: + +```markdown +# Community Weekly Report: {WEEK_START} ~ {WEEK_END} + +## Summary +- New Issues: **{NEW_COUNT}** +- Closed Issues: **{CLOSED_COUNT}** +- Merged PRs: **{MERGED_COUNT}** + +## Issue Classification +| Type | Count | +|------|-------| +| Bug | {BUG_COUNT} | +| Feature | {FEATURE_COUNT} | +| Question | {QUESTION_COUNT} | +| Docs | {DOCS_COUNT} | + +## Highlights +- Auto-classified and labeled {TOTAL_CLASSIFIED} issues +- Assigned responsible persons for bug and feature issues + +--- +*Auto-generated by gitlink-cli community-ops workflow* +``` + +**步骤 3.3** — 发布到 Wiki: + +```bash +gitlink-cli wiki +create --owner {OWNER} --repo {REPO} --title "{REPORT_TITLE}" --content "{REPORT_BODY}" +``` + +### 阶段 4:Release Notes 生成 + +**引用 [`../workflow-release-notes.md`](workflow-release-notes.md)** + +Release Notes 的生成遵循 Release Notes 工作流的规范: + +1. **收集数据**:获取 commits、merged PR、closed Issue +2. **分类整理**:按类型归类(新功能/Bug修复/改进/破坏性变更) +3. **生成发布**:套用模板生成 Notes,创建 Release + +**快捷命令**: + +```bash +gitlink-cli release +create --tag {TAG} --name "{NAME}" --body "{NOTES}" +``` + +## 完整流程图 + +``` +Issue 分类 → 负责人分配 → 周报生成 → Release Notes + ↓ ↓ ↓ ↓ + 添加标签 API PATCH Wiki 发布 Release 创建 +``` + +## 使用示例 + +### AI 交互式 + +用户说:"帮我跑一下社区运营" + +AI 应该: +1. 确认 owner/repo(自动从 git remote 解析或询问用户) +2. 依次执行四个阶段 +3. 展示每阶段的结果 +4. 询问是否需要调整 + +## 注意事项 + +- Issue 分类基于关键词匹配,可能不准确,建议人工审核 +- 负责人分配只针对 Bug 和 Feature 类型 +- 周报数据基于时间范围筛选,不是所有 open issues +- Release Notes 生成引用 changelog 工作流的规范 + +## References + +- [workflow-release-notes](workflow-release-notes.md) — Release Notes 生成 +- [workflow-issue-triage](workflow-issue-triage.md) — Issue 分类详情 +- [gitlink-issue](../../gitlink-issue/SKILL.md) — Issue 操作 +- [gitlink-wiki](../../gitlink-wiki/SKILL.md) — Wiki 操作 +- [gitlink-release](../../gitlink-release/SKILL.md) — Release 操作 diff --git a/skills/gitlink-workflow/references/workflow-contributor-growth.md b/skills/gitlink-workflow/references/workflow-contributor-growth.md new file mode 100644 index 0000000..ab02d16 --- /dev/null +++ b/skills/gitlink-workflow/references/workflow-contributor-growth.md @@ -0,0 +1,245 @@ +# Workflow: Contributor Growth(贡献者成长体系) + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **AI Agent 工作流**:此工作流专为 AI Agent 设计,用于评估和激励团队贡献者。 + +AI Agent 自动收集贡献者数据,使用 AHP 权重模型计算贡献分数,生成排行榜和可视化报告,并可选择颁发 Badge。 + +## 工作流概述 + +Contributor Growth 工作流通过收集 Issue、PR、代码变更、评论等数据,使用 AHP(层次分析法)权重模型计算每个贡献者的综合分数,生成排行榜、HTML 报告和 Wiki 页面,并可自动颁发 Badge。 + +## 适用场景 + +- **贡献评估**:量化团队成员的贡献程度 +- **排行榜生成**:生成贡献者排行榜 +- **激励机制**:通过 Badge 颁发激励贡献者 +- **团队协调**:了解团队成员的参与度 + +## 触发词 + +- "贡献者" / "contributor" +- "排行榜" / "leaderboard" +- "badge" / "徽章" +- "成长" / "growth" +- "评分" / "score" + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--owner` | 否 | 仓库所有者(自动从 git remote 解析) | +| `--repo` | 否 | 仓库名称(自动从 git remote 解析) | +| `--sample` | 否 | 采样 PR 数量获取代码统计(默认 10) | +| `--award` | 否 | 自动创建 Badge 颁发 Issue | + +## AHP 评分模型 + +| 维度 | 权重 | 数据来源 | 提取字段 | +|------|------|----------|----------| +| Issues Created | 15% | `issue +list` (open + closed) | `.data.issues[].author.login` 按作者统计 | +| PRs Merged | 25% | `pr +list state=merged` | `.data.issues[].author_login` 按作者统计 | +| Code Changes | 30% | `pr +files`(采样) | `.data.files[].addition` / `.deletion`(单数) | +| Issue Comments | 15% | `issue +list` 直接提取 | `.data.issues[].comment_journals_count` | +| Team Member | 15% | `repo +members` | `.data.members[].login` 判断是否成员 | + +## Badge 等级 + +| Badge | 分数要求 | 说明 | +|-------|----------|------| +| Champion | >= 80 | 卓越贡献 | +| Core Contributor | >= 60 | 核心贡献者 | +| Active Contributor | >= 40 | 活跃贡献者 | +| Contributor | >= 20 | 贡献者 | +| Newcomer | < 20 | 新人 | + +## 工作流步骤 + +### 步骤 1:收集数据 + +```bash +# Open Issues +gitlink-cli issue +list --owner {OWNER} --repo {REPO} --state open --limit 100 --format json + +# Closed Issues +gitlink-cli issue +list --owner {OWNER} --repo {REPO} --state closed --limit 100 --format json + +# Merged PRs +gitlink-cli pr +list --owner {OWNER} --repo {REPO} --state merged --limit 100 --format json + +# 仓库成员 +gitlink-cli repo +members --owner {OWNER} --repo {REPO} --limit 100 --format json +``` + +### 步骤 2:构建贡献者数据 + +遍历数据,为每个贡献者统计: +- Issues:创建的 Issue 数量 +- Merged:合并的 PR 数量 +- Additions/Deletions:代码变更行数(采样) +- Comments:评论数量(采样) + +### 步骤 3:采样代码变更 + +对 merged PR 采样,获取代码变更统计: + +```bash +# 获取 PR 变更文件 +gitlink-cli pr +files --owner {OWNER} --repo {REPO} --id {PR_ID} --format json +``` + +提取字段:`.data.files[].addition`、`.data.files[].deletion`(**注意:是单数形式,不是 additions/deletions**) + +**已知问题**: +- `pr +files` 输出的 JSON 在中文 Windows 下可能因编码问题导致解析失败(GBK vs UTF-8) +- 如果 Python 解析报错 `JSONDecodeError` 或 `UnicodeDecodeError`,需要指定 `encoding='utf-8'` +- 部分 PR 的 files API 可能返回异常,用 try/except 跳过即可 +- 降级方案:用 PR 数量估算代码变更量(每个 PR 估 100 行) + +### 步骤 4:采样评论数量 + +有两种方式获取评论数据: + +**方式 A(推荐)**:直接从 `issue +list` 返回的数据中提取 +- 字段:`.data.issues[].comment_journals_count` +- 无需额外 API 调用,效率更高 + +**方式 B**:逐个 Issue 采样 +```bash +gitlink-cli issue +view --owner {OWNER} --repo {REPO} --number {ISSUE_ID} --format json +``` +- 字段:`.data.comment_journals_count` + +**补充**:PR 的评论数可从 `pr +list` 返回的 `.data.issues[].journals_count` 获取 + +### 步骤 5:计算 AHP 分数 + +```bash +# 归一化处理 +NI = Issues / MaxIssues +NM = Merged / MaxMerged +NL = Lines / MaxLines +NC = Comments / MaxComments +MS = IsMember ? 1 : 0 + +# 加权求和 +Score = NI * 15 + NM * 25 + NL * 30 + NC * 15 + MS * 15 +``` + +### 步骤 6:生成排行榜 + +输出格式: + +``` +Rank Contributor Issues Merged +/- Lines Comments Score Badge +---- ------------------ ------ ------ --------- -------- ------ ------------- +1 @contributor1 15 8 +1200/-300 45 82.5 Champion +2 @contributor2 10 12 +800/-200 30 68.2 Core Contributor +3 @contributor3 5 6 +400/-100 20 45.1 Active Contributor +``` + +### 步骤 7:生成 HTML 报告 + +HTML 报告包含: +- 汇总统计卡片 +- ECharts 饼图(分数分布) +- 详细排行榜表格 +- AHP 权重说明 + +### 步骤 8:发布到 Wiki + +```bash +gitlink-cli wiki +create \ + --owner {OWNER} \ + --repo {REPO} \ + --title "Contributor Leaderboard {DATE}" \ + --content "{WIKI_CONTENT}" +``` + +Wiki 内容包含: +- 评分体系说明 +- 排行榜表格 +- 生成时间 + +### 步骤 9:颁发 Badge(可选) + +如果指定了 `--award` 参数,为获奖者创建 Issue: + +```bash +gitlink-cli issue +create \ + --owner {OWNER} \ + --repo {REPO} \ + --title "Badge Award: {BADGE}" \ + --body "{ISSUE_BODY}" + +gitlink-cli issue +label-add \ + --owner {OWNER} \ + --repo {REPO} \ + --number {ISSUE_ID} \ + --labels badge +``` + +## 输出示例 + +### 控制台输出 + +``` +====== Contributor Growth System: zzx-coder/gitlink-cli ====== + +[STEP] Collecting data... +[ OK] Issues(open:15 closed:30) PRs(merged:25) Members:8 + +[STEP] Building contributor profiles... +[STEP] Analyzing PR code changes (sampling 10)... +[STEP] Sampling issue comments... + +[STEP] Calculating scores... + +====== Contributor Rankings ====== + +Rank Contributor Issues Merged +/- Lines Comments Score Badge +---- ------------------ ------ ------ --------- -------- ------ ------------- +1 @zzx-coder 12 8 +1500/-400 35 85.2 Champion +2 @contributor1 8 6 +800/-200 25 62.1 Core Contributor +3 @contributor2 5 4 +400/-100 15 42.3 Active Contributor + +====== Generating HTML Report ====== +[ OK] HTML report: contrib-report-zzx-coder-gitlink-cli.html + +[STEP] Publishing to Wiki... +[ OK] Published to Wiki: Contributor Leaderboard 2026-07-02 + +====== Awarding Badges ====== +[ OK] Badge issue created: #45 - Badge Award: Champion (1 recipients) +[ OK] Badge issue created: #46 - Badge Award: Core Contributor (1 recipients) + +====== Complete ====== + + Contributors: 8 + HTML Report: contrib-report-zzx-coder-gitlink-cli.html + Wiki: Contributor Leaderboard 2026-07-02 + Badges: Awarded +``` + +## 注意事项 + +- 代码变更统计基于采样,不是全量数据 +- Badge 颁发会创建 issue,需要确认权限 +- HTML 报告使用 ECharts 需要网络连接 +- 建议定期运行(如每月)跟踪贡献趋势 + +## 已知问题与解决方案 + +| 问题 | 原因 | 解决方案 | +|------|------|----------| +| Python `UnicodeDecodeError: 'gbk' codec` | 中文 Windows 默认 GBK 编码,gitlink-cli 输出 UTF-8 | `open(file, encoding='utf-8')` | +| `pr +files` JSON 解析失败 | 输出含特殊字符,GBK 解码破坏 UTF-8 序列 | 用 `encoding='utf-8'` 读取,或用 try/except 跳过 | +| `pr +files` 字段名为 `addition` 不是 `additions` | GitLink API 使用单数形式 | 用 `f.get('addition', f.get('additions', 0))` 兼容 | +| Issue 数据中 `comment_journals_count` 为 0 | 某些 Issue 确实没有评论 | 正常现象,不影响评分 | + +## References + +- [gitlink-issue](../../gitlink-issue/SKILL.md) — Issue 操作 +- [gitlink-pr](../../gitlink-pr/SKILL.md) — PR 操作 +- [gitlink-repo](../../gitlink-repo/SKILL.md) — 仓库操作 +- [gitlink-wiki](../../gitlink-wiki/SKILL.md) — Wiki 操作 diff --git a/skills/gitlink-workflow/references/workflow-issue-triage.md b/skills/gitlink-workflow/references/workflow-issue-triage.md new file mode 100644 index 0000000..66b5339 --- /dev/null +++ b/skills/gitlink-workflow/references/workflow-issue-triage.md @@ -0,0 +1,313 @@ +# Workflow: Issue Triage(Issue 自动分类) + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **AI Agent 工作流**:此工作流专为 AI Agent 设计,用于自动化 Issue 分类和管理。 + +AI Agent 自动为新建的 Issue 添加标签和分类,提高项目管理效率。 + +## 工作流概述 + +Issue Triage 工作流通过分析 Issue 的标题和描述内容,自动为 Issue 分配合适的标签,帮助项目维护者更好地组织和管理 Issue。 + +## 适用场景 + +- **项目维护**:自动分类新提交的 Issue +- **标签管理**:确保 Issue 有正确的分类标签 +- **优先级排序**:基于分类快速识别高优先级 Issue +- **团队协作**:减少手动分类工作量,提高效率 + +## 工作流步骤 + +### 步骤 1:获取未标记的 Issue 列表 + +```bash +# 获取所有开放的 Issue +gitlink-cli issue +list --state open --format json + +# 筛选出没有标签的 Issue +gitlink-cli issue +list --state open --format json | \ + jq '.data.issues[] | select(.issue_tags == null or .issue_tags == [])' +``` + +### 步骤 2:分析 Issue 内容 + +```bash +# 获取特定 Issue 的详细信息 +gitlink-cli issue +view --id 123 --format json + +# 分析标题和描述 +gitlink-cli issue +view --id 123 --format json | \ + jq '{subject: .data.subject, description: .data.description}' +``` + +### 步骤 3:智能分类 + +基于 Issue 内容的分析,应用以下分类规则: + +**Bug 分类规则**: +- 标题/描述包含关键词:`bug`、`错误`、`失败`、`异常`、`crash`、`issue`、`problem` +- 行为模式:描述功能失效或异常行为 +- 示例:`登录时遇到错误`、`页面加载失败` + +**Feature 分类规则**: +- 标题/描述包含关键词:`feature`、`新增`、`建议`、`request`、`enhancement`、`improve` +- 行为模式:建议新功能或改进 +- 示例:`添加用户权限管理`、`建议支持暗色主题` + +**Question 分类规则**: +- 标题/描述包含关键词:`question`、`如何`、`怎么`、`how`、`帮助`、`help`、`疑问` +- 行为模式:询问使用方法或寻求帮助 +- 示例:`如何配置环境变量`、`怎么部署到服务器` + +**Documentation 分类规则**: +- 标题/描述包含关键词:`doc`、`文档`、`README`、`tutorial`、`guide`、`example` +- 行为模式:与文档相关的问题或建议 +- 示例:`更新安装文档`、`添加使用示例` + +### 步骤 4:添加标签 + +```bash +# 获取项目的标签列表 +gitlink-cli api GET /:owner/:repo/issue_tags --format json + +# 为 Issue 添加标签 +gitlink-cli api POST /:owner/:repo/issues/123 --body \ + '{"issue_tag_ids":[1, 2]}' + +# 添加单个标签 +gitlink-cli api POST /:owner/:repo/issues/123 --body \ + '{"issue_tag_ids":[1]}' +``` + +## 完整工作流示例 + +```bash +#!/bin/bash + +# Issue Triage 自动化脚本 + +OWNER="myuser" +REPO="myproject" + +# 1. 获取所有开放的 Issue +ISSUES=$(gitlink-cli issue +list --owner $OWNER --repo $REPO --state open --format json) + +# 2. 遍历每个 Issue +echo "$ISSUES" | jq -c '.data.issues[]' | while read -r issue; do + ISSUE_ID=$(echo "$issue" | jq -r '.id') + SUBJECT=$(echo "$issue" | jq -r '.subject') + DESCRIPTION=$(echo "$issue" | jq -r '.description') + TAGS=$(echo "$issue" | jq -r '.issue_tags // []') + + # 跳过已有标签的 Issue + if [ "$TAGS" != "[]" ]; then + echo "Issue $ISSUE_ID 已有标签,跳过" + continue + fi + + echo "分析 Issue $ISSUE_ID: $SUBJECT" + + # 分析内容并确定标签 + TAG_IDS=() + CONTENT="$SUBJECT $DESCRIPTION" + + # 分类逻辑 + if echo "$CONTENT" | grep -iqE "bug|错误|失败|异常|crash|issue|problem"; then + TAG_IDS+=("1") # 假设 1 是 bug 标签 + echo " → 分类为: bug" + fi + + if echo "$CONTENT" | grep -iqE "feature|新增|建议|request|enhancement|improve"; then + TAG_IDS+=("2") # 假设 2 是 enhancement 标签 + echo " → 分类为: enhancement" + fi + + if echo "$CONTENT" | grep -iqE "question|如何|怎么|how|帮助|help|疑问"; then + TAG_IDS+=("3") # 假设 3 是 question 标签 + echo " → 分类为: question" + fi + + if echo "$CONTENT" | grep -iqE "doc|文档|README|tutorial|guide|example"; then + TAG_IDS+=("4") # 假设 4 是 documentation 标签 + echo " → 分类为: documentation" + fi + + # 添加标签到 Issue + if [ ${#TAG_IDS[@]} -gt 0 ]; then + echo " → 为 Issue $ISSUE_ID 添加标签: ${TAG_IDS[*]}" + # 实际执行时取消注释 + # gitlink-cli api POST "/$OWNER/$REPO/issues/$ISSUE_ID" --body \ + # "{\"issue_tag_ids\":[${TAG_IDS[*]}]}" + else + echo " → 无法自动分类,需要人工处理" + fi + + echo "" +done +``` + +## AI Agent 集成示例 + +Claude Code 等 AI Agent 可以直接执行此工作流: + +```python +# AI Agent 执行 Issue Triage +def issue_triage(owner, repo): + """AI Agent 自动分类 Issue""" + + # 1. 获取开放的 Issue + issues = gitlink_cli_issue_list(owner, repo, state="open") + + for issue in issues: + # 2. 跳过已有标签的 + if issue.get('issue_tags'): + continue + + # 3. AI 分析内容 + content = f"{issue['subject']} {issue.get('description', '')}" + classification = analyze_issue_content(content) + + # 4. 添加标签 + if classification: + add_issue_tags(owner, repo, issue['id'], classification) + +def analyze_issue_content(content): + """AI 分析 Issue 内容""" + # 使用 AI 模型分析文本 + labels = [] + + if any(word in content.lower() for word in ['bug', 'error', 'fail']): + labels.append('bug') + + if any(word in content.lower() for word in ['feature', 'enhancement']): + labels.append('enhancement') + + return labels +``` + +## 高级分类策略 + +### 多标签分类 +一个 Issue 可以有多个标签: + +```bash +# 同时添加多个标签 +gitlink-cli api POST /:owner/:repo/issues/123 --body \ + '{"issue_tag_ids":[1, 2, 5]}' + +# 示例:既严重又是功能请求 +# bug + enhancement + high-priority +``` + +### 优先级分类 +基于紧急程度添加优先级标签: + +**高优先级关键词**: +- `urgent`、`紧急`、`严重`、`critical`、`blocking`、`阻塞` + +**中优先级关键词**: +- `moderate`、`中等`、`normal`、`常规` + +**低优先级关键词**: +- `low`、`较低`、`minor`、`次要`、`nice-to-have` + +### 复杂度分类 +基于实现难度分类: + +**简单**: +- 关键词:`简单`、`easy`、`quick`、`minor` +- 预估时间:1-2 天 + +**中等**: +- 关键词:`中等`、`moderate`、`normal` +- 预估时间:3-7 天 + +**复杂**: +- 关键词:`复杂`、`complex`、`hard`、`major`、`重构` +- 预估时间:8+ 天 + +## 自定义分类规则 + +根据项目特点定制分类规则: + +```bash +# Web 项目特定分类 +WEB_KEYWORDS=("前端" "frontend" "UI" "界面" "页面") +if grep -qE "${WEB_KEYWORDS[*]}" <<< "$CONTENT"; then + TAG_IDS+=("10") # frontend 标签 +fi + +# 后端项目特定分类 +BACKEND_KEYWORDS=("后端" "backend" "API" "接口" "数据库") +if grep -qE "${BACKEND_KEYWORDS[*]}" <<< "$CONTENT"; then + TAG_IDS+=("11") # backend 标签 +fi + +# DevOps 相关分类 +DEVOPS_KEYWORDS=("部署" "deploy" "CI" "CD" "Docker" "Kubernetes") +if grep -qE "${DEVOPS_KEYWORDS[*]}" <<< "$CONTENT"; then + TAG_IDS+=("12") # devops 标签 +fi +``` + +## 错误处理 + +常见问题处理: + +| 问题 | 原因 | 解决方案 | +|------|------|----------| +| 标签 ID 不存在 | 标签未创建 | 先创建项目标签 | +| 权限不足 | 无修改 Issue 权限 | 联系项目管理员 | +| 分类不准确 | 关键词匹配失败 | 优化分类规则或人工审核 | + +## 质量保证 + +确保分类质量的措施: + +1. **定期审查**:定期审查自动分类结果 +2. **反馈学习**:根据反馈调整分类规则 +3. **人工确认**:对不确定的分类进行人工确认 +4. **规则优化**:持续优化关键词匹配规则 + +## 最佳实践 + +1. **渐进式部署**:先小范围测试,再全面应用 +2. **规则透明**:记录分类规则,便于团队理解和调整 +3. **性能监控**:监控分类准确率和效率 +4. **用户反馈**:收集用户反馈,持续改进 + +## 扩展功能 + +### 自动分配 +基于分类自动分配给合适的开发者: + +```bash +# Bug 分配给核心开发者 +if [[ " ${TAG_IDS[@]} " =~ " 1 " ]]; then + ASSIGNEE="senior_developer" +fi + +# 文档问题分配给技术写作 +if [[ " ${TAG_IDS[@]} " =~ " 4 " ]]; then + ASSIGNEE="tech_writer" +fi +``` + +### 自动设置优先级 +基于分类和关键词自动设置优先级: + +```bash +# 严重 bug 设置为高优先级 +if [[ " ${TAG_IDS[@]} " =~ " 1 " ]] && echo "$CONTENT" | grep -iq "严重"; then + PRIORITY_ID="1" # 高优先级 +fi +``` + +## References + +- [workflow-pr-review](workflow-pr-review.md) — PR 审查工作流 +- [workflow-release-notes](workflow-release-notes.md) — Release Notes 生成 +- [gitlink-workflow](../SKILL.md) — 工作流总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 +- [issue +list](../../gitlink-issue/references/gitlink-issue-list.md) — Issue 列表 +- [issue +view](../../gitlink-issue/references/gitlink-issue-view.md) — 查看 Issue diff --git a/skills/gitlink-workflow/references/workflow-multi-repo.md b/skills/gitlink-workflow/references/workflow-multi-repo.md new file mode 100644 index 0000000..e8830e9 --- /dev/null +++ b/skills/gitlink-workflow/references/workflow-multi-repo.md @@ -0,0 +1,199 @@ +# Workflow: Multi-Repo Collaboration(多仓库协同) + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **AI Agent 工作流**:此工作流专为 AI Agent 设计,用于跨仓库协同管理。 + +AI Agent 自动汇总组织下所有仓库的 Issue、PR、Release 状态,生成可视化 Dashboard,支持协同发版。 + +## 工作流概述 + +Multi-Repo Collaboration 工作流通过遍历组织下的所有仓库,收集各仓库的 Issue、PR、Release 数据,生成统一的状态 Dashboard,并支持跨仓库协同发版。 + +## 适用场景 + +- **状态总览**:查看组织下所有仓库的健康状态 +- **跨仓库追踪**:追踪跨仓库的 Issue 和 PR +- **协同发版**:多个仓库同时发布同一版本 +- **团队协调**:协调团队在多个项目间的工作 + +## 触发词 + +- "多仓库" / "multi repo" +- "协同" / "collaboration" +- "dashboard" / "看板" +- "组织仓库" / "org repos" + +## 参数 + +| 参数 | 必填 | 说明 | +|------|------|------| +| `--org` | 是 | 组织名称 | +| `--repos` | 否 | 指定仓库列表(逗号分隔),默认遍历所有仓库 | +| `--release` | 否 | 协同发版的版本号 | +| `--output` | 否 | Dashboard 输出文件(默认 dashboard.html) | + +## 工作流步骤 + +### 步骤 1:列出组织仓库 + +```bash +# 获取组织下所有仓库 +gitlink-cli repo +list --user {ORG} --limit 100 --format json +``` + +提取仓库列表:`.data.projects[]` 或 `.data[]`,字段:`.name` 或 `.identifier` + +### 步骤 2:收集各仓库数据 + +遍历每个仓库,收集 Issue、PR、Release 数据: + +```bash +# Open Issues +gitlink-cli issue +list --owner {ORG} --repo {REPO} --state open --limit 50 --format json + +# Closed Issues +gitlink-cli issue +list --owner {ORG} --repo {REPO} --state closed --limit 50 --format json + +# Open PRs +gitlink-cli pr +list --owner {ORG} --repo {REPO} --state open --limit 50 --format json + +# Merged PRs +gitlink-cli pr +list --owner {ORG} --repo {REPO} --state merged --limit 50 --format json + +# Latest Release +gitlink-cli release +list --owner {ORG} --repo {REPO} --limit 1 --format json +``` + +### 步骤 3:生成 Dashboard + +Dashboard 包含: +- **汇总卡片**:总仓库数、Open Issues、Open PRs、总活动量 +- **仓库状态表**:每个仓库的 Issue/PR/Release 状态和健康度 + +**健康度判断**: +- Open Issues <= 10 → Healthy(绿色) +- Open Issues 11-20 → Moderate(橙色) +- Open Issues > 20 → Needs Attention(红色) + +### 步骤 4:协同发版(可选) + +如果指定了 `--release` 参数,为所有仓库创建 Release: + +```bash +gitlink-cli release +create \ + --owner {ORG} \ + --repo {REPO} \ + --tag {VERSION} \ + --name "Release {VERSION}" \ + --body "Coordinated release {VERSION} for {REPO}" +``` + +## 数据提取 + +### Issue 数量 + +```bash +# Open Issues 数量 +OPEN_COUNT=$(gitlink-cli issue +list --owner {ORG} --repo {REPO} --state open --limit 50 --format json | jq '.data.issues | length') + +# Closed Issues 数量 +CLOSED_COUNT=$(gitlink-cli issue +list --owner {ORG} --repo {REPO} --state closed --limit 50 --format json | jq '.data.issues | length') +``` + +### PR 数量 + +```bash +# Open PRs 数量 +OPEN_PRS=$(gitlink-cli pr +list --owner {ORG} --repo {REPO} --state open --limit 50 --format json | jq '.data.issues | length') + +# Merged PRs 数量 +MERGED_PRS=$(gitlink-cli pr +list --owner {ORG} --repo {REPO} --state merged --limit 50 --format json | jq '.data.issues | length') +``` + +### Release 状态 + +```bash +# 最新 Release +LATEST_RELEASE=$(gitlink-cli release +list --owner {ORG} --repo {REPO} --limit 1 --format json | jq -r '.data.releases[0].tag_name // "none"') +``` + +## 输出示例 + +### Dashboard HTML + +```html +<!DOCTYPE html> +<html> +<head> + <title>Multi-Repo Collaboration Dashboard + + + +

    Multi-Repo Collaboration Dashboard

    +
    +
    Total Repos: 10
    +
    Open Issues: 45
    +
    Open PRs: 12
    +
    Total Activity: 156
    +
    + + + + + + + + + + + + + + + +
    RepositoryOpen IssuesClosed IssuesOpen PRsMerged PRsLatest ReleaseHealth
    + + +``` + +### 控制台输出 + +``` +====== Multi-Repo Collaboration Dashboard ====== + +[STEP] Fetching repositories for org: myorg... +[ OK] Found 10 repositories + +[STEP] Processing repo1... +[ OK] repo1 : Issues(open:5 closed:12) PRs(open:2 merged:8) Release:v1.2.0 + +[STEP] Processing repo2... +[ OK] repo2 : Issues(open:15 closed:20) PRs(open:5 merged:15) Release:v2.0.0 + +====== Generating Dashboard ====== +[ OK] Dashboard saved to: dashboard.html + +====== Multi-Repo Dashboard Complete ====== + + Repos processed: 10 + Total issues: 156 (open: 45) + Total PRs: 89 (open: 12) + Dashboard: dashboard.html +``` + +## 注意事项 + +- 大量仓库时注意 API 限流,建议添加延时 +- Release 状态可能需要二次验证(GitLink API bug) +- Dashboard HTML 文件可以用浏览器打开查看 +- 协同发版前建议先预览各仓库状态 + +## References + +- [gitlink-repo](../../gitlink-repo/SKILL.md) — 仓库操作 +- [gitlink-issue](../../gitlink-issue/SKILL.md) — Issue 操作 +- [gitlink-pr](../../gitlink-pr/SKILL.md) — PR 操作 +- [gitlink-release](../../gitlink-release/SKILL.md) — Release 操作 +- [gitlink-org](../../gitlink-org/SKILL.md) — 组织管理 diff --git a/skills/gitlink-workflow/references/workflow-pr-review.md b/skills/gitlink-workflow/references/workflow-pr-review.md new file mode 100644 index 0000000..1faed03 --- /dev/null +++ b/skills/gitlink-workflow/references/workflow-pr-review.md @@ -0,0 +1,397 @@ +# Workflow: PR Review(代码审查辅助) + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **AI Agent 工作流**:此工作流专为 AI Agent 设计,用于辅助代码审查。 + +AI Agent 获取 PR 变更内容,分析代码质量,自动添加 Review 评论,提高代码审查效率。 + +## 工作流概述 + +PR Review 工作流通过分析 Pull Request 的代码变更,自动识别潜在问题、提出改进建议,并生成结构化的审查意见。 + +## 适用场景 + +- **代码审查**:自动化 PR 初步审查 +- **质量检查**:检查代码质量和规范合规性 +- **安全审查**:识别潜在的安全问题 +- **性能分析**:评估性能相关代码变更 +- **文档检查**:验证代码注释和文档完整性 + +## 工作流步骤 + +### 步骤 1:获取 PR 详情 + +```bash +# 获取 PR 基本信息 +gitlink-cli pr +view --id 42 --format json + +# 提取关键信息 +PR_INFO=$(gitlink-cli pr +view --id 42 --format json | jq '.data') +PR_TITLE=$(echo "$PR_INFO" | jq -r '.title') +PR_AUTHOR=$(echo "$PR_INFO" | jq -r '.author.login') +SOURCE_BRANCH=$(echo "$PR_INFO" | jq -r '.head_ref') +TARGET_BRANCH=$(echo "$PR_INFO" | jq -r '.base_ref') +``` + +### 步骤 2:获取变更文件列表 + +```bash +# 获取 PR 变更的文件列表 +gitlink-cli pr +files --id 42 --format json + +# 分析文件变更 +FILES_CHANGED=$(gitlink-cli pr +files --id 42 --format json | \ + jq '.data.files[] | + {filename: .filename, + status: .status, + additions: .additions, + deletions: .deletions}') +``` + +### 步骤 3:获取代码差异 + +```bash +# 获取 PR 的完整代码差异 +gitlink-cli pr +diff --id 42 --format json + +# 提取特定文件的差异 +gitlink-cli pr +diff --id 42 --format json | \ + jq '.data.diff | split("diff --git")' +``` + +### 步骤 4:代码质量分析 + +分析代码变更的多个维度: + +**安全性分析**: +```bash +# 检查敏感信息泄露 +if echo "$DIFF" | grep -iE "password|secret|api_key|token"; then + SECURITY_ISSUES+=("可能包含敏感信息") +fi + +# 检查 SQL 注入风险 +if echo "$DIFF" | grep -iE "SELECT.*FROM.*WHERE.*\$"; then + SECURITY_ISSUES+=("可能的 SQL 注入风险") +fi +``` + +**代码规范检查**: +```bash +# 检查代码风格 +if echo "$DIFF" | grep -P "\t"; then + STYLE_ISSUES+=("包含 Tab 字符,建议使用空格") +fi + +# 检查长行 +if echo "$DIFF" | grep ".\{120,\}"; then + STYLE_ISSUES+=("包含超过 120 字符的长行") +fi +``` + +**性能分析**: +```bash +# 检查可能的性能问题 +if echo "$DIFF" | grep -iE "N\+1|SELECT.*\*|foreach.*query"; then + PERF_ISSUES+=("可能的 N+1 查询问题") +fi +``` + +### 步骤 5:生成 Review 评论 + +```bash +# 生成结构化的 Review 评论 +REVIEW_BODY="# 🔍 代码审查结果 + +## ✅ 优点 +- 代码结构清晰 +- 逻辑正确 +- 遵循项目规范 + +## ⚠️ 需要改进 +${STYLE_ISSUES[@]+($(printf -- "- %s\n" "${STYLE_ISSUES[@]}"))} + +## 🔒 安全问题 +${SECURITY_ISSUES[@]+($(printf -- "- %s\n" "${SECURITY_ISSUES[@]}"))} + +## 🚀 性能建议 +${PERF_ISSUES[@]+($(printf -- "- %s\n" "${PERF_ISSUES[@]}"))} + +## 📝 总体评价 +代码整体质量良好,建议修改上述问题后合并。" + +# 添加 Review 评论 +gitlink-cli api POST /:owner/:repo/pulls/42/reviews --body \ + "{\"body\":\"$REVIEW_BODY\",\"event\":\"COMMENT\"}" +``` + +## 完整工作流示例 + +```bash +#!/bin/bash + +# PR Review 自动化脚本 + +PR_ID=$1 +OWNER="myuser" +REPO="myproject" + +echo "开始审查 PR #$PR_ID..." + +# 1. 获取 PR 详情 +PR_INFO=$(gitlink-cli pr +view --owner $OWNER --repo $REPO --id $PR_ID --format json) +PR_TITLE=$(echo "$PR_INFO" | jq -r '.data.title') +PR_AUTHOR=$(echo "$PR_INFO" | jq -r '.data.author.login') +ADDITIONS=$(echo "$PR_INFO" | jq -r '.data.additions') +DELETIONS=$(echo "$PR_INFO" | jq -r '.data.deletions') + +echo "PR 标题: $PR_TITLE" +echo "PR 作者: $PR_AUTHOR" +echo "代码变更: +$ADDITIONS -$DELETIONS" + +# 2. 获取变更文件 +FILES=$(gitlink-cli pr +files --owner $OWNER --repo $REPO --id $PR_ID --format json) + +# 3. 获取代码差异 +DIFF=$(gitlink-cli pr +diff --owner $OWNER --repo $REPO --id $PR_ID --format json | \ + jq -r '.data.diff') + +# 4. 分析代码 +ISSUES=() +SUGGESTIONS=() + +# 安全性检查 +if echo "$DIFF" | grep -iE "password|secret|api_key|token.*="; then + ISSUES+=("🔒 安全:可能包含硬编码的敏感信息") +fi + +# 代码规范检查 +if echo "$DIFF" | grep -P "\t"; then + SUGGESTIONS+=("📝 规范:建议使用空格代替 Tab") +fi + +# 性能检查 +if echo "$DIFF" | grep -iE "SELECT.*\*.*FROM"; then + SUGGESTIONS+=("🚀 性能:建议明确指定字段而不是使用 *") +fi + +# 5. 生成 Review 评论 +if [ ${#ISSUES[@]} -eq 0 ] && [ ${#SUGGESTIONS[@]} -eq 0 ]; then + REVIEW_BODY="# ✅ 审查通过 + +代码质量良好,没有发现明显问题。可以合并。" + EVENT="APPROVE" +else + REVIEW_BODY="# 🔍 代码审查结果 + +## PR 信息 +- **标题**: $PR_TITLE +- **作者**: $PR_AUTHOR +- **变更**: +$ADDITIONS -$DELETIONS 行 + +## ❌ 需要修复 +$(printf -- "- %s\n" "${ISSUES[@]}") + +## 💡 改进建议 +$(printf -- "- %s\n" "${SUGGESTIONS[@]}") + +## 📋 后续步骤 +1. 修复上述问题 +2. 确保所有测试通过 +3. 更新相关文档" + EVENT="REQUEST_CHANGES" +fi + +# 6. 提交 Review +echo "提交 Review 评论..." +gitlink-cli api POST "/$OWNER/$REPO/pulls/$PR_ID/reviews" --body \ + "{\"body\":\"$REVIEW_BODY\",\"event\":\"$EVENT\"}" + +echo "审查完成!" +``` + +## AI Agent 集成示例 + +Claude Code 等 AI Agent 可以深度集成此工作流: + +```python +# AI Agent 执行 PR Review +def pr_review(owner, repo, pr_id): + """AI Agent 自动代码审查""" + + # 1. 获取 PR 信息 + pr_info = get_pr_details(owner, repo, pr_id) + files_changed = get_pr_files(owner, repo, pr_id) + diff_content = get_pr_diff(owner, repo, pr_id) + + # 2. AI 分析代码 + review_results = { + 'security': analyze_security(diff_content), + 'performance': analyze_performance(diff_content), + 'style': analyze_code_style(diff_content), + 'documentation': analyze_documentation(files_changed), + 'testing': analyze_test_coverage(files_changed) + } + + # 3. 生成审查意见 + review_comment = generate_review_comment(pr_info, review_results) + + # 4. 提交 Review + submit_review(owner, repo, pr_id, review_comment, review_results) + +def analyze_security(diff_content): + """AI 安全性分析""" + issues = [] + + # 检查常见安全问题 + security_patterns = { + 'hardcoded_secrets': r'password\s*=\s*["\'].*["\']', + 'sql_injection': r'SELECT.*FROM.*WHERE.*\${', + 'xss_risk': r'innerHTML\s*=', + 'command_injection': r'system\(|exec\(.*\$' + } + + for issue_name, pattern in security_patterns.items(): + if re.search(pattern, diff_content, re.IGNORECASE): + issues.append({ + 'type': 'security', + 'severity': 'high', + 'issue': issue_name, + 'description': f'检测到 {issue_name} 风险' + }) + + return issues + +def generate_review_comment(pr_info, review_results): + """AI 生成结构化审查意见""" + comment = f"""# 🔍 AI 代码审查报告 + +## PR 概览 +- **标题**: {pr_info['title']} +- **作者**: {pr_info['author']} +- **变更**: +{pr_info['additions']} -{pr_info['deletions']} 行 +- **文件数**: {len(pr_info['files'])} + +## 🔒 安全审查 +""" + + if review_results['security']: + for issue in review_results['security']: + comment += f"- ❌ **{issue['issue']}**: {issue['description']}\n" + else: + comment += "✅ 未发现安全问题\n" + + comment += "\n## 🚀 性能审查\n" + # 类似地添加其他审查结果... + + return comment +``` + +## 审查维度 + +### 1. 安全性审查 +- **敏感信息泄露**:检查硬编码的密码、API 密钥 +- **注入攻击**:SQL 注入、命令注入、XSS 风险 +- **权限控制**:检查权限验证逻辑 +- **数据验证**:输入验证和输出编码 + +### 2. 性能审查 +- **数据库查询**:N+1 查询、缺少索引 +- **内存使用**:内存泄漏、大对象处理 +- **算法复杂度**:时间复杂度和空间复杂度 +- **缓存策略**:缓存命中率和使用合理性 + +### 3. 代码质量审查 +- **代码规范**:命名规范、格式风格 +- **代码结构**:模块化、可读性、可维护性 +- **错误处理**:异常处理完整性 +- **注释文档**:代码注释和文档质量 + +### 4. 测试审查 +- **测试覆盖**:单元测试和集成测试 +- **测试质量**:测试用例的有效性 +- **边界条件**:边界值和异常情况测试 + +### 5. 文档审查 +- **API 文档**:接口文档完整性 +- **用户文档**:用户指南更新 +- **变更日志**:CHANGELOG 更新 + +## 审查决策 + +基于分析结果做出审查决策: + +**APPROVE(通过)**: +- 无严重问题 +- 建议性问题可接受 +- 测试覆盖充分 + +**REQUEST_CHANGES(请求修改)**: +- 存在严重安全问题 +- 重要功能缺失 +- 测试覆盖不足 + +**COMMENT(评论)**: +- 一般性建议 +- 文档改进 +- 代码优化建议 + +## 自动化规则 + +常见问题的自动检测规则: + +```python +AUTO_REVIEW_RULES = { + 'security': { + 'hardcoded_password': { + 'pattern': r'password\s*=\s*["\'][^"\']{8,}["\']', + 'severity': 'high', + 'message': '检测到硬编码密码,请使用环境变量或配置文件' + }, + 'sql_injection': { + 'pattern': r'SELECT.*FROM.*WHERE.*\$[a-z_]+', + 'severity': 'high', + 'message': '可能的 SQL 注入风险,请使用参数化查询' + } + }, + 'performance': { + 'n_plus_one': { + 'pattern': r'for\s+\$.*\{\s*.*SELECT', + 'severity': 'medium', + 'message': '可能的 N+1 查询问题,考虑使用预加载' + }, + 'missing_index': { + 'pattern': r'WHERE.*LIKE.*%.*%', + 'severity': 'low', + 'message': '前缀模糊搜索可能无法使用索引' + } + } +} +``` + +## 最佳实践 + +1. **逐步审查**:先检查严重问题,再检查一般问题 +2. **建设性反馈**:提供具体的改进建议 +3. **平衡严格**:平衡代码质量和开发效率 +4. **学习改进**:从审查中学习,提高代码质量 +5. **团队协作**:与开发者沟通,达成共识 + +## 质量保证 + +确保审查质量: + +1. **双重检查**:重要 PR 进行二次审查 +2. **审查标准**:建立统一的审查标准 +3. **审查培训**:培训审查人员 +4. **反馈收集**:收集对审查质量的反馈 +5. **持续改进**:优化审查流程和规则 + +## References + +- [workflow-issue-triage](workflow-issue-triage.md) — Issue 分类工作流 +- [workflow-release-notes](workflow-release-notes.md) — Release Notes 生成 +- [gitlink-workflow](../SKILL.md) — 工作流总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 +- [pr +view](../../gitlink-pr/references/gitlink-pr-view.md) — 查看 PR +- [pr +files](../../gitlink-pr/references/gitlink-pr-files.md) — 查看 PR 文件变更 diff --git a/skills/gitlink-workflow/references/workflow-release-notes.md b/skills/gitlink-workflow/references/workflow-release-notes.md new file mode 100644 index 0000000..0f0a99a --- /dev/null +++ b/skills/gitlink-workflow/references/workflow-release-notes.md @@ -0,0 +1,413 @@ +# Workflow: Release Notes 生成 + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **AI Agent 工作流**:此工作流专为 AI Agent 设计,用于自动生成版本发布说明。 + +AI Agent 从提交历史、Issue 和 PR 数据自动生成结构化的 Release Notes,确保发布文档的完整性和准确性。 + +## 工作流概述 + +Release Notes 生成工作流自动收集版本间的所有变更信息,整理成结构化的发布说明,包含新功能、Bug 修复、破坏性变更等重要信息。 + +## 适用场景 + +- **版本发布**:为每个新版本生成发布说明 +- **变更追踪**:追踪版本间的具体变更 +- **用户沟通**:向用户清晰传达版本更新内容 +- **历史记录**:维护项目变更历史 + +## 工作流步骤 + +### 步骤 1:确定版本范围 + +```bash +# 获取最新标签 +LATEST_TAG=$(gitlink-cli release +list --format json | \ + jq -r '.data.releases[0].tag_name') + +# 确定新版本号 +NEW_TAG="v1.2.0" + +# 或者获取两个标签之间的差异 +BASE_TAG="v1.1.0" +HEAD_TAG="v1.2.0" +``` + +### 步骤 2:获取提交历史 + +```bash +# 获取版本间的提交比较 +gitlink-cli api GET /:owner/:repo/compare/$BASE_TAG...$HEAD_TAG --format json + +# 提取提交信息 +COMMITS=$(gitlink-cli api GET /:owner/:repo/compare/$BASE_TAG...$HEAD_TAG --format json | \ + jq '.data.commits[] | + {message: .commit.message, + author: .commit.author.name, + date: .commit.author.date, + sha: .sha}') +``` + +### 步骤 3:获取已关闭的 Issue + +```bash +# 获取已关闭的 Issue +CLOSED_ISSUES=$(gitlink-cli issue +list --state closed --format json | \ + jq '.data.issues[] | + select(.closed_at >= "'$START_DATE'") | + {id: .id, + subject: .subject, + labels: [.issue_tags[].name], + closed_at: .closed_at}') +``` + +### 步骤 4:获取合并的 PR + +```bash +# 获取已合并的 PR +MERGED_PRS=$(gitlink-cli pr +list --state merged --format json | \ + jq '.data.prs[] | + select(.merged_at >= "'$START_DATE'") | + {id: .id, + title: .title, + number: .number, + author: .author.login, + merged_at: .merged_at}') +``` + +### 步骤 5:分类和整理变更 + +```bash +# 按变更类型分类 +FEATURES=() +BUG_FIXES=() +ENHANCEMENTS=() +BREAKING_CHANGES=() + +# 分析 Issue 标签分类 +while read -r issue; do + SUBJECT=$(echo "$issue" | jq -r '.subject') + LABELS=$(echo "$issue" | jq -r '.labels[]') + + if echo "$LABELS" | grep -q "feature"; then + FEATURES+=("$SUBJECT") + elif echo "$LABELS" | grep -q "bug"; then + BUG_FIXES+=("$SUBJECT") + elif echo "$LABELS" | grep -q "enhancement"; then + ENHANCEMENTS+=("$SUBJECT") + fi +done <<< "$CLOSED_ISSUES" + +# 分析提交信息 +while read -r commit; do + MESSAGE=$(echo "$commit" | jq -r '.message') + + if echo "$MESSAGE" | grep -iq "BREAKING"; then + BREAKING_CHANGES+=("$MESSAGE") + fi +done <<< "$COMMITS" +``` + +### 步骤 6:生成 Release Notes + +```bash +# 生成结构化的 Release Notes +RELEASE_NOTES="# 🚀 Release Notes for $NEW_TAG + +## 📝 What's Changed + +### ✨ New Features +$(for feature in "${FEATURES[@]}"; do + echo "- $feature" +done) + +### 🐛 Bug Fixes +$(for fix in "${BUG_FIXES[@]}"; do + echo "- $fix" +done) + +### 🔧 Enhancements +$(for enhancement in "${ENHANCEMENTS[@]}"; do + echo "- $enhancement" +done) + +### ⚠️ Breaking Changes +$(for breaking in "${BREAKING_CHANGES[@]}"; do + echo "- $breaking" +done)" + +# 创建 Release +gitlink-cli release +create --tag $NEW_TAG --name "$NEW_TAG" --body "$RELEASE_NOTES" +``` + +## AI Agent 集成示例 + +Claude Code 等 AI Agent 可以深度集成此工作流: + +```python +# AI Agent 生成 Release Notes +def generate_release_notes(owner, repo, new_version): + """AI Agent 自动生成发布说明""" + + # 1. 获取版本信息 + prev_version = get_latest_release(owner, repo) + commits = compare_revisions(owner, repo, prev_version, new_version) + issues = get_closed_issues(owner, repo, since=prev_version) + prs = get_merged_prs(owner, repo, since=prev_version) + + # 2. AI 分析变更 + changes = analyze_changes(commits, issues, prs) + + # 3. 生成发布说明 + release_notes = format_release_notes(new_version, changes, prev_version) + + # 4. 创建 Release + create_release(owner, repo, new_version, release_notes) + + return release_notes + +def analyze_changes(commits, issues, prs): + """AI 智能分析变更内容""" + + changes = { + 'features': [], + 'bug_fixes': [], + 'enhancements': [], + 'breaking_changes': [], + 'contributors': set(), + 'performance_improvements': [], + 'security_fixes': [] + } + + # 分析 Issue + for issue in issues: + labels = [label['name'] for label in issue.get('issue_tags', [])] + subject = issue['subject'] + + if 'feature' in labels: + changes['features'].append(format_issue_reference(issue)) + elif 'bug' in labels: + changes['bug_fixes'].append(format_issue_reference(issue)) + elif 'enhancement' in labels: + changes['enhancements'].append(format_issue_reference(issue)) + elif 'security' in labels: + changes['security_fixes'].append(format_issue_reference(issue)) + + # 分析提交信息 + for commit in commits: + message = commit['commit']['message'] + + # 使用 AI 分析提交消息 + analysis = analyze_commit_message(message) + + if analysis.get('breaking_change'): + changes['breaking_changes'].append(message) + elif analysis.get('performance'): + changes['performance_improvements'].append(message) + + # 收集贡献者 + changes['contributors'].add(commit['author']['name']) + + return changes + +def format_release_notes(version, changes, prev_version): + """AI 生成结构化发布说明""" + + notes = f"""# 🎉 Release {version} + +## 📊 变更统计 +- **新功能**: {len(changes['features'])} 个 +- **Bug 修复**: {len(changes['bug_fixes'])} 个 +- **功能改进**: {len(changes['enhancements'])} 个 +- **破坏性变更**: {len(changes['breaking_changes'])} 个 +""" + + if changes['features']: + notes += "\n## ✨ 新功能\n" + notes += "\n".join(f"- {feature}" for feature in changes['features']) + notes += "\n" + + if changes['bug_fixes']: + notes += "\n## 🐛 Bug 修复\n" + notes += "\n".join(f"- {fix}" for fix in changes['bug_fixes']) + notes += "\n" + + if changes['breaking_changes']: + notes += "\n## ⚠️ 破坏性变更\n" + notes += "\n".join(f"- {change}" for change in changes['breaking_changes']) + notes += "\n" + + if changes['contributors']: + notes += f"\n## 🙏 贡献者\n" + notes += ", ".join(sorted(changes['contributors'])) + notes += "\n" + + notes += f"\n---\n**完整变更日志**: https://www.gitlink.org.cn/{owner}/{repo}/compare/{prev_version}...{version}" + + return notes + +def analyze_commit_message(message): + """AI 分析提交消息""" + return { + 'breaking_change': bool(re.search(r'BREAKING|breaking|!', message)), + 'performance': bool(re.search(r'performance|优化|提升', message, re.I)), + 'security': bool(re.search(r'security|安全|漏洞', message, re.I)) + } +``` + +## Release Notes 模板 + +### 标准模板 + +```markdown +# 🎉 Release {VERSION} + +## 📊 变更统计 +- **新功能**: {FEATURE_COUNT} 个 +- **Bug 修复**: {BUG_FIX_COUNT} 个 +- **功能改进**: {ENHANCEMENT_COUNT} 个 +- **破坏性变更**: {BREAKING_COUNT} 个 + +## ✨ 新功能 +{FEATURES_LIST} + +## 🐛 Bug 修复 +{BUG_FIXES_LIST} + +## 🔧 功能改进 +{ENHANCEMENTS_LIST} + +## ⚠️ 破坏性变更 +{BREAKING_CHANGES_LIST} + +## 🙏 贡献者 +{CONTRIBUTORS_LIST} + +## 📥 安装 +```bash +# 使用 npm +npm install {PACKAGE}@{VERSION} + +# 使用 yarn +yarn add {PACKAGE}@{VERSION} + +# 使用 pnpm +pnpm add {PACKAGE}@{VERSION} +``` + +## 🔄 升级指南 +{UPGRADE_GUIDE} + +## 📚 文档 +完整文档请查看: https://www.gitlink.org.cn/{OWNER}/{REPO}/wiki + +--- +**完整变更日志**: https://www.gitlink.org.cn/{OWNER}/{REPO}/compare/{PREV_VERSION}...{VERSION} +``` + +### 简化模板 + +```markdown +# {VERSION} + +## 新增 +{FEATURES} + +## 修复 +{BUG_FIXES} + +## 改进 +{ENHANCEMENTS} + +## 贡献者 +{CONTRIBUTORS} + +## 链接 +- [完整变更](https://www.gitlink.org.cn/{OWNER}/{REPO}/compare/{PREV_VERSION}...{VERSION}) +- [问题追踪](https://www.gitlink.org.cn/{OWNER}/{REPO}/issues) +``` + +## 自动化分类规则 + +基于提交信息和 Issue 标签的自动分类: + +```python +RELEASE_CATEGORIES = { + 'features': { + 'labels': ['feature', 'enhancement'], + 'commit_keywords': ['feat:', 'add', 'new'], + 'icon': '✨', + 'title': '新功能' + }, + 'bug_fixes': { + 'labels': ['bug', 'fix'], + 'commit_keywords': ['fix:', 'bugfix'], + 'icon': '🐛', + 'title': 'Bug 修复' + }, + 'enhancements': { + 'labels': ['improvement', 'optimize'], + 'commit_keywords': ['improve:', 'optimize:', 'refactor:'], + 'icon': '🔧', + 'title': '功能改进' + }, + 'breaking_changes': { + 'labels': ['breaking', 'major'], + 'commit_keywords': ['BREAKING', 'breaking:', '!'], + 'icon': '⚠️', + 'title': '破坏性变更' + }, + 'security': { + 'labels': ['security', 'vulnerability'], + 'commit_keywords': ['security:', 'fix security'], + 'icon': '🔒', + 'title': '安全修复' + } +} +``` + +## 版本号规范 + +遵循语义化版本 (Semantic Versioning): + +``` +MAJOR.MINOR.PATCH + +MAJOR: 不兼容的 API 变更 +MINOR: 向后兼容的功能新增 +PATCH: 向后兼容的 Bug 修复 +``` + +版本号示例: +- `1.0.0` → `1.1.0`:新增功能 +- `1.1.0` → `1.1.1`:Bug 修复 +- `1.1.1` → `2.0.0`:破坏性变更 + +## 质量检查 + +发布前检查清单: + +- [ ] Release Notes 完整性检查 +- [ ] 变更统计准确性验证 +- [ ] 破坏性变更标识 +- [ ] 升级指南完整性 +- [ ] 文档链接正确性 +- [ ] 安装指令有效性 +- [ ] 贡献者列表完整性 + +## 最佳实践 + +1. **定期发布**:建立定期发布节奏 +2. **变更追踪**:确保所有变更都被记录 +3. **清晰分类**:使用明确的分类和标签 +4. **用户友好**:提供升级指南和迁移说明 +5. **版本规范**:遵循语义化版本规范 + +## References + +- [workflow-issue-triage](workflow-issue-triage.md) — Issue 分类工作流 +- [workflow-pr-review](workflow-pr-review.md) — PR 审查工作流 +- [gitlink-workflow](../SKILL.md) — 工作流总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 +- [release +create](../../gitlink-release/references/gitlink-release-create.md) — 创建 Release +- [release +list](../../gitlink-release/references/gitlink-release-list.md) — 列出 Release diff --git a/skills/gitlink-workflow/references/workflow-repo-setup.md b/skills/gitlink-workflow/references/workflow-repo-setup.md new file mode 100644 index 0000000..f887ff3 --- /dev/null +++ b/skills/gitlink-workflow/references/workflow-repo-setup.md @@ -0,0 +1,646 @@ +# Workflow: Repo Setup(仓库初始化) + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **AI Agent 工作流**:此工作流专为 AI Agent 设计,用于自动化仓库初始化设置。 + +AI Agent 自动创建新仓库并完成基础配置,包括分支保护、初始文档、Issue 模板等,确保新项目快速启动。 + +## 工作流概述 + +Repo Setup 工作流自动化创建新 GitLink 仓库,并进行标准化初始化配置,包括 README、LICENSE、分支保护、CI 配置等项目必需的设置。 + +## 适用场景 + +- **项目创建**:快速创建新的项目仓库 +- **标准化设置**:确保所有仓库配置一致 +- **模板应用**:应用组织或团队的项目模板 +- **批量创建**:批量创建多个相关项目 + +## 工作流步骤 + +### 步骤 1:创建仓库 + +```bash +# 创建新仓库 +gitlink-cli repo +create \ + --name my-awesome-project \ + --description "一个很棒的项目" \ + --private false + +# 或者创建私有仓库 +gitlink-cli repo +create \ + --name internal-project \ + --description "内部项目" \ + --private true +``` + +### 步骤 2:初始化本地仓库 + +```bash +# 本地初始化 +cd my-awesome-project +git init +git remote add gitlink https://www.gitlink.org.cn/username/my-awesome-project.git + +# 创建初始文件 +echo "# My Awesome Project" > README.md +echo "MIT License" > LICENSE +git add . +git commit -m "Initial commit" +git push -u gitlink master:master +``` + +### 步骤 3:设置分支保护 + +```bash +# 保护主分支 +gitlink-cli branch +protect \ + --owner username \ + --repo my-awesome-project \ + --name master + +# 如果使用 main 分支 +gitlink-cli branch +protect \ + --owner username \ + --repo my-awesome-project \ + --name main +``` + +### 步骤 4:创建配置文件 + +```bash +# 创建 .gitignore +cat > .gitignore << 'EOF' +# 依赖 +node_modules/ +vendor/ + +# 构建输出 +dist/ +build/ +*.log + +# IDE +.vscode/ +.idea/ + +# 环境变量 +.env +.env.local +EOF + +# 创建配置文件(根据项目类型) +if [ "$PROJECT_TYPE" = "node" ]; then + echo '{"name":"my-awesome-project","version":"1.0.0"}' > package.json +elif [ "$PROJECT_TYPE" = "python" ]; then + echo "[project]\nname = 'my-awesome-project'\nversion = '1.0.0'" > pyproject.toml +fi + +git add .gitignore package.json pyproject.toml +git commit -m "Add project configuration files" +git push gitlink master:master +``` + +### 步骤 5:创建 Issue 和 PR 模板 + +```bash +# 创建 Issue 模板 +cat > .github/ISSUE_TEMPLATE/bug_report.md << 'EOF' +--- +name: Bug 报告 +about: 报告项目中的问题 +title: '[Bug] ' +--- + +## Bug 描述 +简要描述遇到的问题。 + +## 复现步骤 +1. +2. +3. + +## 预期行为 +描述你期望发生的行为。 + +## 实际行为 +描述实际发生的行为。 + +## 环境 +- 操作系统: +- 版本: +- 其他信息: +EOF + +# 创建 PR 模板 +cat > .github/PULL_REQUEST_TEMPLATE.md << 'EOF' +## 变更描述 +简要描述这个 PR 的目的和内容。 + +## 变更类型 +- [ ] Bug 修复 +- [ ] 新功能 +- [ ] 功能改进 +- [ ] 文档更新 +- [ ] 性能优化 +- [ ] 代码重构 + +## 测试 +描述你如何测试这些变更: + +## 检查清单 +- [ ] 代码遵循项目规范 +- [ ] 已添加必要的测试 +- [ ] 已更新相关文档 +- [ ] 所有测试通过 +- [ ] 无合并冲突 +EOF + +git add .github/ +git commit -m "Add issue and PR templates" +git push gitlink master:master +``` + +### 步骤 6:创建初始 Issue + +```bash +# 创建项目初始化 Issue +gitlink-cli issue +create \ + --owner username \ + --repo my-awesome-project \ + --title "完成项目初始化" \ + --body "## 初始化任务清单 + +- [x] 创建仓库 +- [x] 添加 README.md +- [x] 添加 LICENSE +- [x] 设置分支保护 +- [x] 添加配置文件 +- [x] 创建 Issue 模板 +- [x] 创建 PR 模板 +- [ ] 配置 CI/CD +- [ ] 添加项目文档 +- [ ] 设置开发指南 + +## 下一步 +1. 配置 CI/CD 流程 +2. 编写项目文档 +3. 设置开发环境指南 +4. 创建贡献指南" +``` + +### 步骤 7:配置 CI/CD(可选) + +```bash +# 创建 CI 配置文件 +cat > .gitlab-ci.yml << 'EOF' +stages: + - test + - build + - deploy + +test: + stage: test + script: + - echo "Running tests..." + - npm test + +build: + stage: build + script: + - echo "Building..." + - npm run build + +deploy: + stage: deploy + script: + - echo "Deploying..." + - npm run deploy + only: + - master +EOF + +git add .gitlab-ci.yml +git commit -m "Add CI/CD configuration" +git push gitlink master:master +``` + +## 完整工作流示例 + +```bash +#!/bin/bash + +# 仓库初始化自动化脚本 + +PROJECT_NAME=$1 +PROJECT_DESC=$2 +IS_PRIVATE=${3:-false} +OWNER="username" + +if [ -z "$PROJECT_NAME" ]; then + echo "使用方法: $0 [description] [private]" + exit 1 +fi + +echo "开始初始化项目: $PROJECT_NAME" + +# 1. 创建仓库 +echo "创建仓库..." +REPO_INFO=$(gitlink-cli repo +create \ + --name "$PROJECT_NAME" \ + --description "$PROJECT_DESC" \ + --private "$IS_PRIVATE" \ + --format json) + +if echo "$REPO_INFO" | jq -e '.ok' > /dev/null; then + echo "✅ 仓库创建成功" +else + echo "❌ 仓库创建失败" + exit 1 +fi + +# 2. 本地初始化 +echo "初始化本地仓库..." +mkdir -p "$PROJECT_NAME" +cd "$PROJECT_NAME" +git init +git remote add gitlink "https://www.gitlink.org.cn/$OWNER/$PROJECT_NAME.git" + +# 3. 创建基础文件 +echo "创建项目文件..." + +# README.md +cat > README.md << EOF +# $PROJECT_NAME + +$PROJECT_DESC + +## 快速开始 + +\`\`\`bash +# 安装依赖 +npm install + +# 开发模式运行 +npm run dev + +# 构建项目 +npm run build + +# 运行测试 +npm test +\`\`\` + +## 项目结构 + +\`\`\` +$PROJECT_NAME/ +├── src/ # 源代码 +├── tests/ # 测试文件 +├── docs/ # 文档 +├── scripts/ # 脚本 +└── package.json # 项目配置 +\`\`\` + +## 贡献指南 + +欢迎提交 Issue 和 Pull Request! + +## 许可证 + +MIT License +EOF + +# LICENSE +cat > LICENSE << 'EOF' +MIT License + +Copyright (c) 2026 + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +EOF + +# package.json +cat > package.json << EOF +{ + "name": "$PROJECT_NAME", + "version": "1.0.0", + "description": "$PROJECT_DESC", + "main": "src/index.js", + "scripts": { + "dev": "echo 'Development mode'", + "build": "echo 'Building project'", + "test": "echo 'Running tests'", + "lint": "echo 'Linting code'" + }, + "keywords": [], + "author": "", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://www.gitlink.org.cn/$OWNER/$PROJECT_NAME.git" + } +} +EOF + +# .gitignore +cat > .gitignore << 'EOF' +node_modules/ +dist/ +build/ +*.log +.env +.env.local +.DS_Store +*.swp +*.swo +.vscode/ +.idea/ +coverage/ +.nyc_output/ +EOF + +# 创建目录结构 +mkdir -p src tests docs scripts + +# 4. 提交初始文件 +echo "提交初始文件..." +git add . +git commit -m "Initial commit" +git push -u gitlink master:master + +# 5. 设置分支保护 +echo "设置分支保护..." +gitlink-cli branch +protect \ + --owner "$OWNER" \ + --repo "$PROJECT_NAME" \ + --name master + +# 6. 创建项目模板 +echo "创建 Issue 模板..." +mkdir -p .github/ISSUE_TEMPLATE + +cat > .github/ISSUE_TEMPLATE/bug_report.md << 'EOF' +--- +name: Bug 报告 +about: 报告项目中的问题 +title: '[Bug] ' +--- + +## Bug 描述 +简要描述遇到的问题。 + +## 复现步骤 +1. +2. +3. + +## 预期行为 +描述你期望发生的行为。 + +## 实际行为 +描述实际发生的行为。 + +## 环境 +- 操作系统: +- 版本: +- 其他信息: +EOF + +# 7. 创建初始化 Issue +echo "创建初始化 Issue..." +gitlink-cli issue +create \ + --owner "$OWNER" \ + --repo "$PROJECT_NAME" \ + --title "完成项目初始化设置" \ + --body "## 项目初始化任务 + +### 基础配置 +- [x] 创建仓库 +- [x] 添加 README.md +- [x] 添加 LICENSE +- [x] 设置分支保护 +- [x] 添加配置文件 +- [x] 创建目录结构 + +### 下一步任务 +- [ ] 配置 CI/CD +- [ ] 编写项目文档 +- [ ] 设置开发指南 +- [ ] 创建贡献指南 +- [ ] 添加代码规范 +- [ ] 配置代码检查 + +## 开发环境设置 +\`\`\`bash +# 克隆仓库 +git clone https://www.gitlink.org.cn/$OWNER/$PROJECT_NAME.git + +# 安装依赖 +cd $PROJECT_NAME +npm install + +# 开发模式 +npm run dev +\`\`\` + +## 贡献流程 +1. Fork 本仓库 +2. 创建功能分支 (\`git checkout -b feature/AmazingFeature\`) +3. 提交更改 (\`git commit -m 'Add some AmazingFeature'\`) +4. 推送到分支 (\`git push origin feature/AmazingFeature\`) +5. 创建 Pull Request" + +echo "✅ 项目初始化完成!" +echo "" +echo "项目信息:" +echo " 名称: $PROJECT_NAME" +echo " 仓库: https://www.gitlink.org.cn/$OWNER/$PROJECT_NAME" +echo " 状态: $([ "$IS_PRIVATE" = "true" ] && echo "私有" || echo "公开")" +echo "" +echo "下一步:" +echo " 1. cd $PROJECT_NAME" +echo " 2. 配置开发环境" +echo " 3. 开始开发" +``` + +## AI Agent 集成示例 + +Claude Code 等 AI Agent 可以深度集成此工作流: + +```python +# AI Agent 仓库初始化 +def setup_repository(owner, repo_name, description, private=False): + """AI Agent 自动化仓库初始化""" + + # 1. 创建仓库 + repo = create_repository(owner, repo_name, description, private) + + # 2. 初始化项目结构 + project_structure = generate_project_structure(repo_name) + initialize_project_files(repo_name, project_structure) + + # 3. 配置分支保护 + protect_branch(owner, repo_name, 'master') + + # 4. 创建 Issue 模板 + create_issue_templates(owner, repo_name) + + # 5. 配置 CI/CD + setup_cicd(owner, repo_name, project_structure['type']) + + # 6. 创建初始化 Issue + create_setup_issue(owner, repo_name) + + return repo + +def generate_project_structure(repo_name): + """AI 生成项目结构""" + + # 分析项目名称和描述,确定项目类型 + project_type = analyze_project_type(repo_name) + + structures = { + 'node': { + 'directories': ['src', 'tests', 'docs', 'scripts'], + 'files': { + 'package.json': generate_package_json(repo_name), + '.gitignore': generate_gitignore('node'), + 'README.md': generate_readme(repo_name) + } + }, + 'python': { + 'directories': ['src', 'tests', 'docs', 'scripts'], + 'files': { + 'pyproject.toml': generate_pyproject(repo_name), + '.gitignore': generate_gitignore('python'), + 'README.md': generate_readme(repo_name) + } + } + } + + return structures.get(project_type, structures['node']) + +def create_issue_templates(owner, repo): + """创建 Issue 模板""" + + templates = { + 'bug_report.md': generate_bug_template(), + 'feature_request.md': generate_feature_template(), + 'question.md': generate_question_template() + } + + for template_name, content in templates.items(): + # 使用 API 创建模板文件 + create_file_via_api(owner, repo, + f'.github/ISSUE_TEMPLATE/{template_name}', + content) +``` + +## 项目模板 + +### 前端项目模板 + +```bash +# 创建前端项目结构 +create_frontend_project() { + mkdir -p src/{components,pages,hooks,utils} + mkdir -p public/{images,fonts} + mkdir -p tests/{unit,integration} + + # package.json + cat > package.json << 'EOF' +{ + "name": "my-frontend-project", + "version": "1.0.0", + "scripts": { + "dev": "vite", + "build": "vite build", + "test": "vitest", + "lint": "eslint src/" + }, + "dependencies": { + "react": "^18.0.0" + } +} +EOF +} +``` + +### 后端项目模板 + +```bash +# 创建后端项目结构 +create_backend_project() { + mkdir -p src/{controllers,models,routes,middleware} + mkdir -p tests/{unit,integration} + mkdir -p config + mkdir -m migrations + + # package.json + cat > package.json << 'EOF' +{ + "name": "my-backend-project", + "version": "1.0.0", + "scripts": { + "start": "node src/server.js", + "dev": "nodemon src/server.js", + "test": "jest", + "migrate": "knex migrate:latest" + }, + "dependencies": { + "express": "^4.18.0", + "knex": "^2.0.0" + } +} +EOF +} +``` + +## 配置检查清单 + +仓库初始化完成后检查: + +- [ ] 仓库创建成功 +- [ ] README.md 完整 +- [ ] LICENSE 文件存在 +- [ ] 分支保护已设置 +- [ ] .gitignore 配置正确 +- [ ] Issue 模板创建 +- [ ] PR 模板创建 +- [ ] CI/CD 配置(可选) +- [ ] 初始化 Issue 已创建 +- [ ] 本地仓库可正常推送 + +## 最佳实践 + +1. **标准化模板**:使用统一的项目模板 +2. **配置管理**:统一配置文件格式 +3. **文档完整**:确保 README 和文档完整 +4. **安全设置**:合理设置分支保护 +5. **CI/CD 配置**:早期建立 CI/CD 流程 + +## References + +- [workflow-issue-triage](workflow-issue-triage.md) — Issue 分类工作流 +- [workflow-sprint-report](workflow-sprint-report.md) — Sprint 报告工作流 +- [gitlink-workflow](../SKILL.md) — 工作流总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 +- [repo +create](../../gitlink-repo/references/repo-create.md) — 创建仓库 +- [branch +protect](../../gitlink-branch/references/branch-protect.md) — 保护分支 diff --git a/skills/gitlink-workflow/references/workflow-sprint-report.md b/skills/gitlink-workflow/references/workflow-sprint-report.md new file mode 100644 index 0000000..b362796 --- /dev/null +++ b/skills/gitlink-workflow/references/workflow-sprint-report.md @@ -0,0 +1,549 @@ +# Workflow: Sprint Report(Sprint 报告) + +> **前置条件:** 先阅读 [`../../gitlink-shared/SKILL.md`](../../../gitlink-shared/SKILL.md) 了解认证、全局参数和安全规则。 +> **AI Agent 工作流**:此工作流专为 AI Agent 设计,用于自动生成 Sprint 进度报告。 + +AI Agent 自动汇总 Sprint 期间的 Issue、PR、提交记录等数据,生成结构化的进度报告,帮助团队了解项目进展。 + +## 工作流概述 + +Sprint Report 工作流收集 Sprint 期间的所有活动数据,包括 Issue 完成情况、PR 合并状态、代码提交统计、团队成员贡献等,自动生成团队进度报告。 + +## 适用场景 + +- **Sprint 回顾**:为 Sprint 回顾会议提供数据支持 +- **进度汇报**:向管理层汇报项目进展 +- **团队协调**:协调团队工作计划和资源分配 +- **绩效评估**:评估团队和个人的工作效率 + +## 工作流步骤 + +### 步骤 1:确定报告时间范围 + +```bash +# 设置 Sprint 时间范围(通常为 2 周) +SPRINT_START="2026-01-01" +SPRINT_END="2026-01-14" + +# 或者基于当前时间计算 +SPRINT_END=$(date +%Y-%m-%d) +SPRINT_START=$(date -d "14 days ago" +%Y-%m-%d) +``` + +### 步骤 2:获取 Issue 统计 + +```bash +# 获取 Sprint 期间关闭的 Issue +CLOSED_ISSUES=$(gitlink-cli issue +list --state closed --format json | \ + jq '.data.issues[] | + select(.closed_at >= "'$SPRINT_START'" and .closed_at <= "'$SPRINT_END'")') + +# 获取新增 Issue +NEW_ISSUES=$(gitlink-cli issue +list --state open --format json | \ + jq '.data.issues[] | + select(.created_at >= "'$SPRINT_START'" and .created_at <= "'$SPRINT_END'")') + +# 统计 Issue 数据 +CLOSED_COUNT=$(echo "$CLOSED_ISSUES" | jq -s 'length') +NEW_COUNT=$(echo "$NEW_ISSUES" | jq -s 'length') +``` + +### 步骤 3:获取 PR 统计 + +```bash +# 获取 Sprint 期间合并的 PR +MERGED_PRS=$(gitlink-cli pr +list --state merged --format json | \ + jq '.data.prs[] | + select(.merged_at >= "'$SPRINT_START'" and .merged_at <= "'$SPRINT_END'")') + +# 获取新建的 PR +NEW_PRS=$(gitlink-cli pr +list --state open --format json | \ + jq '.data.prs[] | + select(.created_at >= "'$SPRINT_START'" and .created_at <= "'$SPRINT_END'")') + +# 统计 PR 数据 +MERGED_COUNT=$(echo "$MERGED_PRS" | jq -s 'length') +NEW_PR_COUNT=$(echo "$NEW_PRS" | jq -s 'length') +``` + +### 步骤 4:获取提交统计 + +```bash +# 获取项目活动数据 +ACTIVITY=$(gitlink-cli api GET /:owner/:repo/activity --format json | \ + jq ".data.activity[] | + select(.created_at >= \"$SPRINT_START\" and .created_at <= \"$SPRINT_END\")") + +# 提取提交数据 +COMMITS=$(echo "$ACTIVITY" | jq 'select(.type == "commit")') +COMMIT_COUNT=$(echo "$COMMITS" | jq -s 'length') +``` + +### 步骤 5:分析团队贡献 + +```bash +# 按团队成员统计贡献 +CONTRIBUTORS=$(gitlink-cli api GET /:owner/:repo/activity --format json | \ + jq ".data.activity[] | + select(.created_at >= \"$SPRINT_START\" and .created_at <= \"$SPRINT_END\") | + .author | + group_by(.) | + map({developer: .[0], count: length}) | + sort_by(.count) | reverse") +``` + +### 步骤 6:生成 Sprint 报告 + +```bash +# 生成结构化的 Sprint 报告 +SPRINT_REPORT="# 📊 Sprint 进度报告 + +**时间范围**: $SPRINT_START 至 $SPRINT_END +**Sprint 周期**: 14 天 + +## 🎯 目标达成情况 + +### Issue 统计 +- ✅ **完成 Issue**: $CLOSED_COUNT 个 +- 🆕 **新增 Issue**: $NEW_COUNT 个 +- 📈 **完成率**: $(($CLOSED_COUNT * 100 / ($CLOSED_COUNT + $NEW_COUNT)))% + +### Pull Request 统计 +- 🔀 **合并 PR**: $MERGED_COUNT 个 +- 🆕 **新建 PR**: $NEW_PR_COUNT 个 +- ✅ **合并率**: $(($MERGED_COUNT * 100 / ($MERGED_COUNT + $NEW_PR_COUNT)))% + +### 代码提交统计 +- 💻 **提交次数**: $COMMIT_COUNT 次 +- 📊 **日均提交**: $(($COMMIT_COUNT / 14)) 次/天 + +## 👥 团队贡献 +$(echo "$CONTRIBUTORS" | jq -r '.[] | "- **\(.developer)**: \(.count) 次贡献"') + +## 🎉 主要成就 +$(echo "$CLOSED_ISSUES" | jq -r '.[] | "- 完成 Issue: \(.subject)"') + +## 🔄 进行中工作 +$(echo "$NEW_ISSUES" | jq -r '.[] | "- 新建 Issue: \(.subject)"') + +## 📈 下期计划 +1. 继续进行中的 Issue 开发 +2. 新功能规划和设计 +3. 技术债务清理 +4. 性能优化工作" + +# 输出报告 +echo "$SPRINT_REPORT" +``` + +## 完整工作流示例 + +```bash +#!/bin/bash + +# Sprint 报告自动化生成脚本 + +OWNER="username" +REPO="myproject" +REPORT_DIR="sprint_reports" + +# 获取时间范围 +SPRINT_NUMBER=$1 +if [ -z "$SPRINT_NUMBER" ]; then + # 计算当前是第几个 Sprint(假设每 Sprint 2 周,从项目开始计算) + PROJECT_START="2026-01-01" + CURRENT_DATE=$(date +%Y-%m-%d) + DAYS_DIFF=$(( ($(date -d "$CURRENT_DATE" +%s) - $(date -d "$PROJECT_START" +%s)) / 86400 )) + SPRINT_NUMBER=$((DAYS_DIFF / 14 + 1)) +fi + +SPRINT_START=$(date -d "$((SPRINT_NUMBER - 1)) weeks ago" +%Y-%m-%d) +SPRINT_END=$(date -d "$((SPRINT_NUMBER - 1)) weeks ago +14 days" +%Y-%m-%d) + +echo "生成 Sprint $SPRINT_NUMBER 报告 ($SPRINT_START - $SPRINT_END)" + +# 创建报告目录 +mkdir -p "$REPORT_DIR" + +# 1. 获取 Issue 数据 +echo "收集 Issue 数据..." +ISSUE_DATA=$(gitlink-cli issue +list --owner $OWNER --repo $REPO --format json) + +CLOSED_ISSUES=$(echo "$ISSUE_DATA" | jq -r ".data.issues[] | + select(.closed_at >= \"$SPRINT_START\" and .closed_at <= \"$SPRINT_END\")") + +NEW_ISSUES=$(echo "$ISSUE_DATA" | jq -r ".data.issues[] | + select(.created_at >= \"$SPRINT_START\" and .created_at <= \"$SPRINT_END\")") + +CLOSED_COUNT=$(echo "$CLOSED_ISSUES" | jq -s 'length') +NEW_COUNT=$(echo "$NEW_ISSUES" | jq -s 'length') + +# 2. 获取 PR 数据 +echo "收集 PR 数据..." +PR_DATA=$(gitlink-cli pr +list --owner $OWNER --repo $REPO --format json) + +MERGED_PRS=$(echo "$PR_DATA" | jq -r ".data.prs[] | + select(.merged_at >= \"$SPRINT_START\" and .merged_at <= \"$SPRINT_END\")") + +NEW_PRS=$(echo "$PR_DATA" | jq -r ".data.prs[] | + select(.created_at >= \"$SPRINT_START\" and .created_at <= \"$SPRINT_END\")") + +MERGED_COUNT=$(echo "$MERGED_PRS" | jq -s 'length') +NEW_PR_COUNT=$(echo "$NEW_PRS" | jq -s 'length') + +# 3. 获取提交数据 +echo "收集提交数据..." +COMMITS=$(gitlink-cli api GET "/$OWNER/$REPO/commits" --format json | \ + jq -r ".data[] | + select(.committed_date >= \"$SPRINT_START\" and .committed_date <= \"$SPRINT_END\")") + +COMMIT_COUNT=$(echo "$COMMITS" | jq -s 'length') + +# 4. 分析团队贡献 +echo "分析团队贡献..." +CONTRIBUTORS=$(echo "$COMMITS" | jq -r '.author | group_by(.) | + map({developer: .[0], count: length}) | + sort_by(.count) | reverse') + +# 5. 分析 Issue 标签 +echo "分析 Issue 分类..." +FEATURES=$(echo "$CLOSED_ISSUES" | jq -r '[.[] | select(.issue_tags[]?.name == "feature")] | length') +BUGS=$(echo "$CLOSED_ISSUES" | jq -r '[.[] | select(.issue_tags[]?.name == "bug")] | length') +ENHANCEMENTS=$(echo "$CLOSED_ISSUES" | jq -r '[.[] | select(.issue_tags[]?.name == "enhancement")] | length') + +# 6. 计算完成率 +COMPLETION_RATE=0 +if [ $((CLOSED_COUNT + NEW_COUNT)) -gt 0 ]; then + COMPLETION_RATE=$((CLOSED_COUNT * 100 / (CLOSED_COUNT + NEW_COUNT))) +fi + +# 7. 生成报告 +echo "生成 Sprint 报告..." +REPORT_FILE="$REPORT_DIR/sprint_${SPRINT_NUMBER}_$(date +%Y%m%d).md" + +cat > "$REPORT_FILE" << EOF +# 📊 Sprint $SPRINT_NUMBER 进度报告 + +**时间范围**: $SPRINT_START 至 $SPRINT_END +**生成时间**: $(date +%Y-%m-%d) +**报告周期**: 14 天 + +## 🎯 Sprint 目标达成情况 + +### 总体概览 +| 指标 | 数量 | 说明 | +|------|------|------| +| ✅ 完成 Issue | $CLOSED_COUNT 个 | Sprint 期间关闭的 Issue | +| 🆕 新增 Issue | $NEW_COUNT 个 | Sprint 期间新建的 Issue | +| 🔀 合并 PR | $MERGED_COUNT 个 | Sprint 期间合并的 PR | +| 🆕 新建 PR | $NEW_PR_COUNT 个 | Sprint 期间新建的 PR | +| 💻 代码提交 | $COMMIT_COUNT 次 | Sprint 期间的提交次数 | + +### 完成率分析 +- **Issue 完成率**: ${COMPLETION_RATE}% +- **PR 合并率**: $((MERGED_COUNT * 100 / (MERGED_COUNT + NEW_PR_COUNT)))% +- **平均日提交**: $((COMMIT_COUNT / 14)) 次/天 + +## 📊 Issue 分类统计 + +| 分类 | 数量 | 占比 | +|------|------|------| +| 新功能 | $FEATURES 个 | $((FEATURES * 100 / CLOSED_COUNT))% | +| Bug 修复 | $BUGS 个 | $((BUGS * 100 / CLOSED_COUNT))% | +| 功能改进 | $ENHANCEMENTS 个 | $((ENHANCEMENTS * 100 / CLOSED_COUNT))% | + +## 👥 团队贡献统计 + +$(echo "$CONTRIBUTORS" | jq -r '.[] | | + "| **\(.developer)** | \(.count) 次提交 | $((.count * 100 / COMMIT_COUNT))% |"') + +## 🎉 主要成就 + +### 完成的 Issue +$(echo "$CLOSED_ISSUES" | jq -r '"- [\(.subject)](#issue/\(.id)) - \(.assigned_to // "未分配")"') + +### 合并的 PR +$(echo "$MERGED_PRS" | jq -r '"- [\(.title)](#pr/\(.id)) - \(.author.login)"') + +## 🔄 进行中的工作 + +### 未完成的 Issue +$(echo "$NEW_ISSUES" | jq -r '"- [\(.subject)](#issue/\(.id)) - \(.assigned_to // "未分配")"') + +### 待合并的 PR +$(echo "$NEW_PRS" | jq -r '"- [\(.title)](#pr/\(.id)) - \(.author.login)"') + +## 📈 趋势分析 + +### 代码活动趋势 +- 本 Sprint 共有 **$COMMIT_COUNT 次提交**,日均 **$((COMMIT_COUNT / 14)) 次** +- 比上 Sprint $([[ $SPRINT_NUMBER -gt 1 ]] && echo "增长了/减少了 XX%" || echo "为基线数据") + +### 团队效率分析 +- 团队成员积极参与,贡献分布较为均匀 +- 代码审查及时,PR 合并率良好 + +## ⚠️ 风险和问题 + +### 当前风险 +- 高优先级 Issue 积压:$(echo "$NEW_ISSUES" | jq '[.[] | select(.priority_id == 1)] | length') 个 +- 长期未解决的 Issue:$(echo "$NEW_ISSUES" | jq '[.[] | select(.created_at < "'$SPRINT_START'")] | length') 个 + +### 技术债务 +- 代码复用待改进 +- 测试覆盖率需要提升 +- 文档需要更新 + +## 📋 下期计划 + +### 主要目标 +1. 继续完成当前进行中的 Issue +2. 优化代码质量和测试覆盖 +3. 更新项目文档 +4. 技术债务清理 + +### 资源规划 +- 开发资源:保持当前团队配置 +- 时间规划:重点关注高优先级 Issue +- 技术重点:性能优化和代码重构 + +## 🙏 致谢 + +感谢所有团队成员在 Sprint $SPRINT_NUMBER 期间的辛勤工作! + +--- +**报告生成**: $(date +%Y-%m-%d %H:%M:%S) +**数据来源**: GitLink API +**报告类型**: 自动化 Sprint 报告 +EOF + +echo "✅ Sprint 报告已生成: $REPORT_FILE" + +# 8. 可选:创建 Issue 讨论报告 +echo "创建 Sprint 回顾 Issue..." +REVIEW_ISSUE_BODY="## Sprint $SPRINT_NUMBER 回顾 + +### Sprint 报告 +完整的 Sprint 报告请查看: [Sprint $SPRINT_NUMBER 报告](../../blob/master/$REPORT_FILE) + +### 讨论要点 +1. 目标达成情况分析 +2. 团队协作效果评估 +3. 流程改进建议 +4. 下 Sprint 目标规划 + +### 问题跟踪 +- 需要解决的问题 +- 改进建议 +- 风险识别" + +gitlink-cli issue +create \ + --owner $OWNER \ + --repo $REPO \ + --title "Sprint $SPRINT_NUMBER 回顾" \ + --body "$REVIEW_ISSUE_BODY" + +echo "Sprint 报告工作流完成!" +``` + +## AI Agent 集成示例 + +Claude Code 等 AI Agent 可以深度集成此工作流: + +```python +# AI Agent 生成 Sprint 报告 +def generate_sprint_report(owner, repo, sprint_number): + """AI Agent 自动生成 Sprint 进度报告""" + + # 1. 确定 Sprint 时间范围 + sprint_start, sprint_end = calculate_sprint_period(sprint_number) + + # 2. 收集数据 + sprint_data = collect_sprint_data(owner, repo, sprint_start, sprint_end) + + # 3. AI 分析数据 + analysis = analyze_sprint_performance(sprint_data) + + # 4. 生成报告 + report = generate_report_content(sprint_number, sprint_data, analysis) + + # 5. 保存报告并创建回顾 Issue + save_report(report, sprint_number) + create_review_issue(owner, repo, sprint_number, report) + + return report + +def collect_sprint_data(owner, repo, start_date, end_date): + """收集 Sprint 数据""" + + return { + 'issues': { + 'closed': get_closed_issues(owner, repo, start_date, end_date), + 'new': get_new_issues(owner, repo, start_date, end_date) + }, + 'pull_requests': { + 'merged': get_merged_prs(owner, repo, start_date, end_date), + 'new': get_new_prs(owner, repo, start_date, end_date) + }, + 'commits': get_commits(owner, repo, start_date, end_date), + 'contributors': get_contributor_stats(owner, repo, start_date, end_date) + } + +def analyze_sprint_performance(data): + """AI 分析 Sprint 表现""" + + analysis = { + 'velocity': calculate_velocity(data), + 'trends': identify_trends(data), + 'risks': identify_risks(data), + 'recommendations': generate_recommendations(data) + } + + # AI 分析完成率趋势 + completion_rate = len(data['issues']['closed']) / ( + len(data['issues']['closed']) + len(data['issues']['new']) + ) * 100 + + if completion_rate > 80: + analysis['performance'] = 'excellent' + elif completion_rate > 60: + analysis['performance'] = 'good' + else: + analysis['performance'] = 'needs_improvement' + + return analysis + +def generate_report_content(sprint_number, data, analysis): + """AI 生成报告内容""" + + report = f"""# 📊 Sprint {sprint_number} 进度报告 + +## 🎯 目标达成情况 + +### 总体概览 +- **完成 Issue**: {len(data['issues']['closed'])} 个 +- **新增 Issue**: {len(data['issues']['new'])} 个 +- **合并 PR**: {len(data['pull_requests']['merged'])} 个 +- **代码提交**: {len(data['commits'])} 次 + +### AI 分析结果 +- **表现评级**: {analysis['performance']} +- **团队速度**: {analysis['velocity']} story points +- **主要趋势**: {analysis['trends']} + +## 🎉 主要成就 +""" + + # 添加主要成就 + for issue in data['issues']['closed'][:5]: + report += f"- {issue['subject']} (#{issue['id']})\n" + + # 添加风险和建议 + report += "\n## ⚠️ 风险识别\n" + for risk in analysis['risks']: + report += f"- {risk}\n" + + report += "\n## 💡 改进建议\n" + for recommendation in analysis['recommendations']: + report += f"- {recommendation}\n" + + return report +``` + +## 报告模板 + +### 标准报告结构 + +```markdown +# Sprint {NUMBER} 进度报告 + +## 元信息 +- **时间范围**: {START_DATE} - {END_DATE} +- **Sprint 周期**: 14 天 +- **生成时间**: {TIMESTAMP} + +## 目标达成 +### 完成情况 +- 计划完成: X 个 Issue +- 实际完成: Y 个 Issue +- 完成率: Z% + +## 工作统计 +### Issue 统计 +- 关闭: N 个 +- 新建: M 个 +- 分类统计 + +### PR 统计 +- 合并: N 个 +- 新建: M 个 +- 合并率: X% + +### 提交统计 +- 总提交: N 次 +- 日均: X 次 + +## 团队贡献 +- 成员A: N 次贡献 +- 成员B: M 次贡献 + +## 风险和问题 +- 当前风险 +- 技术债务 +- 阻塞问题 + +## 下期计划 +- 主要目标 +- 资源规划 +- 时间安排 +``` + +## 数据分析维度 + +### 1. 速度分析 +- Story Points 完成 +- Issue 完成数量 +- PR 合并数量 + +### 2. 质量分析 +- Bug 修复比例 +- 代码审查通过率 +- 测试覆盖率变化 + +### 3. 效率分析 +- 平均 Issue 解决时间 +- 平均 PR 合并时间 +- 代码审查周期 + +### 4. 团队分析 +- 成员贡献分布 +- 协作效率 +- 沟通成本 + +## 最佳实践 + +1. **定期生成**:每个 Sprint 结束后及时生成报告 +2. **数据准确**:确保收集的数据完整准确 +3. **客观分析**:基于数据进行客观分析 +4. **行动导向**:报告应包含可执行的改进建议 +5. **团队参与**:让团队成员参与报告讨论 + +## 质量保证 + +报告质量检查: + +- [ ] 数据完整性检查 +- [ ] 计算准确性验证 +- [ ] 格式一致性检查 +- [ ] 语法和拼写检查 +- [ ] 链接有效性验证 +- [ ] 客观性审查 + +## References + +- [workflow-issue-triage](workflow-issue-triage.md) — Issue 分类工作流 +- [workflow-pr-review](workflow-pr-review.md) — PR 审查工作流 +- [gitlink-workflow](../SKILL.md) — 工作流总览 +- [gitlink-shared](../../gitlink-shared/SKILL.md) — 认证和全局参数 +- [pm-sprint](../../gitlink-pm/references/pm-sprint.md) — Sprint 管理 +- [pm-report](../../gitlink-pm/references/pm-report.md) — 周报生成 diff --git a/uninstall.ps1 b/uninstall.ps1 new file mode 100644 index 0000000..2c4fc7d --- /dev/null +++ b/uninstall.ps1 @@ -0,0 +1,328 @@ +# GitLink CLI Windows 卸载脚本 +# 用法: powershell -NoProfile -ExecutionPolicy Bypass -File uninstall.ps1 + +param( + [switch]$Purge, + [switch]$Yes, + [switch]$Help +) + +$ErrorActionPreference = "Stop" + +# ---------- 工具函数 ---------- +function Info-Message { + param([string]$Message) + Write-Host "[INFO] $Message" -ForegroundColor Green +} + +function Warn-Message { + param([string]$Message) + Write-Host "[WARN] $Message" -ForegroundColor Yellow +} + +function Error-Message { + param([string]$Message) + Write-Host "[ERROR] $Message" -ForegroundColor Red +} + +function Step-Message { + param([string]$Message) + Write-Host "[STEP] $Message" -ForegroundColor Cyan +} + +function Ask-Question { + param([string]$Question) + Write-Host "[ASK] $Question" -ForegroundColor Blue +} + +# ---------- 显示帮助 ---------- +if ($Help) { + Write-Host "" + Write-Host "GitLink CLI Windows 卸载脚本" + Write-Host "" + Write-Host "用法: powershell -NoProfile -ExecutionPolicy Bypass -File uninstall.ps1 [选项]" + Write-Host "" + Write-Host "选项:" + Write-Host " -Purge 删除所有文件(包括配置)" + Write-Host " -Yes 自动确认,不询问" + Write-Host " -Help 显示此帮助" + Write-Host "" + Write-Host "示例:" + Write-Host " .\uninstall.ps1 # 交互式卸载" + Write-Host " .\uninstall.ps1 -Purge # 完全删除" + Write-Host " .\uninstall.ps1 -Yes # 自动确认" + exit 0 +} + +# ---------- 查找二进制文件 ---------- +function Find-Binary { + Step-Message "查找 gitlink-cli 二进制文件..." + + $possibleDirs = @( + "$env:USERPROFILE\.gitlink-cli\bin", + "$env:USERPROFILE\.local\bin", + "$env:USERPROFILE\scoop\shims", + "$env:ChocolateyInstall\bin", + "$env:USERPROFILE\go\bin" + ) + + foreach ($dir in $possibleDirs) { + $binaryPath = Join-Path $dir "gitlink-cli.exe" + if (Test-Path $binaryPath) { + $script:InstallDir = $dir + Info-Message "找到安装目录: $dir" + return $true + } + } + + # 检查PATH + if (Get-Command "gitlink-cli" -ErrorAction SilentlyContinue) { + $script:InstallDir = Split-Path (Get-Command "gitlink-cli").Source -Parent + Info-Message "通过PATH找到安装目录: $script:InstallDir" + return $true + } + + Warn-Message "未找到 gitlink-cli 二进制文件" + return $false +} + +# ---------- 检查安装状态 ---------- +function Test-InstallationStatus { + Step-Message "检查安装状态..." + + $foundItems = @() + + if ($script:InstallDir -and (Test-Path (Join-Path $script:InstallDir "gitlink-cli.exe"))) { + $foundItems += "二进制文件: $($script:InstallDir)\gitlink-cli.exe" + } + + if (Test-Path "$env:USERPROFILE\.gitlink\skills") { + $foundItems += "Skills: $env:USERPROFILE\.gitlink\skills" + } + + if (Test-Path "$env:USERPROFILE\.gitlink-cli") { + $foundItems += "配置: $env:USERPROFILE\.gitlink-cli" + } + + if (Test-Path "$env:USERPROFILE\.gitlink") { + $foundItems += "GitLink数据: $env:USERPROFILE\.gitlink" + } + + # 检查npm + try { + $null = npm list -g @gitlink-ai/cli 2>&1 | Select-String "@gitlink-ai/cli" + if ($?) { + $foundItems += "npm包: @gitlink-ai/cli (全局)" + } + } catch { + # npm未安装或包未安装 + } + + if ($foundItems.Count -eq 0) { + Warn-Message "未检测到 gitlink-cli 安装" + return $false + } + + Info-Message "检测到以下组件:" + foreach ($item in $foundItems) { + Write-Host " - $item" + } + + return $true +} + +# ---------- 删除二进制 ---------- +function Remove-Binary { + if (-not $script:InstallDir) { + Warn-Message "跳过二进制删除(未找到安装目录)" + return + } + + Step-Message "删除二进制文件..." + $binaryPath = Join-Path $script:InstallDir "gitlink-cli.exe" + + if (-not (Test-Path $binaryPath)) { + Warn-Message "二进制文件不存在: $binaryPath" + return + } + + try { + Remove-Item $binaryPath -Force + Info-Message "已删除: $binaryPath" + } catch { + Error-Message "删除失败: $_" + Error-Message "请手动删除: $binaryPath" + throw + } +} + +# ---------- 删除Skills ---------- +function Remove-Skills { + Step-Message "删除 Skills..." + + $skillsPath = "$env:USERPROFILE\.gitlink\skills" + if (-not (Test-Path $skillsPath)) { + Warn-Message "Skills目录不存在: $skillsPath" + return + } + + try { + $skillCount = (Get-ChildItem $skillsPath -Directory).Count + Info-Message "找到 $skillCount 个Skills" + + Remove-Item $skillsPath -Recurse -Force + Info-Message "已删除Skills" + } catch { + Error-Message "删除Skills失败: $_" + } +} + +# ---------- 删除配置 ---------- +function Remove-Config { + if ($Purge) { + Step-Message "删除配置文件(--purge模式)..." + } else { + if (-not $Yes) { + Ask-Question "是否删除配置文件和数据? [y/N] " + $response = Read-Host + if ($response -ne 'y' -and $response -ne 'Y') { + Info-Message "保留配置文件" + return + } + } + Step-Message "删除配置文件..." + } + + $configPaths = @( + "$env:USERPROFILE\.gitlink-cli", + "$env:USERPROFILE\.gitlink" + ) + + foreach ($path in $configPaths) { + if (Test-Path $path) { + try { + Info-Message "删除: $path" + Remove-Item $path -Recurse -Force + } catch { + Warn-Message "删除失败 $path : $_" + } + } + } + + Info-Message "配置文件已删除" +} + +# ---------- 卸载npm包 ---------- +function Uninstall-Npm { + try { + $null = npm list -g @gitlink-ai/cli 2>&1 | Select-String "@gitlink-ai/cli" + if (-not $?) { + return + } + } catch { + return + } + + Step-Message "卸载npm包..." + + if ($Yes) { + $response = 'y' + } else { + Ask-Question "检测到npm全局安装的 gitlink-cli,是否卸载? [y/N] " + $response = Read-Host + } + + if ($response -eq 'y' -or $response -eq 'Y') { + try { + npm uninstall -g @gitlink-ai/cli | Out-Null + Info-Message "npm包已卸载" + } catch { + Warn-Message "npm包卸载失败,请手动执行: npm uninstall -g @gitlink-ai/cli" + } + } else { + Info-Message "保留npm包" + } +} + +# ---------- 显示卸载摘要 ---------- +function Show-Summary { + Write-Host "" + Write-Host "========================================" + Info-Message "卸载完成!" + Write-Host "========================================" + Write-Host "" + + if ($Purge) { + Warn-Message "已完全删除所有文件(包括配置)" + } else { + Info-Message "以下文件可能需要手动清理:" + Write-Host " - $env:USERPROFILE\.gitlink-cli" + Write-Host " - $env:USERPROFILE\.gitlink" + Write-Host "" + Info-Message "如需删除,请重新运行: .\uninstall.ps1 -Purge" + } + + Write-Host "" + Info-Message "感谢使用 GitLink CLI!" + Info-Message "如有任何问题,请访问: https://www.gitlink.org.cn/Gitlink/gitlink-cli/issues" + Write-Host "" +} + +# ---------- 确认卸载 ---------- +function Confirm-Uninstall { + if ($Yes) { + return + } + + Write-Host "" + Warn-Message "即将卸载 gitlink-cli 及相关文件" + Ask-Question "确认继续? [y/N] " + $response = Read-Host + + if ($response -ne 'y' -and $response -ne 'Y') { + Info-Message "取消卸载" + exit 0 + } +} + +# ---------- 主流程 ---------- +function Main { + # 欢迎信息 + Write-Host "" + Write-Host "========================================" + Write-Host " GitLink CLI 一键卸载 (Windows)" + Write-Host "========================================" + Write-Host "" + + # 查找二进制 + if (-not (Find-Binary)) { + Error-Message "未检测到 gitlink-cli 安装" + exit 1 + } + + # 检查安装状态 + if (-not (Test-InstallationStatus)) { + Error-Message "未检测到 gitlink-cli 安装" + exit 1 + } + + # 确认卸载 + Confirm-Uninstall + + # 执行卸载 + Remove-Binary + Remove-Skills + Remove-Config + Uninstall-Npm + + # 显示摘要 + Show-Summary +} + +# 运行主流程 +try { + Main +} catch { + Error-Message "卸载失败: $_" + exit 1 +} \ No newline at end of file diff --git a/uninstall.sh b/uninstall.sh new file mode 100644 index 0000000..f1caba0 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,317 @@ +#!/bin/bash +# GitLink CLI 一键卸载脚本 +# 删除二进制、skills和配置文件 +# 用法: curl -sSL https://www.gitlink.org.cn/Gitlink/gitlink-cli/raw/master/uninstall.sh | bash +# 或者: bash uninstall.sh [--purge] + +set -e + +REPO_OWNER="Gitlink" +REPO_NAME="gitlink-cli" +BINARY_NAME="gitlink-cli" +API_BASE="https://www.gitlink.org.cn" + +# 默认安装目录(按优先级排序) +POSSIBLE_INSTALL_DIRS=( + "/usr/local/bin" + "/usr/bin" + "$HOME/.local/bin" + "$HOME/bin" + "/opt/homebrew/bin" + "$HOME/.gitlink-cli/bin" +) + +# ---------- 颜色输出 ---------- +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +BLUE='\033[0;34m' +NC='\033[0m' + +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +error() { echo -e "${RED}[ERROR]${NC} $*"; } +step() { echo -e "${CYAN}[STEP]${NC} $*"; } +ask() { echo -e "${BLUE}[ASK]${NC} $*"; } + +# ---------- 查找二进制文件 ---------- +find_binary() { + step "查找 ${BINARY_NAME} 二进制文件..." + + for dir in "${POSSIBLE_INSTALL_DIRS[@]}"; do + if [ -f "${dir}/${BINARY_NAME}" ]; then + INSTALL_DIR="${dir}" + info "找到安装目录: ${INSTALL_DIR}" + return 0 + fi + done + + # 检查是否在PATH中 + if command -v "${BINARY_NAME}" >/dev/null 2>&1; then + INSTALL_DIR="$(dirname "$(command -v "${BINARY_NAME}")")" + info "通过PATH找到安装目录: ${INSTALL_DIR}" + return 0 + fi + + warn "未找到 ${BINARY_NAME} 二进制文件" + return 1 +} + +# ---------- 检查安装状态 ---------- +check_installation_status() { + local found_items=() + + step "检查安装状态..." + + # 检查二进制 + if [ -n "${INSTALL_DIR}" ] && [ -f "${INSTALL_DIR}/${BINARY_NAME}" ]; then + found_items+=("二进制文件: ${INSTALL_DIR}/${BINARY_NAME}") + fi + + # 检查skills + if [ -d "${HOME}/.gitlink/skills" ]; then + found_items+=("Skills: ${HOME}/.gitlink/skills") + fi + + # 检查配置 + if [ -d "${HOME}/.config/gitlink-cli" ]; then + found_items+=("配置: ${HOME}/.config/gitlink-cli") + fi + + if [ -d "${HOME}/.gitlink" ]; then + found_items+=("GitLink数据: ${HOME}/.gitlink") + fi + + # 检查npm安装 + if npm list -g "@gitlink-ai/cli" >/dev/null 2>&1; then + found_items+=("npm包: @gitlink-ai/cli (全局)") + fi + + if [ ${#found_items[@]} -eq 0 ]; then + warn "未检测到 ${BINARY_NAME} 安装" + return 1 + fi + + info "检测到以下组件:" + for item in "${found_items[@]}"; do + echo " - ${item}" + done + + return 0 +} + +# ---------- 删除二进制 ---------- +remove_binary() { + if [ -z "${INSTALL_DIR}" ]; then + warn "跳过二进制删除(未找到安装目录)" + return 0 + fi + + step "删除二进制文件..." + local binary_path="${INSTALL_DIR}/${BINARY_NAME}" + + if [ ! -f "${binary_path}" ]; then + warn "二进制文件不存在: ${binary_path}" + return 0 + fi + + # 尝试删除 + if [ -w "${binary_path}" ]; then + rm -f "${binary_path}" + info "已删除: ${binary_path}" + else + if [ -w "${INSTALL_DIR}" ]; then + rm -f "${binary_path}" + info "已删除: ${binary_path}" + else + # 需要sudo权限 + warn "需要管理员权限删除二进制文件" + if sudo rm -f "${binary_path}" 2>/dev/null; then + info "已删除: ${binary_path}" + else + error "删除失败,请手动删除: ${binary_path}" + return 1 + fi + fi + fi +} + +# ---------- 删除Skills ---------- +remove_skills() { + step "删除 Skills..." + + if [ ! -d "${HOME}/.gitlink/skills" ]; then + warn "Skills目录不存在: ${HOME}/.gitlink/skills" + return 0 + fi + + # 显示Skills信息 + local skill_count=$(find "${HOME}/.gitlink/skills" -maxdepth 1 -type d | wc -l) + info "找到 ${skill_count} 个Skills" + + # 删除 + rm -rf "${HOME}/.gitlink/skills" + info "已删除Skills" +} + +# ---------- 删除配置 ---------- +remove_config() { + if [ "$PURGE_ALL" != "true" ]; then + ask "是否删除配置文件和数据? [y/N] " + read -r response + if [[ ! "$response" =~ ^[Yy]$ ]]; then + info "保留配置文件" + return 0 + fi + fi + + step "删除配置文件..." + + local config_dirs=( + "$HOME/.config/gitlink-cli" + "$HOME/.gitlink" + ) + + for dir in "${config_dirs[@]}"; do + if [ -d "$dir" ]; then + info "删除: $dir" + rm -rf "$dir" + fi + done + + info "配置文件已删除" +} + +# ---------- 卸载npm包 ---------- +uninstall_npm() { + if ! npm list -g "@gitlink-ai/cli" >/dev/null 2>&1; then + return 0 + fi + + step "卸载npm包..." + + ask "检测到npm全局安装的 ${BINARY_NAME},是否卸载? [y/N] " + read -r response + + if [[ "$response" =~ ^[Yy]$ ]]; then + if npm uninstall -g "@gitlink-ai/cli" 2>/dev/null; then + info "npm包已卸载" + else + warn "npm包卸载失败,请手动执行: npm uninstall -g @gitlink-ai/cli" + fi + else + info "保留npm包" + fi +} + +# ---------- 显示卸载摘要 ---------- +show_summary() { + echo "" + echo "========================================" + info "卸载完成!" + echo "========================================" + echo "" + + if [ "$PURGE_ALL" = "true" ]; then + warn "已完全删除所有文件(包括配置)" + else + info "以下文件可能需要手动清理:" + echo " - ${HOME}/.config/gitlink-cli" + echo " - ${HOME}/.gitlink" + echo "" + info "如需删除,请重新运行: bash uninstall.sh --purge" + fi + + echo "" + info "感谢使用 GitLink CLI!" + echo "如有任何问题,请访问: https://www.gitlink.org.cn/Gitlink/gitlink-cli/issues" + echo "" +} + +# ---------- 确认卸载 ---------- +confirm_uninstall() { + if [ "$AUTO_CONFIRM" = "true" ]; then + return 0 + fi + + echo "" + warn "即将卸载 ${BINARY_NAME} 及相关文件" + ask "确认继续? [y/N] " + read -r response + + if [[ ! "$response" =~ ^[Yy]$ ]]; then + info "取消卸载" + exit 0 + fi +} + +# ---------- 主流程 ---------- +main() { + # 解析参数 + PURGE_ALL="false" + AUTO_CONFIRM="false" + + while [[ $# -gt 0 ]]; do + case $1 in + --purge) + PURGE_ALL="true" + shift + ;; + --yes|-y) + AUTO_CONFIRM="true" + shift + ;; + --help|-h) + echo "用法: bash uninstall.sh [选项]" + echo "" + echo "选项:" + echo " --purge 删除所有文件(包括配置)" + echo " --yes, -y 自动确认,不询问" + echo " --help, -h 显示此帮助" + echo "" + echo "示例:" + echo " bash uninstall.sh # 交互式卸载" + echo " bash uninstall.sh --purge # 完全删除" + echo " bash uninstall.sh -y # 自动确认" + exit 0 + ;; + *) + error "未知选项: $1" + echo "使用 --help 查看帮助" + exit 1 + ;; + esac + done + + # 欢迎信息 + echo "" + echo "========================================" + echo " GitLink CLI 一键卸载" + echo "========================================" + echo "" + + # 查找二进制 + find_binary + + # 检查安装状态 + if ! check_installation_status; then + error "未检测到 ${BINARY_NAME} 安装" + exit 1 + fi + + # 确认卸载 + confirm_uninstall + + # 执行卸载 + remove_binary + remove_skills + remove_config + uninstall_npm + + # 显示摘要 + show_summary +} + +# 运行主流程 +main "$@" \ No newline at end of file diff --git a/workflows/01-community-ops.ps1 b/workflows/01-community-ops.ps1 new file mode 100644 index 0000000..f498864 --- /dev/null +++ b/workflows/01-community-ops.ps1 @@ -0,0 +1,501 @@ +# ---------------------------------------------------------------- +# Scenario 1: Community Operations Automation (定时批量链路) +# Flow: 收集周期数据 → 10类关键词分类 → 生成 Release Notes + 社区周报 +# 遵循 gitlink-changelog skill 的收集→分类→发布流程 +# 条目格式: - 描述 (#编号) (@作者) [分类] + +# ---------------------------------------------------------------- +#Requires -Version 5.1 + +param( + [string]$Owner = "", + [string]$Repo = "", + [int]$PeriodHours = 6, + [string]$ReleaseVersion = "", + [switch]$DryRun, + [switch]$Help +) + +$ErrorActionPreference = "Stop" +Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue + +if ($Help) { + Write-Host "Usage: powershell 01-community-ops.ps1 [-Owner O] [-Repo R] [-PeriodHours N] [-ReleaseVersion TAG] [-DryRun]" + exit 0 +} + +Check-Auth +$r = Resolve-OwnerRepo $Owner $Repo +$Owner = $r.Owner; $Repo = $r.Repo + +# ================================================================ +# Phase 1: 时间窗口和基线 +# ================================================================ +Log-Title "Phase 1: Time Window" + + +$periodStart = (Get-Date).AddHours(-$PeriodHours) +$periodEnd = Get-Date +$periodStartStr = $periodStart.ToString("yyyy-MM-dd HH:mm") +$periodEndStr = $periodEnd.ToString("yyyy-MM-dd HH:mm") + + +Log-Step "Finding previous release..." +$prevTag = "" +$releasesJson = Invoke-GL release,+list,--owner,$Owner,--repo,$Repo,--limit,5 +if ($releasesJson) { + try { + $relData = ($releasesJson | ConvertFrom-Json).data + $releases = if ($relData.releases) { @($relData.releases) } elseif ($relData -is [array]) { @($relData) } else { @() } + if ($releases.Count -gt 0) { + $prevTag = if ($releases[0].tag_name) { $releases[0].tag_name } else { "" } + + } + } catch {} +} +if (-not $prevTag) { $prevTag = "initial" } + +$newVersion = if ($ReleaseVersion) { $ReleaseVersion } else { "weekly-$(Get-Date -Format 'yyyyMMdd')" } +Log-Ok "Period: $periodStartStr ~ $periodEndStr" +Log-Ok "Release: $prevTag -> $newVersion" + +# ================================================================ +# Phase 2: 收集数据 +# ================================================================ +Log-Title "Phase 2: Collect Data" + +# -- Commits -- +Log-Step "Collecting commits..." +$commitCount = 0 +$commitList = "(无 commit 数据)" +if ($prevTag -ne "initial") { + $compareJson = Invoke-GL api,GET,"/v1/$Owner/$Repo/compare/$prevTag...master" + if ($compareJson) { + try { + $cData = ($compareJson | ConvertFrom-Json).data + if ($cData.commits) { + $commits = @($cData.commits) + $commitCount = $commits.Count + $lines = @() + $show = [Math]::Min($commitCount, 50) + for ($i = 0; $i -lt $show; $i++) { + $msg = if ($commits[$i].commit.message) { ($commits[$i].commit.message -split "`n")[0] } else { "N/A" } + $author = if ($commits[$i].commit.author.name) { $commits[$i].commit.author.name } else { "unknown" } + $lines += "- $msg ($author)" + } + $commitList = $lines -join "`n" + } + } catch {} + + } +} +Log-Ok "Commits: $commitCount" + +# -- Merged PRs -- +Log-Step "Collecting merged PRs..." +$prItems = @() +$prContributors = @{} +$prsJson = Invoke-GL pr,+list,--owner,$Owner,--repo,$Repo,--state,merged,--limit,100 +if ($prsJson) { + try { + $prData = ($prsJson | ConvertFrom-Json).data + $prs = if ($prData.issues) { @($prData.issues) } elseif ($prData.pulls) { @($prData.pulls) } elseif ($prData -is [array]) { @($prData) } else { @() } + foreach ($pr in $prs) { + $prTitle = if ($pr.name) { $pr.name } elseif ($pr.subject) { $pr.subject } elseif ($pr.title) { $pr.title } else { "N/A" } + $prNum = if ($pr.pull_request_number) { $pr.pull_request_number } elseif ($pr.number) { $pr.number } else { "?" } + $prAuthor = if ($pr.author_login) { $pr.author_login } elseif ($pr.author.login) { $pr.author.login } else { "?" } + $prItems += "- $prTitle (#$prNum) (@$prAuthor)" + $prContributors[$prAuthor] = $true + + } + } catch {} +} +$prCount = $prItems.Count +$prText = if ($prCount -gt 0) { ($prItems -join "`n") } else { "(本周期无 PR 合并)" } +Log-Ok "Merged PRs: $prCount" + +# -- Issues (新增 + 关闭) -- +Log-Step "Collecting issues..." +$bugLines = @() +$featLines = @() +$docLines = @() +$otherLines = @() +$issContributors = @{} +$issCount = 0 +# Dedupe by issue index: the same issue can be returned by both the open +# and closed state queries, which would otherwise double-count it. +$seenIssues = @{} +$today = (Get-Date -Format "yyyy-MM-dd") +$yesterday = ((Get-Date).AddDays(-1)).ToString("yyyy-MM-dd") + +foreach ($state in @("open","closed")) { + $issJson = Invoke-GL issue,+list,--owner,$Owner,--repo,$Repo,--state,$state,--limit,100 + if (-not $issJson) { continue } + try { + $issData = ($issJson | ConvertFrom-Json).data + $issues = if ($issData.issues) { @($issData.issues) } elseif ($issData -is [array]) { @($issData) } else { @() } + foreach ($iss in $issues) { + $num = if ($iss.project_issues_index) { $iss.project_issues_index } elseif ($iss.number) { $iss.number } else { "?" } + if ($num -ne "?" -and $seenIssues.ContainsKey($num)) { continue } + if ($num -ne "?") { $seenIssues[$num] = $true } + $title = if ($iss.subject) { $iss.subject } elseif ($iss.title) { $iss.title } else { "N/A" } + $desc = if ($iss.description) { $iss.description } else { "" } + $author = if ($iss.author.login) { $iss.author.login } elseif ($iss.author.username) { $iss.author.username } else { "?" } + $created = if ($iss.created_at) { $iss.created_at } else { "" } + $closed = if ($iss.closed_at) { $iss.closed_at } else { "" } + $stateName = if ($iss.status.name) { $iss.status.name } elseif ($iss.state) { $iss.state } else { "?" } + + # 时间筛选:created 或 closed >= periodStart + $inPeriod = $false + if ($created) { try { if (([DateTime]$created) -ge $periodStart) { $inPeriod = $true } } catch {} } + if (-not $inPeriod -and $closed) { try { if (([DateTime]$closed) -ge $periodStart) { $inPeriod = $true } } catch {} } + if (-not $inPeriod) { continue } + + $line = "- $title (#$num) (@$author)" + if ($stateName -match "关闭|closed") { $line += " [已关闭]" } + + # 标题+描述 关键词分类 (10 个标准类别) + $combined = "$title $desc".ToLower() + if ($combined -match '(?i)bug|error|crash|fault|fix|缺陷|错误|异常|崩溃|修复|故障') { + $line += " [缺陷]"; $bugLines += $line + } elseif ($combined -match '(?i)feature|enhancement|add|新增|建议|功能|特性|新功能|支持|request') { + $line += " [功能]"; $featLines += $line + } elseif ($combined -match '(?i)doc|readme|guide|wiki|tutorial|文档|说明|教程|手册') { + $line += " [文档]"; $docLines += $line + } elseif ($combined -match '(?i)test|测试|用例|覆盖|验证') { + $line += " [测试]"; $docLines += $line + } elseif ($combined -match '(?i)duplicate|重复|重复的') { + $line += " [重复]"; $docLines += $line + } elseif ($combined -match '(?i)question|疑问|不确定|讨论|澄清|是否|可否') { + $line += " [疑问]"; $docLines += $line + } elseif ($combined -match '(?i)help|协助|帮助|协作|请求帮助|互助') { + $line += " [协助]"; $docLines += $line + } elseif ($combined -match '(?i)postpone|wontfix|暂缓|搁置|低优|不重要|不紧急|暂不|delay') { + $line += " [搁置]"; $docLines += $line + } elseif ($combined -match '(?i)task|todo|任务|待办|计划|安排') { + $line += " [任务]"; $docLines += $line + } elseif ($combined -match '(?i)support|兼容|环境|依赖|平台|适配') { + $line += " [支持]"; $docLines += $line + } else { + $line += " [其他]"; $otherLines += $line + } + + if ($author -ne "?") { $issContributors[$author] = $true } + $issCount++ + } + } catch {} +} +$bugSection = if ($bugLines.Count -gt 0) { ($bugLines -join "`n") } else { "_无_" } +$featSection = if ($featLines.Count -gt 0) { ($featLines -join "`n") } else { "_无_" } +$docSection = if ($docLines.Count -gt 0) { ($docLines -join "`n") } else { "_无_" } +$otherSection = if ($otherLines.Count -gt 0) { ($otherLines -join "`n") } else { "_无_" } +Log-Ok "Issues in period: $issCount" + +# -- 动态收集标签 ID 映射 (与 01a-issue-triage.ps1 一致) -- +Log-Step "Discovering label IDs..." +$tagIdMap = @{} + +# 优先从项目 issue_tags API 获取 +$tagsApi = Invoke-GL api,GET,"/v1/$Owner/$Repo/issue_tags" +if ($tagsApi) { + try { + $tData = ($tagsApi | ConvertFrom-Json).data + $tagList = if ($tData.issue_tags) { @($tData.issue_tags) } elseif ($tData -is [array]) { @($tData) } else { @() } + foreach ($t in $tagList) { + if ($t.id -and $t.name) { $tagIdMap[$t.name] = $t.id } + } + } catch {} +} + +# 补充:从已有 issue 的 tags 字段收集 +foreach ($state in @("open","closed")) { + $sample = Invoke-GL issue,+list,--owner,$Owner,--repo,$Repo,--state,$state,--limit,50 + if ($sample) { + try { + $sData = ($sample | ConvertFrom-Json).data + $sIssues = if ($sData.issues) { @($sData.issues) } elseif ($sData -is [array]) { @($sData) } else { @() } + foreach ($iss in $sIssues) { + if ($iss.tags) { + foreach ($t in $iss.tags) { + if ($t.id -and $t.name) { $tagIdMap[$t.name] = $t.id } + } + } + } + } catch {} + } +} +$tagFound = @() +foreach ($k in $tagIdMap.Keys) { $tagFound += "$k($($tagIdMap[$k]))" } +if ($tagFound.Count -gt 0) { Log-Ok "Found tags: $($tagFound -join ' ')" } else { Log-Warn "No tags found in repo" } + +# -- 打标签 & 分配责任人 (自动分类后的实际写入) -- +# 收集需要处理的 Issue 信息 (编号, issue ID, 标签名, 作者) +$triageItems = @() +$seenTriage = @{} + +foreach ($state in @("open","closed")) { + $issJson2 = Invoke-GL issue,+list,--owner,$Owner,--repo,$Repo,--state,$state,--limit,100 + if (-not $issJson2) { continue } + try { + $issData2 = ($issJson2 | ConvertFrom-Json).data + $issues2 = if ($issData2.issues) { @($issData2.issues) } elseif ($issData2 -is [array]) { @($issData2) } else { @() } + foreach ($iss in $issues2) { + $num = if ($iss.project_issues_index) { $iss.project_issues_index } elseif ($iss.number) { $iss.number } else { "?" } + if ($num -eq "?" -or $seenTriage.ContainsKey($num)) { continue } + $created = if ($iss.created_at) { $iss.created_at } else { "" } + $closed = if ($iss.closed_at) { $iss.closed_at } else { "" } + $inPeriod = $false + if ($created) { try { if (([DateTime]$created) -ge $periodStart) { $inPeriod = $true } } catch {} } + if (-not $inPeriod -and $closed) { try { if (([DateTime]$closed) -ge $periodStart) { $inPeriod = $true } } catch {} } + if (-not $inPeriod) { continue } + + $seenTriage[$num] = $true + $title2 = if ($iss.subject) { $iss.subject } elseif ($iss.title) { $iss.title } else { "" } + $desc2 = if ($iss.description) { $iss.description } else { "" } + $author2 = if ($iss.author.login) { $iss.author.login } elseif ($iss.author.username) { $iss.author.username } else { "" } + $issueId2 = if ($iss.id) { $iss.id } else { $num } + + # 检查是否已有标签 + $hasLabel = $false + if ($iss.tags -and @($iss.tags).Count -gt 0) { $hasLabel = $true } + + # 关键词分类 + $combined2 = "$title2 $desc2".ToLower() + $chosenLabel = "" + if ($combined2 -match '(?i)bug|error|crash|fault|fix|缺陷|错误|异常|崩溃|修复|故障') { + $chosenLabel = "缺陷" + } elseif ($combined2 -match '(?i)feature|enhancement|add|新增|建议|功能|特性|新功能|支持|request') { + $chosenLabel = "功能" + } elseif ($combined2 -match '(?i)doc|readme|guide|wiki|tutorial|文档|说明|教程|手册') { + $chosenLabel = "文档" + } elseif ($combined2 -match '(?i)test|测试|用例|覆盖|验证') { + $chosenLabel = "测试" + } elseif ($combined2 -match '(?i)duplicate|重复|重复的') { + $chosenLabel = "重复" + } elseif ($combined2 -match '(?i)question|疑问|不确定|讨论|澄清|是否|可否') { + $chosenLabel = "疑问" + } elseif ($combined2 -match '(?i)help|协助|帮助|协作|请求帮助|互助') { + $chosenLabel = "协助" + } elseif ($combined2 -match '(?i)postpone|wontfix|暂缓|搁置|低优|不重要|不紧急|暂不|delay') { + $chosenLabel = "搁置" + } elseif ($combined2 -match '(?i)task|todo|任务|待办|计划|安排') { + $chosenLabel = "任务" + } elseif ($combined2 -match '(?i)support|兼容|环境|依赖|平台|适配') { + $chosenLabel = "支持" + } + + # 检查是否已有负责人 + $hasAssignee = $false + if ($iss.assigned_to_id -and "$($iss.assigned_to_id)" -ne "0" -and "$($iss.assigned_to_id)" -ne "") { $hasAssignee = $true } + if ($iss.assigned_to -and $iss.assigned_to.login) { $hasAssignee = $true } + + if ($chosenLabel -or -not $hasAssignee) { + $triageItems += @{ num=$num; id=$issueId2; title=$title2; desc=$desc2; author=$author2; label=$chosenLabel; hasLabel=$hasLabel; hasAssignee=$hasAssignee } + } + } + } catch {} +} + +$labelOk = 0; $labelFail = 0; $labelSkip = 0 +$assignOk = 0; $assignFail = 0; $assignSkip = 0 + +if ($triageItems.Count -gt 0) { + Log-Step "Applying labels and assignees to $($triageItems.Count) issue(s)..." + foreach ($item in $triageItems) { + # -- 打标签 -- + if (-not $item.label) { + $labelSkip++ + } elseif ($item.hasLabel) { + Log-Info " #$($item.num) already has label, skipping" + $labelSkip++ + } elseif ($DryRun) { + Log-Warn " [DRY RUN] Would tag #$($item.num) with '$($item.label)'" + $labelSkip++ + } else { + $tgtId = $tagIdMap[$item.label] + if (-not $tgtId) { + Log-Warn " #$($item.num): label '$($item.label)' not found in repo tags — create on website first" + $labelFail++ + } else { + $bodyJson = "{`"issue_tag_ids`":[$tgtId]}" + $tagResult = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$($item.id)",--body,$bodyJson + if ($tagResult) { + try { + $tagOkFlag = (($tagResult | ConvertFrom-Json).ok -eq $true) + } catch { $tagOkFlag = $false } + if ($tagOkFlag) { Log-Ok " #$($item.num) tagged: $($item.label)"; $labelOk++ } else { Log-Warn " #$($item.num) tag failed"; $labelFail++ } + } else { Log-Warn " #$($item.num) tag failed (no response)"; $labelFail++ } + } + } + + # -- 分配责任人 (默认分配给 Issue 作者) -- + if ($item.hasAssignee) { + $assignSkip++ + } elseif (-not $item.author -or $item.author -eq "?") { + Log-Info " #$($item.num): no author info, skipping assign" + $assignSkip++ + } elseif ($DryRun) { + Log-Warn " [DRY RUN] Would assign #$($item.num) to @$($item.author)" + $assignSkip++ + } else { + # Resolve login name to numeric user ID + $authorID = $null + $isNum = $false + try { [void][int]$item.author; $isNum = $true } catch {} + if ($isNum) { + $authorID = $item.author + } else { + $userJson = Invoke-GL api,GET,"/users/$($item.author)" + if ($userJson) { + try { + $userData = ($userJson | ConvertFrom-Json).data + if ($userData.id) { $authorID = [int]$userData.id } + elseif ($userData.user_id) { $authorID = [int]$userData.user_id } + } catch {} + } + } + if (-not $authorID) { + Log-Warn " #$($item.num): cannot resolve user ID for '@$($item.author)'" + $assignFail++ + } else { + $bodyJson = "{`"assigner_ids`":[$authorID]}" + $assignResult = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$($item.id)",--body,$bodyJson + if ($assignResult) { + try { + $assignOkFlag = (($assignResult | ConvertFrom-Json).ok -eq $true) + } catch { $assignOkFlag = $false } + if ($assignOkFlag) { Log-Ok " #$($item.num) assigned to @$($item.author) (ID:$authorID)"; $assignOk++ } else { Log-Warn " #$($item.num) assign failed"; $assignFail++ } + } else { Log-Warn " #$($item.num) assign failed (no response)"; $assignFail++ } + } + } + } + Log-Ok "Labels: $labelOk applied, $labelFail failed, $labelSkip skipped" + Log-Ok "Assign: $assignOk applied, $assignFail failed, $assignSkip skipped" +} else { + Log-Info "No new issues require triage in this period" +} + +# -- 贡献者汇总 -- +$allContribs = @($prContributors.Keys; $issContributors.Keys) | Select-Object -Unique | Sort-Object +$contribText = if ($allContribs.Count -gt 0) { ($allContribs | ForEach-Object { "- @$_" }) -join "`n" } else { "(无活跃贡献者)" } + +# ================================================================ +# Phase 3: 生成 Release Notes (changelog skill 模板) +# ================================================================ +Log-Title "Phase 3: Generate Release Notes" + +$releaseBody = @" +# 🎉 Release $newVersion + +## 📊变更统计 +- **周期**: $periodStartStr ~ $periodEndStr +- **已合并 PR**: $prCount 个 +- **Issue 活动**: $issCount 条(新增/关闭) +- **Commits**: $commitCount 条 + +## 📝 Issue 活动 + +### 🐛 缺陷 / Bug +$bugSection + +### ✨ 新功能 / Feature +$featSection + +### 📖 文档 / 其他 +$docSection + +### 💡 其他 +$otherSection + +## 🔀 已合并 PR +$prText + +## 🙏 贡献者 +$contribText + +--- +**完整变更日志**: https://www.gitlink.org.cn/$Owner/$Repo/compare/$prevTag...$newVersion + +*Auto-generated by gitlink-cli community-ops workflow (gitlink-changelog skill)* +"@ + +Write-Host $releaseBody +Write-Host "" + +# ================================================================ +# Phase 4: 发布 Release +# ================================================================ +Log-Title "Phase 4: Publish Release" + +if ($DryRun) { + Log-Warn "[DRY RUN] Would create release: $newVersion" +} else { + Log-Step "Creating release $newVersion..." + $relResult = Invoke-GL release,+create,--owner,$Owner,--repo,$Repo,--tag,$newVersion,--name,"Release $newVersion",--body,$releaseBody + if ($relResult -and (Get-JsonOk ($relResult | ConvertFrom-Json))) { + Log-Ok "Release $newVersion published!" + Log-Info "View: https://www.gitlink.org.cn/$Owner/$Repo/releases" + } else { + Log-Warn "Release may have failed (tag might exist)" + } +} + +# ================================================================ +# Phase 5: 发布社区周报 (覆盖更新同一天) +# ================================================================ +Log-Title "Phase 5: Publish Weekly Report" + +$wikiTitle = "社区周报" +$wikiBody = @" +# 社区周报 - $Owner/$Repo + +**$periodStartStr ~ $periodEndStr** + +--- + +## 📊 数据总览 +- 已合并 PR: **$prCount** 个 +- Issue 活动: **$issCount** 条 +- 贡献者: $($allContribs.Count) + +## 🔀 合并的 PR +$prText + +## 📝 Issue 动态 +$bugSection +$featSection +$docSection +$otherSection + +--- + +*Auto-generated by gitlink-cli | $periodStartStr ~ $periodEndStr | Next report in ~$PeriodHours h* +"@ + +if ($DryRun) { + Log-Warn "[DRY RUN] Would publish Wiki" +} else { + Log-Step "Publishing to Wiki..." + # 先尝试覆盖更新,不存在则新建 + $wikiResult = Invoke-GL wiki,+update,--owner,$Owner,--repo,$Repo,--title,$wikiTitle,--cover,$wikiBody + if ($wikiResult -and (Get-JsonOk ($wikiResult | ConvertFrom-Json))) { + Log-Ok "Weekly report updated on Wiki" + } else { + Log-Info "Page not found, creating new..." + $wikiResult = Invoke-GL wiki,+create,--owner,$Owner,--repo,$Repo,--title,$wikiTitle,--content,$wikiBody + if ($wikiResult -and (Get-JsonOk ($wikiResult | ConvertFrom-Json))) { + Log-Ok "Weekly report created on Wiki" + } else { + Log-Warn "Wiki publish failed" + } + } +} + +Log-Title "Complete" +Write-Host " Release: $newVersion" -ForegroundColor Green +Write-Host " PRs merged: $prCount" -ForegroundColor Green +Write-Host " Issues: $issCount" -ForegroundColor Green +Write-Host " Labels: $labelOk applied / $labelFail failed / $labelSkip skipped" -ForegroundColor Green +Write-Host " Assigned: $assignOk applied / $assignFail failed / $assignSkip skipped" -ForegroundColor Green +Write-Host " Contributors: $($allContribs.Count)" -ForegroundColor Green +Write-Host " Wiki: $wikiTitle" -ForegroundColor Green diff --git a/workflows/01-community-ops.sh b/workflows/01-community-ops.sh new file mode 100644 index 0000000..3dfd5e9 --- /dev/null +++ b/workflows/01-community-ops.sh @@ -0,0 +1,327 @@ +#!/usr/bin/env bash +# ================================================================ +# Scenario 1: Community Operations (定时批量链路, 48h周期) +# Flow: 收集周期数据 → 分类 → 生成 Release Notes → 发布 Release + Wiki +# +# 遵循 gitlink-changelog skill 的收集→分类→发布流程 +# 条目格式: - 描述 (#编号) (@作者) +# ================================================================ +set -o pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" + +PERIOD_HOURS=48 +RELEASE_VERSION="" +OWNER="" +REPO="" +DRY_RUN=false + +usage() { + echo "Usage: $0 [--owner O] [--repo R] [--period-hours N] [--release-version TAG] [--dry-run]" + echo "" + echo " 定时链路 — 收集周期内数据,按 gitlink-changelog skill 生成 Release Notes 和社区周报。" + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --owner) OWNER="$2"; shift 2 ;; + --repo) REPO="$2"; shift 2 ;; + --period-hours) PERIOD_HOURS="$2"; shift 2 ;; + --release-version) RELEASE_VERSION="$2"; shift 2 ;; + --dry-run) DRY_RUN="true"; shift ;; + --help|-h) usage ;; + *) log_err "Unknown arg: $1"; usage ;; + esac +done + +check_auth +require_owner_repo + +# ================================================================ +# Phase 1: 确定时间窗口和基线 +# ================================================================ +log_title "Phase 1: Time Window" + +PERIOD_START=$(date -d "$PERIOD_HOURS hours ago" +"%Y-%m-%d %H:%M" 2>/dev/null || date -v-${PERIOD_HOURS}H +"%Y-%m-%d %H:%M") +PERIOD_END=$(date +"%Y-%m-%d %H:%M") +PERIOD_START_TS=$(date -d "$PERIOD_START" +%s 2>/dev/null || date -j -f "%Y-%m-%d %H:%M" "$PERIOD_START" +%s) + +# 找上一版本 +log_step "Finding previous release..." +PREV_TAG="" +PREV_DATE="" +RELEASES_JSON=$(gl_run release +list --owner "$OWNER" --repo "$REPO" --limit 5 2>/dev/null || true) +if [[ -n "$RELEASES_JSON" ]]; then + PREV_TAG=$(echo "$RELEASES_JSON" | jq -r '(.data.releases // .data // [])[0].tag_name // ""' 2>/dev/null || echo "") + PREV_DATE=$(echo "$RELEASES_JSON" | jq -r '(.data.releases // .data // [])[0].created_at // ""' 2>/dev/null || echo "") +fi + +if [[ -z "$PREV_TAG" || "$PREV_TAG" == "null" ]]; then + log_info "No previous release — this will be the first" + PREV_TAG="initial" +fi + +NEW_VERSION="${RELEASE_VERSION:-weekly-$(date +%Y%m%d)}" +log_ok "Period: ${PERIOD_START} ~ ${PERIOD_END}" +log_ok "Release: ${PREV_TAG} → ${NEW_VERSION}" + +# ================================================================ +# Phase 2: 收集数据 (changelog skill: collect-data) +# ================================================================ +log_title "Phase 2: Collect Data" + +# ── Commits ── +log_step "Collecting commits..." +COMMIT_LIST="" +COMMIT_COUNT=0 +if [[ "$PREV_TAG" != "initial" ]]; then + COMPARE_JSON=$(gl_run api GET "/v1/$OWNER/$REPO/compare/$PREV_TAG...master" 2>/dev/null || true) + if [[ -n "$COMPARE_JSON" ]]; then + COMMIT_COUNT=$(echo "$COMPARE_JSON" | jq '.data.total_commits // 0' 2>/dev/null || echo "0") + COMMIT_LIST=$(echo "$COMPARE_JSON" | jq -r '[.data.commits[]? | "- \(.commit.message | split("\n")[0]) (\(.commit.author.name // "unknown"))"] | .[:50] | join("\n")' 2>/dev/null || echo "") + fi +fi +[[ -z "$COMMIT_LIST" ]] && COMMIT_LIST="(无 commit 数据)" +log_ok "Commits: $COMMIT_COUNT" + +# ── Merged PRs (用临时文件避免 pipefail 静默退出) ── +log_step "Collecting merged PRs..." +PR_ITEMS="" +PR_COUNT=0 +PR_CONTRIBUTORS="" +PR_TMP=$(mktemp) +PRS_JSON=$(gl_run pr +list --owner "$OWNER" --repo "$REPO" --state merged --limit 100 2>/dev/null || true) +if [[ -n "$PRS_JSON" ]]; then + echo "$PRS_JSON" | jq -r '(.data.issues // .data.pulls // .data // [])[]? | "\(.name // .subject // .title // "N/A")\t\(.pull_request_number // .number // .id)\t\(.author_login // .author.login // "?")"' 2>/dev/null > "$PR_TMP" || true + while IFS=$'\t' read -r pr_title pr_num pr_author || [[ -n "$pr_title" ]]; do + PR_ITEMS+="- ${pr_title} (#${pr_num}) (@${pr_author})"$'\n' + PR_CONTRIBUTORS+="@${pr_author} " + PR_COUNT=$((PR_COUNT + 1)) || true + done < "$PR_TMP" +fi +rm -f "$PR_TMP" +[[ -z "$PR_ITEMS" ]] && PR_ITEMS="(本周期无 PR 合并)" +log_ok "Merged PRs in period: $PR_COUNT" + +# ── Issues (新增 + 关闭) — 用临时文件避免子 shell 变量丢失 ── +log_step "Collecting issues..." + +ISS_FEAT_FILE=$(mktemp) +ISS_BUG_FILE=$(mktemp) +ISS_DOC_FILE=$(mktemp) +ISS_OTHER_FILE=$(mktemp) +ISS_AUTHORS_FILE=$(mktemp) +ISS_COUNT_FILE=$(mktemp) +echo "0" > "$ISS_COUNT_FILE" + +for state in open closed; do + ISS_JSON=$(gl_run issue +list --owner "$OWNER" --repo "$REPO" --state "$state" --limit 100 2>/dev/null || true) + [[ -z "$ISS_JSON" ]] && continue + echo "$ISS_JSON" | jq -c '(.data.issues // .data // [])[]' 2>/dev/null | while read -r issue; do + NUM=$(echo "$issue" | jq -r '.project_issues_index // .number // .id') + TITLE=$(echo "$issue" | jq -r '.subject // .title // "N/A"') + DESC=$(echo "$issue" | jq -r '.description // ""') + AUTHOR=$(echo "$issue" | jq -r '.author.login // .author.username // "?"') + CREATED=$(echo "$issue" | jq -r '.created_at // ""') + CLOSED=$(echo "$issue" | jq -r '.closed_at // ""') + STATE=$(echo "$issue" | jq -r '.status.name // .state // "?"') + + # 时间筛选:created 或 closed >= PERIOD_START + IN_PERIOD=false + if [[ -n "$CREATED" ]]; then CT_TS=$(date -d "$CREATED" +%s 2>/dev/null || echo "0"); [[ "$CT_TS" -ge "$PERIOD_START_TS" ]] && IN_PERIOD=true; fi + [[ "$IN_PERIOD" != "true" && -n "$CLOSED" ]] && { CL_TS=$(date -d "$CLOSED" +%s 2>/dev/null || echo "0"); [[ "$CL_TS" -ge "$PERIOD_START_TS" ]] && IN_PERIOD=true; } + [[ "$IN_PERIOD" != "true" ]] && continue + + LINE="- ${TITLE} (#${NUM}) (@${AUTHOR})" + [[ "$STATE" =~ 关闭|closed ]] && LINE+=" [已关闭]" + + # 关键词分类 — 标题 + 描述 都参与匹配, 10 个标准类别 + COMBINED=$(echo "$TITLE $DESC" | tr '[:upper:]' '[:lower:]') + if echo "$COMBINED" | grep -qiE 'bug|error|crash|fault|fix|缺陷|错误|异常|崩溃|修复|故障'; then + CATEGORY="缺陷" + echo "${LINE} [缺陷]" >> "$ISS_BUG_FILE" + elif echo "$COMBINED" | grep -qiE 'feature|enhancement|add|新增|建议|功能|特性|新功能|支持|request'; then + CATEGORY="功能" + echo "${LINE} [功能]" >> "$ISS_FEAT_FILE" + elif echo "$COMBINED" | grep -qiE 'doc|readme|guide|wiki|tutorial|文档|说明|教程|手册'; then + CATEGORY="文档" + echo "${LINE} [文档]" >> "$ISS_DOC_FILE" + elif echo "$COMBINED" | grep -qiE 'test|测试|用例|覆盖|验证'; then + CATEGORY="测试" + echo "${LINE} [测试]" >> "$ISS_DOC_FILE" + elif echo "$COMBINED" | grep -qiE 'duplicate|重复|重复的|和.*一样'; then + CATEGORY="重复" + echo "${LINE} [重复]" >> "$ISS_DOC_FILE" + elif echo "$COMBINED" | grep -qiE 'question|疑问|不确定|讨论|澄清|是否|可否'; then + CATEGORY="疑问" + echo "${LINE} [疑问]" >> "$ISS_DOC_FILE" + elif echo "$COMBINED" | grep -qiE 'help|协助|帮助|协作|请求帮助|互助'; then + CATEGORY="协助" + echo "${LINE} [协助]" >> "$ISS_DOC_FILE" + elif echo "$COMBINED" | grep -qiE 'postpone|wontfix|暂缓|搁置|低优|不重要|不紧急|暂不|delay'; then + CATEGORY="搁置" + echo "${LINE} [搁置]" >> "$ISS_DOC_FILE" + elif echo "$COMBINED" | grep -qiE 'task|todo|任务|待办|计划|安排'; then + CATEGORY="任务" + echo "${LINE} [任务]" >> "$ISS_DOC_FILE" + elif echo "$COMBINED" | grep -qiE 'support|兼容|环境|依赖|平台|适配'; then + CATEGORY="支持" + echo "${LINE} [支持]" >> "$ISS_DOC_FILE" + else + echo "${LINE} [其他]" >> "$ISS_OTHER_FILE" + fi + echo "@${AUTHOR}" >> "$ISS_AUTHORS_FILE" + # 计数 + CNT=$(cat "$ISS_COUNT_FILE"); echo $((CNT + 1)) > "$ISS_COUNT_FILE" + done +done + +ISSUE_FEATURES=$(cat "$ISS_FEAT_FILE" 2>/dev/null || echo "") +ISSUE_BUGS=$(cat "$ISS_BUG_FILE" 2>/dev/null || echo "") +ISSUE_DOCS=$(cat "$ISS_DOC_FILE" 2>/dev/null || echo "") +ISSUE_OTHER=$(cat "$ISS_OTHER_FILE" 2>/dev/null || echo "") +ISSUE_COUNT=$(cat "$ISS_COUNT_FILE" 2>/dev/null || echo "0") +ISSUE_CONTRIBUTORS=$(sort -u "$ISS_AUTHORS_FILE" 2>/dev/null | tr '\n' ' ' || echo "") +rm -f "$ISS_FEAT_FILE" "$ISS_BUG_FILE" "$ISS_DOC_FILE" "$ISS_OTHER_FILE" "$ISS_AUTHORS_FILE" "$ISS_COUNT_FILE" + +log_ok "Issues in period: $ISSUE_COUNT" + +# ── 贡献者汇总 ── +ALL_CONTRIBUTORS=$(echo "$PR_CONTRIBUTORS $ISSUE_CONTRIBUTORS" | tr ' ' '\n' | sort -u | grep -v '^$' | sed 's/^/- /' | tr '\n' ' ') +[[ -z "$ALL_CONTRIBUTORS" ]] && ALL_CONTRIBUTORS="(无活跃贡献者)" + +# ================================================================ +# Phase 3: 生成 Release Notes (changelog skill: 标准模板) +# ================================================================ +log_title "Phase 3: Generate Release Notes" + +FEAT_SECTION="${ISSUE_FEATURES:-_无_}" +BUG_SECTION="${ISSUE_BUGS:-_无_}" +DOC_SECTION="${ISSUE_DOCS:-_无_}" +OTHER_SECTION="${ISSUE_OTHER:-_无_}" + +FEAT_COUNT=$(echo "$ISSUE_FEATURES" | grep -c '^-' 2>/dev/null || echo "0") +BUG_COUNT=$(echo "$ISSUE_BUGS" | grep -c '^-' 2>/dev/null || echo "0") + +RELEASE_BODY="# 🎉 Release ${NEW_VERSION} + +## 📊 变更统计 +- **周期**: ${PERIOD_START} ~ ${PERIOD_END} +- **已合并 PR**: ${PR_COUNT} 个 +- **Issue 活动**: ${ISSUE_COUNT} 条(新增/关闭) +- **Commits**: ${COMMIT_COUNT} 条 + +## 📝 Issue 活动 + +### 🐛 缺陷 / Bug +${BUG_SECTION} + +### ✨ 新功能 / Feature +${FEAT_SECTION} + +### 📖 文档 +${DOC_SECTION} + +### 💡 其他 +${OTHER_SECTION} + +## 🔀 已合并 PR +${PR_ITEMS} + +## 🙏 贡献者 +${ALL_CONTRIBUTORS} + +--- +**完整变更日志**: https://www.gitlink.org.cn/${OWNER}/${REPO}/compare/${PREV_TAG}...${NEW_VERSION} + +*Auto-generated by gitlink-cli community-ops workflow (gitlink-changelog skill)*" + +echo "" +echo "$RELEASE_BODY" +echo "" + +# ================================================================ +# Phase 4: 发布 Release +# ================================================================ +log_title "Phase 4: Publish Release" + +if [[ "$DRY_RUN" == "true" ]]; then + log_warn "[DRY RUN] Would create release: $NEW_VERSION" +else + log_step "Creating release $NEW_VERSION..." + RELEASE_RESULT=$(gl_run release +create \ + --owner "$OWNER" --repo "$REPO" \ + --tag "$NEW_VERSION" \ + --name "Release $NEW_VERSION" \ + --body "$RELEASE_BODY" 2>&1) || true + + if echo "$RELEASE_RESULT" | jq -e '.ok == true' &>/dev/null; then + log_ok "Release $NEW_VERSION published!" + log_info "View: https://www.gitlink.org.cn/$OWNER/$REPO/releases" + else + log_warn "Release may have failed (tag might exist):" + echo "$RELEASE_RESULT" | jq -r '.error.message // "unknown"' 2>/dev/null || true + fi +fi + +# ================================================================ +# Phase 5: 发布社区周报到 Wiki +# ================================================================ +log_title "Phase 5: Publish Weekly Report" + +WIKI_TITLE="社区周报" +WIKI_BODY="# 社区周报 - ${OWNER}/${REPO} + +**${PERIOD_START} ~ ${PERIOD_END}** + +--- + +## 📊 数据总览 +- 已合并 PR: **${PR_COUNT}** 个 +- Issue 活动: **${ISSUE_COUNT}** 条 +- 活跃贡献者: $(echo "$ALL_CONTRIBUTORS" | tr '\n' ' ') + +## 🔀 合并的 PR +${PR_ITEMS} + +## 📝 Issue 动态 +${BUG_SECTION} +${FEAT_SECTION} +${DOC_SECTION} +${OTHER_SECTION} + +--- + +*Auto-generated by gitlink-cli | ${PERIOD_START} ~ ${PERIOD_END} | Next report in ~${PERIOD_HOURS}h*" + +if [[ "$DRY_RUN" == "true" ]]; then + log_warn "[DRY RUN] Would publish Wiki" +else + log_step "Publishing to Wiki..." + # 先尝试覆盖更新,页面不存在则新建 + WIKI_RESULT=$(gl_run wiki +update --owner "$OWNER" --repo "$REPO" \ + --title "$WIKI_TITLE" --cover "$WIKI_BODY" 2>&1) || true + if echo "$WIKI_RESULT" | jq -e '.ok == true' &>/dev/null; then + log_ok "Weekly report updated on Wiki" + else + # 页面可能还不存在,新建 + log_info "Page not found, creating new..." + WIKI_RESULT=$(gl_run wiki +create --owner "$OWNER" --repo "$REPO" \ + --title "$WIKI_TITLE" --content "$WIKI_BODY" 2>&1) || true + if echo "$WIKI_RESULT" | jq -e '.ok == true' &>/dev/null; then + log_ok "Weekly report created on Wiki" + else + log_warn "Wiki publish failed" + fi + fi +fi + +# ================================================================ +log_title "Complete" +echo -e "${GREEN} Release: $NEW_VERSION${NC}" +echo -e "${GREEN} PRs merged: $PR_COUNT${NC}" +echo -e "${GREEN} Issues: $ISSUE_COUNT${NC}" +echo -e "${GREEN} Contributors: $(echo "$ALL_CONTRIBUTORS" | wc -w)${NC}" +echo -e "${GREEN} Wiki: ${WIKI_TITLE}${NC}" diff --git a/workflows/01a-issue-triage.ps1 b/workflows/01a-issue-triage.ps1 new file mode 100644 index 0000000..b93c285 --- /dev/null +++ b/workflows/01a-issue-triage.ps1 @@ -0,0 +1,206 @@ +# ---------------------------------------------------------------- +# Scenario 1a: Real-Time Issue Triage (Webhook触发的单条Issue分类) +# Flow: Webhook触发 → 拉取Issue详情 → 动态收集标签ID → 分类 → 打tags → 分配 +# +# 10 个标准中文分类: 缺陷/功能/文档/任务/测试/支持/重复/疑问/协助/搁置 +# ---------------------------------------------------------------- +#Requires -Version 5.1 + +param( + [string]$Owner = "", + [string]$Repo = "", + [string]$IssueNumber = "", + [string]$Assignee = "", + [switch]$DryRun, + [switch]$Help +) + +$ErrorActionPreference = "Stop" +Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue + +if ($Help) { + Write-Host "Usage: powershell 01a-issue-triage.ps1 -IssueNumber N [-Owner OWNER] [-Repo REPO] [-Assignee USER] [-DryRun]" + exit 0 +} + +if (-not $IssueNumber) { Log-Err "IssueNumber is required (use -Help for usage)"; exit 1 } + +Check-Auth +$r = Resolve-OwnerRepo $Owner $Repo +$Owner = $r.Owner; $Repo = $r.Repo + +# 10 个标准中文分类标签 +$STANDARD_LABELS = @("缺陷","功能","文档","任务","测试","支持","重复","疑问","协助","搁置") + +# ================================================================ +Log-Title "Issue Triage: #$IssueNumber ($Owner/$Repo)" + +# -- Phase 1: 拉取Issue详情 -- +Log-Step "Fetching issue #$IssueNumber details..." +$issueJson = Invoke-GLCheck issue,+view,--owner,$Owner,--repo,$Repo,--number,$IssueNumber +if (-not $issueJson) { Log-Err "Failed to fetch issue #$IssueNumber"; exit 1 } + +$issueData = $issueJson.data +$issueId = if ($issueData.id) { $issueData.id } else { $IssueNumber } +$issueTitle = if ($issueData.subject) { $issueData.subject } elseif ($issueData.title) { $issueData.title } else { "N/A" } +$issueDesc = if ($issueData.description) { $issueData.description } else { "" } +$issueAuthor = if ($issueData.author.login) { $issueData.author.login } elseif ($issueData.author.username) { $issueData.author.username } else { "" } + +$existingTags = @() +if ($issueData.tags) { $existingTags = @($issueData.tags | ForEach-Object { $_.name }) } +if ($existingTags.Count -gt 0) { Log-Info "Already tagged: $($existingTags -join ', ')" } + +Log-Ok "Issue `"$issueTitle`" by @$issueAuthor" + +# -- Phase 2: 动态收集标签 ID 映射 -- +Log-Step "Discovering label IDs..." +$tagIdMap = @{} + +# 优先从项目 issue_tags API 获取 +$tagsApi = Invoke-GL api,GET,"/v1/$Owner/$Repo/issue_tags" +if ($tagsApi) { + try { + $tData = ($tagsApi | ConvertFrom-Json).data + $tagList = if ($tData.issue_tags) { @($tData.issue_tags) } elseif ($tData -is [array]) { @($tData) } else { @() } + foreach ($t in $tagList) { + if ($t.id -and $t.name) { $tagIdMap[$t.name] = $t.id } + } + } catch {} +} + +# 补充:从已有 issue 的 tags 字段收集 +foreach ($state in @("open","closed")) { + $sample = Invoke-GL issue,+list,--owner,$Owner,--repo,$Repo,--state,$state,--limit,50 + if ($sample) { + try { + $sData = ($sample | ConvertFrom-Json).data + $issues = if ($sData.issues) { @($sData.issues) } elseif ($sData -is [array]) { @($sData) } else { @() } + foreach ($iss in $issues) { + if ($iss.tags) { + foreach ($t in $iss.tags) { + if ($t.id -and $t.name) { $tagIdMap[$t.name] = $t.id } + } + } + } + } catch {} + } +} + +$found = @(); $missing = @() +foreach ($lbl in $STANDARD_LABELS) { + if ($tagIdMap.ContainsKey($lbl)) { $found += "$lbl($($tagIdMap[$lbl]))" } else { $missing += $lbl } +} +Log-Ok "Found tags: $($found -join ' ')" +if ($missing.Count -gt 0) { Log-Warn "Not in repo yet: $($missing -join ' ')" } + +# -- Phase 3: 关键词分类 (标题+描述) -- +Log-Step "Analyzing issue content..." + +$combined = "$issueTitle $issueDesc".ToLower() +$chosenLabel = "" + +if ($combined -match '(?i)bug|error|crash|fault|fix|缺陷|错误|异常|崩溃|修复|故障') { + $chosenLabel = "缺陷" +} elseif ($combined -match '(?i)feature|enhancement|add|新增|建议|功能|特性|新功能') { + $chosenLabel = "功能" +} elseif ($combined -match '(?i)doc|readme|guide|wiki|文档|说明|教程') { + $chosenLabel = "文档" +} elseif ($combined -match '(?i)test|测试|用例|覆盖') { + $chosenLabel = "测试" +} elseif ($combined -match '(?i)duplicate|重复|重复的|和.*重复') { + $chosenLabel = "重复" +} elseif ($combined -match '(?i)question|疑问|不确定|讨论|澄清') { + $chosenLabel = "疑问" +} elseif ($combined -match '(?i)help|协助|帮助|协作|请求帮助') { + $chosenLabel = "协助" +} elseif ($combined -match '(?i)postpone|wontfix|暂缓|搁置|低优|不重要|不紧急') { + $chosenLabel = "搁置" +} elseif ($combined -match '(?i)support|兼容|环境|依赖|支持') { + $chosenLabel = "支持" +} elseif ($combined -match '(?i)task|todo|任务|待办|计划') { + $chosenLabel = "任务" +} + +if ($chosenLabel) { + Log-Ok "Classified: $chosenLabel" +} else { + Log-Info "No matching label — skipping" +} + +# -- Phase 4: 打标签 (raw API PATCH tags) -- +if (-not $chosenLabel) { + Log-Info "No label assigned" +} elseif ($DryRun) { + Log-Warn "[DRY RUN] Would tag #$IssueNumber with '$chosenLabel'" +} else { + $tgtId = $tagIdMap[$chosenLabel] + if (-not $tgtId) { + Log-Warn "Label '$chosenLabel' not found in repo tags — create it on website first" + Log-Info " https://www.gitlink.org.cn/$Owner/$Repo/settings/labels" + } else { + Log-Step "Tagging with '$chosenLabel' (ID:$tgtId)..." + + # GitLink v1 API 使用 issue_tag_ids (整数ID数组),不是 tags (对象数组) + $bodyJson = "{`"issue_tag_ids`":[$tgtId]}" + + $tagResult = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$issueId",--body,$bodyJson + if ($tagResult) { + try { + $tagOk = (($tagResult | ConvertFrom-Json).ok -eq $true) + } catch { $tagOk = $false } + if ($tagOk) { Log-Ok "Tagged: $chosenLabel" } else { Log-Warn "Tag failed" } + } else { Log-Warn "Tag failed" } + } +} + +# -- Phase 5: 分配责任人 -- +$targetAssignee = if ($Assignee) { $Assignee } else { $issueAuthor } + +Log-Step "Assigning..." +if (-not $targetAssignee) { + Log-Warn "No assignee, skipping" +} elseif ($DryRun) { + Log-Warn "[DRY RUN] Would assign @$targetAssignee" +} else { + # assigned_to_id 需要数字用户 ID,不是登录名 + # 先尝试把输入当纯数字;否则通过 /users/{login} 查询 + $assigneeID = $null + $isNumeric = $false + try { [void][int]$targetAssignee; $isNumeric = $true } catch {} + if ($isNumeric) { + $assigneeID = $targetAssignee + } else { + Log-Info "Resolving user ID for '$targetAssignee'..." + $userJson = Invoke-GL api,GET,"/users/$targetAssignee" + if ($userJson) { + try { + $userData = ($userJson | ConvertFrom-Json).data + if ($userData.id) { + $assigneeID = [int]$userData.id + } elseif ($userData.user_id) { + $assigneeID = [int]$userData.user_id + } + } catch {} + } + } + + if (-not $assigneeID) { + Log-Warn "Cannot resolve user ID for '$targetAssignee' — skipping assign" + } else { + $escTitle = $issueTitle -replace '"','\"' + $escDesc = $issueDesc -replace '"','\"' + $bodyJson = "{`"subject`":`"$escTitle`",`"description`":`"$escDesc`",`"assigner_ids`":[$assigneeID]}" + $result = Invoke-GL api,PATCH,"/v1/$Owner/$Repo/issues/$issueId",--body,$bodyJson + if ($result -and ((($result | ConvertFrom-Json).ok -eq $true))) { + Log-Ok "Assigned: @$targetAssignee (ID:$assigneeID)" + } else { + Log-Warn "Assign failed" + } + } +} + +Log-Title "Triage Complete" +Write-Host " Issue: #$IssueNumber - $issueTitle" -ForegroundColor Green +Write-Host " Author: @$issueAuthor" -ForegroundColor Green +Write-Host " Label: $(if($chosenLabel){$chosenLabel}else{'无'})" -ForegroundColor Green +Write-Host " Assignee: @$targetAssignee" -ForegroundColor Green diff --git a/workflows/01a-issue-triage.sh b/workflows/01a-issue-triage.sh new file mode 100644 index 0000000..2381e06 --- /dev/null +++ b/workflows/01a-issue-triage.sh @@ -0,0 +1,198 @@ +#!/usr/bin/env bash +# ================================================================ +# Scenario 1a: Real-Time Issue Triage (Linux Webhook 版) +# Flow: 接收 Issue 编号 → 拉取详情 → 分类 → 打 tags → 分配人 +# ================================================================ +set -eo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" + +ISSUE_NUMBER="" +OWNER="" +REPO="" +ASSIGNEE="" +DRY_RUN=false + +usage() { + echo "Usage: $0 --issue-number N [--owner OWNER] [--repo REPO] [--assignee USER] [--dry-run]" + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --issue-number) ISSUE_NUMBER="$2"; shift 2 ;; + --owner) OWNER="$2"; shift 2 ;; + --repo) REPO="$2"; shift 2 ;; + --assignee) ASSIGNEE="$2"; shift 2 ;; + --dry-run) DRY_RUN="true"; shift ;; + --help|-h) usage ;; + *) log_err "Unknown arg: $1"; usage ;; + esac +done + +[[ -z "$ISSUE_NUMBER" ]] && { log_err "--issue-number is required"; usage; } + +check_auth +require_owner_repo + +# 10 个标准中文分类标签(必须和仓库实际标签名一致) +STANDARD_LABELS="缺陷 功能 文档 任务 测试 支持 重复 疑问 协助 搁置" + +# ================================================================ +log_title "Issue Triage: #$ISSUE_NUMBER ($OWNER/$REPO)" + +# ── Phase 1: 拉取 Issue 详情 ───────────────────────────────────── +log_step "Fetching issue #$ISSUE_NUMBER details..." +ISSUE_JSON=$(gl_check issue +view --owner "$OWNER" --repo "$REPO" --number "$ISSUE_NUMBER") +[[ -z "$ISSUE_JSON" ]] && { log_err "Failed to fetch issue #$ISSUE_NUMBER"; exit 1; } + +ISSUE_TITLE=$(echo "$ISSUE_JSON" | jq -r '.data.subject // .data.title // "N/A"') +ISSUE_DESC=$(echo "$ISSUE_JSON" | jq -r '.data.description // ""' | head -c 2000) +ISSUE_AUTHOR=$(echo "$ISSUE_JSON" | jq -r '.data.author.login // .data.author.username // ""') +ISSUE_STATE=$(echo "$ISSUE_JSON" | jq -r '.data.status.name // .data.state.name // .data.state // "N/A"') + +EXISTING_TAGS=$(echo "$ISSUE_JSON" | jq -r '[.data.tags[]?.name // ""] | join(", ")' 2>/dev/null || echo "") +[[ -n "$EXISTING_TAGS" ]] && log_info "Already tagged: $EXISTING_TAGS" + +log_ok "Issue \"$ISSUE_TITLE\" by @$ISSUE_AUTHOR" + +# ── Phase 2: 动态收集标签 ID 映射 ───────────────────────────────── +log_step "Discovering label IDs..." + +ID_MAP_FILE=$(mktemp) + +# 优先从项目 issue_tags API 获取 +TAGS_JSON=$(gl_run api GET "/v1/$OWNER/$REPO/issue_tags" 2>/dev/null || true) +if [[ -n "$TAGS_JSON" ]]; then + echo "$TAGS_JSON" | jq -r '(.data.issue_tags // .data // [])[]? | "\(.id)|\(.name)"' 2>/dev/null >> "$ID_MAP_FILE" || true +fi + +# 补充:从已有 issue 的 tags 字段收集(覆盖 issue_tags API 未返回的情况) +for state in open closed; do + SAMPLE=$(gl_run issue +list --owner "$OWNER" --repo "$REPO" --state "$state" --limit 50 2>/dev/null || true) + echo "$SAMPLE" | jq -r '(.data.issues // .data // [])[]?.tags[]? | "\(.id)|\(.name)"' 2>/dev/null >> "$ID_MAP_FILE" || true +done + +declare -A TAG_ID +while IFS='|' read -r tid tname || [[ -n "$tid" ]]; do + [[ -n "$tid" && -n "$tname" ]] && TAG_ID["$tname"]="$tid" +done < "$ID_MAP_FILE" +rm -f "$ID_MAP_FILE" + +FOUND=""; MISSING="" +for lbl in $STANDARD_LABELS; do + if [[ -n "${TAG_ID[$lbl]:-}" ]]; then + FOUND="$FOUND $lbl(${TAG_ID[$lbl]})" + else + MISSING="$MISSING $lbl" + fi +done +log_ok "Found tags:${FOUND:- (none)}" +[[ -n "$MISSING" ]] && log_warn "Not in repo yet:${MISSING}" + +# ── Phase 3: 分类(关键词 → 中文标签)───────────────────────────── +log_step "Analyzing issue content..." + +COMBINED=$(echo "$ISSUE_TITLE $ISSUE_DESC" | tr '[:upper:]' '[:lower:]') +CHOSEN_LABEL="" +CONFIDENCE="" + +# 优先匹配具体意图,再匹配通用 +if echo "$COMBINED" | grep -qiE 'bug|error|crash|fault|fix|错误|异常|崩溃|缺陷|修复|故障'; then + CHOSEN_LABEL="缺陷" +elif echo "$COMBINED" | grep -qiE 'feature|enhancement|add|新增|建议|功能|特性|新功能'; then + CHOSEN_LABEL="功能" +elif echo "$COMBINED" | grep -qiE 'doc|readme|guide|wiki|文档|说明|教程'; then + CHOSEN_LABEL="文档" +elif echo "$COMBINED" | grep -qiE 'test|测试|用例|覆盖'; then + CHOSEN_LABEL="测试" +elif echo "$COMBINED" | grep -qiE 'duplicate|重复|重复的|和.*重复'; then + CHOSEN_LABEL="重复" +elif echo "$COMBINED" | grep -qiE 'question|疑问|不确定|讨论|澄清'; then + CHOSEN_LABEL="疑问" +elif echo "$COMBINED" | grep -qiE 'help|协助|帮助|协作|请求帮助'; then + CHOSEN_LABEL="协助" +elif echo "$COMBINED" | grep -qiE 'postpone|wontfix|暂缓|搁置|低优|不重要|不紧急'; then + CHOSEN_LABEL="搁置" +elif echo "$COMBINED" | grep -qiE 'support|兼容|环境|依赖|支持'; then + CHOSEN_LABEL="支持" +elif echo "$COMBINED" | grep -qiE 'task|todo|任务|待办|计划'; then + CHOSEN_LABEL="任务" +elif echo "$COMBINED" | grep -qiE 'how|怎么|如何|求助|使用|用法'; then + CHOSEN_LABEL="疑问" +fi + +if [[ -n "$CHOSEN_LABEL" ]]; then + CONFIDENCE="keyword" + log_ok "Classified: $CHOSEN_LABEL" +else + log_info "No matching label — skipping" +fi + +# ── Phase 4: 打标签(raw API PATCH tags)───────────────────────── +if [[ -z "$CHOSEN_LABEL" ]]; then + log_info "No label assigned" +elif [[ "$DRY_RUN" == "true" ]]; then + log_warn "[DRY RUN] Would tag #$ISSUE_NUMBER with '$CHOSEN_LABEL'" +else + TGT_ID="${TAG_ID[$CHOSEN_LABEL]:-}" + if [[ -z "$TGT_ID" ]]; then + log_warn "Label '$CHOSEN_LABEL' not found in repo tags — create it on website first" + log_info " https://www.gitlink.org.cn/$OWNER/$REPO/settings/labels" + else + log_step "Tagging with '$CHOSEN_LABEL' (ID:$TGT_ID)..." + + + TAG_RESP=$(gl_run api PATCH "/v1/$OWNER/$REPO/issues/$ISSUE_NUMBER" \ + --body "{\"issue_tag_ids\":[$TGT_ID]}" 2>&1) || true + + if echo "$TAG_RESP" | jq -e '.ok == true' &>/dev/null; then + log_ok "Tagged: $CHOSEN_LABEL" + else + ERR=$(echo "$TAG_RESP" | jq -r '.error.message // "unknown"' 2>/dev/null || echo "unknown") + log_warn "Tag failed: $ERR" + fi + fi +fi + +# ── Phase 5: 分配责任人 ────────────────────────────────────────── +TARGET_ASSIGNEE="${ASSIGNEE:-$ISSUE_AUTHOR}" + +log_step "Assigning..." +if [[ -z "$TARGET_ASSIGNEE" ]]; then + log_warn "No assignee, skipping" +elif [[ "$DRY_RUN" == "true" ]]; then + log_warn "[DRY RUN] Would assign @$TARGET_ASSIGNEE" +else + # Resolve login name to numeric user ID + ASSIGNEE_ID="" + if [[ "$TARGET_ASSIGNEE" =~ ^[0-9]+$ ]]; then + ASSIGNEE_ID="$TARGET_ASSIGNEE" + else + USER_JSON=$(gl_run api GET "/users/$TARGET_ASSIGNEE" 2>/dev/null || true) + if [[ -n "$USER_JSON" ]]; then + ASSIGNEE_ID=$(echo "$USER_JSON" | jq -r '.data.id // .data.user_id // ""' 2>/dev/null || echo "") + fi + fi + + if [[ -z "$ASSIGNEE_ID" ]]; then + log_warn "Cannot resolve user ID for '$TARGET_ASSIGNEE' — skipping assign" + else + BODY="{\"assigner_ids\":[$ASSIGNEE_ID]}" + ARESP=$(gl_run api PATCH "/v1/$OWNER/$REPO/issues/$ISSUE_NUMBER" --body "$BODY" 2>&1) || true + if echo "$ARESP" | jq -e '.ok == true' &>/dev/null; then + log_ok "Assigned: @$TARGET_ASSIGNEE (ID:$ASSIGNEE_ID)" + else + ERR2=$(echo "$ARESP" | jq -r '.error.message // "unknown"' 2>/dev/null || echo "unknown") + log_warn "Assign failed: $ERR2" + fi + fi +fi + +# ── Complete ────────────────────────────────────────────────────── +log_title "Triage Complete" +echo -e "${GREEN} Issue: #$ISSUE_NUMBER - $ISSUE_TITLE${NC}" +echo -e "${GREEN} Author: @$ISSUE_AUTHOR${NC}" +echo -e "${GREEN} Label: ${CHOSEN_LABEL:-无}${NC}" +echo -e "${GREEN} Assignee: @$TARGET_ASSIGNEE${NC}" diff --git a/workflows/01a-webhook-listener.ps1 b/workflows/01a-webhook-listener.ps1 new file mode 100644 index 0000000..d138cec --- /dev/null +++ b/workflows/01a-webhook-listener.ps1 @@ -0,0 +1,410 @@ +# ---------------------------------------------------------------- +# Scenario 1a: Webhook HTTP Listener (实时链路接收器) +# Role: HTTP server listening for GitLink webhook events (Issue created) +# → Parse payload → Call 01a-issue-triage.ps1 to classify & assign +# +# 这是社区运营自动化"实时链路"的入口,负责接收 GitLink 平台推送的 +# Webhook 事件,解析 Issue 编号,然后调用分类脚本。 +# +# 架构: +# GitLink 平台 (Issue 创建) +# → POST https://:/webhook +# → 01a-webhook-listener.ps1 (本脚本,HTTP 服务器) +# → 01a-issue-triage.ps1 (分类+打标签+分配) +# +# 部署方式: +# A. 本地 + ngrok 内网穿透: +# 1. 启动本脚本: powershell 01a-webhook-listener.ps1 +# 2. 启动 ngrok: ngrok http 8080 +# 3. 运行注册: powershell 01a-webhook-setup.ps1 -WebhookUrl "https://xxx.ngrok.io/webhook" +# +# B. 部署到公网服务器: +# 1. 上传脚本到服务器 +# 2. 启动本脚本: powershell 01a-webhook-listener.ps1 -Port 443 -Ssl +# 3. 运行注册: powershell 01a-webhook-setup.ps1 -WebhookUrl "https://your-server.com/webhook" +# +# C. 仅手动触发 (无需 webhook): +# powershell 01a-issue-triage.ps1 -IssueNumber 42 +# ---------------------------------------------------------------- +#Requires -Version 5.1 +#Requires -RunAsAdministrator + +param( + [int]$Port = 8080, + [string]$Secret = "", + [string]$HostPrefix = "+", + [switch]$Ssl, + [switch]$Help +) + +$ErrorActionPreference = "Stop" +$ScriptDir = $PSScriptRoot + +if ($Help) { + Write-Host "Usage: powershell 01a-webhook-listener.ps1 [-Port PORT] [-Secret SECRET] [-HostPrefix +] [-Ssl]" + Write-Host "" + Write-Host " Webhook HTTP 接收器 — 监听 GitLink 平台的 Issue 事件,自动触发分类。" + Write-Host "" + Write-Host " -Port PORT 监听端口 (默认: 8080)" + Write-Host " -Secret SECRET HMAC 密钥,用于验证 GitLink 请求来源(需与注册时一致)" + Write-Host " -HostPrefix PREFIX 监听主机前缀 (默认: + 表示所有IP,也可用 localhost)" + Write-Host " -Ssl 启用 HTTPS (需要已导入的 SSL 证书)" + Write-Host "" + Write-Host " 部署前准备:" + Write-Host " 如需公网访问,请使用 ngrok 或部署到有公网IP的服务器:" + Write-Host " ngrok http $Port" + Write-Host " 然后运行 01a-webhook-setup.ps1 在 GitLink 平台注册 webhook" + exit 0 +} + +# ================================================================ +# Color Helpers (no dependency on common.psm1 since this is a server) +# ================================================================ +function Log-Step { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [STEP] $Msg" -ForegroundColor Blue } +function Log-Ok { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [ OK] $Msg" -ForegroundColor Green } +function Log-Warn { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [WARN] $Msg" -ForegroundColor Yellow } +function Log-Err { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [ ERR] $Msg" -ForegroundColor Red } +function Log-Info { param([string]$Msg) Write-Host "[$(Get-Date -Format 'HH:mm:ss')] [INFO] $Msg" -ForegroundColor Cyan } + +# ================================================================ +# HMAC Signature Verification +# ================================================================ +function Test-WebhookSignature { + param( + [string]$RequestBody, + [string]$SignatureHeader, + [string]$Secret + ) + if (-not $Secret) { return $true } # No secret configured, skip verification + + if (-not $SignatureHeader) { + Log-Warn "No signature header in request (expected X-GitLink-Signature or X-Hub-Signature-256)" + return $false + } + + try { + $hmac = New-Object System.Security.Cryptography.HMACSHA256 + $hmac.Key = [System.Text.Encoding]::UTF8.GetBytes($Secret) + $hash = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($RequestBody)) + $computed = "sha256=" + [System.BitConverter]::ToString($hash).Replace("-", "").ToLower() + + # Support both X-GitLink-Signature and X-Hub-Signature-256 (GitHub-compatible) + # Strip prefix if present (e.g., "sha256=abc123..." → "abc123...") + $received = $SignatureHeader + if ($received -match '^sha256=') { + $received = $received + } else { + $received = "sha256=$received" + } + + return $computed -eq $received + } catch { + Log-Err "HMAC verification error: $_" + return $false + } +} + +# ================================================================ +# Extract Issue Number from Webhook Payload +# ================================================================ +function Get-IssueNumberFromPayload { + param([string]$Body, [string]$EventType) + + try { + $payload = $Body | ConvertFrom-Json + + # Try multiple known payload structures + # GitLink format: { action: "opened", issue: { id: ..., number: ..., project_issues_index: ... } } + if ($payload.issue) { + $num = $payload.issue.project_issues_index + if (-not $num) { $num = $payload.issue.number } + if (-not $num) { $num = $payload.issue.id } + if ($num) { + Log-Info "Extracted issue number: #$num (event: $EventType)" + return $num.ToString() + } + } + + # GitHub-compatible format: { action: "opened", issue: { number: ... } } + if ($payload.issue -and $payload.issue.number) { + Log-Info "Extracted issue number (GitHub format): #$($payload.issue.number)" + return $payload.issue.number.ToString() + } + + # Direct format: { number: ..., id: ... } + if ($payload.number) { return $payload.number.ToString() } + if ($payload.id) { + Log-Info "Extracted issue id: $($payload.id)" + return $payload.id.ToString() + } + + Log-Warn "Could not extract issue number from payload" + Log-Info "Payload keys: $($payload.PSObject.Properties.Name -join ', ')" + if ($payload.issue) { + Log-Info "Issue keys: $($payload.issue.PSObject.Properties.Name -join ', ')" + } + return $null + } catch { + Log-Err "Failed to parse webhook payload: $_" + Log-Info "Raw body (first 500 chars): $($Body.Substring(0, [Math]::Min(500, $Body.Length)))" + return $null + } +} + +# ================================================================ +# Extract Owner/Repo from payload or git remote +# ================================================================ +function Get-RepoInfoFromPayload { + param([string]$Body) + + try { + $payload = $Body | ConvertFrom-Json + $owner = $null + $repo = $null + + # GitLink format + if ($payload.repository) { + if ($payload.repository.owner) { + $owner = if ($payload.repository.owner.login) { $payload.repository.owner.login } + elseif ($payload.repository.owner.username) { $payload.repository.owner.username } + else { $payload.repository.owner } + } + if ($payload.repository.name) { $repo = $payload.repository.name } + } + + # GitHub-compatible format + if ((-not $owner) -and $payload.repository -and $payload.repository.full_name) { + $parts = $payload.repository.full_name -split '/' + $owner = $parts[0] + $repo = $parts[1] + } + + return @{ Owner = $owner; Repo = $repo } + } catch { + return @{ Owner = $null; Repo = $null } + } +} + +# ================================================================ +# Process Incoming Webhook +# ================================================================ +function Invoke-WebhookHandler { + param( + [string]$Body, + [string]$EventType, + [string]$EventHeader, + [string]$SignatureHeader + ) + + # Validate secret if configured + if ($Secret -and -not (Test-WebhookSignature -RequestBody $Body -SignatureHeader $SignatureHeader -Secret $Secret)) { + Log-Err "HMAC signature verification FAILED — request rejected" + return @{ StatusCode = 403; Body = '{"error":"Invalid signature"}' } + } + + # Only process issue events + if ($EventType -notmatch '^issue' -and $EventHeader -notmatch 'issue') { + Log-Info "Ignoring non-issue event: $EventType" + return @{ StatusCode = 200; Body = '{"status":"ignored","reason":"non-issue event"}' } + } + + # Only process "opened" action (new issue created) + try { + $payload = $Body | ConvertFrom-Json + if ($payload.action -and $payload.action -ne 'opened') { + Log-Info "Ignoring issue event with action: $($payload.action)" + return @{ StatusCode = 200; Body = '{"status":"ignored","reason":"action is not opened"}' } + } + } catch { } + + # Extract issue number + $issueNumber = Get-IssueNumberFromPayload -Body $Body -EventType $EventType + if (-not $issueNumber) { + Log-Err "Cannot extract issue number — skipping triage" + return @{ StatusCode = 400; Body = '{"error":"Cannot extract issue number from payload"}' } + } + + Log-Ok "=== New Issue #$issueNumber — dispatching to triage ===" + + # Extract owner/repo to pass to triage script + $repoInfo = Get-RepoInfoFromPayload -Body $Body + + # Dispatch triage script asynchronously so we can respond to webhook quickly + $jobScript = { + param($ScriptDir, $IssueNum, $Owner, $Repo, $Body) + $argList = @("-File", "$ScriptDir\01a-issue-triage.ps1", "-IssueNumber", $IssueNum) + if ($Owner) { $argList += @("-Owner", $Owner) } + if ($Repo) { $argList += @("-Repo", $Repo) } + $result = & powershell.exe -NoProfile -ExecutionPolicy Bypass @argList 2>&1 + $result | Out-File "$ScriptDir\webhook-triage-$IssueNum-$(Get-Date -Format 'yyyyMMdd-HHmmss').log" -Encoding UTF8 + } + + Start-Job -ScriptBlock $jobScript -ArgumentList $ScriptDir, $issueNumber, $repoInfo.Owner, $repoInfo.Repo, $Body | Out-Null + Log-Ok "Triage job started for #$issueNumber (running in background)" + + return @{ StatusCode = 200; Body = '{"status":"accepted","issue_number":' + $issueNumber + '}' } +} + +# ================================================================ +# Main: Start HTTP Listener +# ================================================================ +Clear-Host +Write-Host "" +Write-Host "╔══════════════════════════════════════════════════════════════╗" -ForegroundColor Cyan +Write-Host "║ GitLink Community Ops — Webhook Listener ║" -ForegroundColor Cyan +Write-Host "║ 实时链路接收器: Issue 创建 → 自动分类 → 分配责任人 ║" -ForegroundColor Cyan +Write-Host "╚══════════════════════════════════════════════════════════════╝" -ForegroundColor Cyan +Write-Host "" + +$protocol = if ($Ssl) { "https" } else { "http" } +$listenUrl = "$($protocol)://$($HostPrefix):$Port/" + +Log-Info "Starting listener on: $listenUrl" +Log-Info "Triage script: $ScriptDir\01a-issue-triage.ps1" +if ($Secret) { + Log-Info "HMAC verification: ENABLED" +} else { + Log-Warn "HMAC verification: DISABLED (set -Secret to enable)" +} + +# Try to register URL ACL if not running as admin for non-localhost +if ($HostPrefix -ne "localhost" -and $HostPrefix -ne "127.0.0.1") { + Write-Host "" + Log-Warn "Listening on $HostPrefix requires URL ACL registration." + Log-Info "If you get 'Access Denied', run as Administrator OR use -HostPrefix localhost" +} + +# Create HttpListener +$listener = $null +try { + $listener = New-Object System.Net.HttpListener + $listener.Prefixes.Add($listenUrl + "webhook/") + $listener.Prefixes.Add($listenUrl) # Also listen on root path + $listener.Start() + Log-Ok "HTTP listener started successfully" +} catch { + Log-Err "Failed to start HTTP listener: $_" + Write-Host "" + Write-Host "Troubleshooting:" -ForegroundColor Yellow + Write-Host " 1. Run as Administrator" + Write-Host " 2. Or register URL ACL manually:" + Write-Host " netsh http add urlacl url=$listenUrl user=Everyone" + Write-Host " 3. Or use localhost only: -HostPrefix localhost" + Write-Host " 4. Check if port $Port is already in use: netstat -ano | findstr $Port" + exit 1 +} + +Write-Host "" +Log-Ok "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +Log-Ok " Listening on: $listenUrl" +Log-Ok " Webhook URL: ${listenUrl}webhook" +Log-Ok " Health check: ${listenUrl}" +Log-Ok "" +Log-Ok " 按 Ctrl+C 停止服务" +Log-Ok "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" +Write-Host "" + +# Handle Ctrl+C gracefully +$keepRunning = $true +$null = Register-EngineEvent -SourceIdentifier "WebhookListenerStop" -Forward -SupportEvent +try { + [Console]::TreatControlCAsInput = $false +} catch { } + +# Main event loop +while ($keepRunning) { + try { + $context = $listener.GetContext() + $request = $context.Request + $response = $context.Response + + $requestMethod = $request.HttpMethod + $requestUrl = $request.Url.ToString() + $remoteIp = $request.RemoteEndPoint.Address.ToString() + + Log-Step "$requestMethod $requestUrl (from $remoteIp)" + + if ($requestMethod -eq "GET" -and ($requestUrl -notmatch '/webhook$')) { + # Health check / root page + $html = @" + +GitLink Webhook Listener + + +

    GitLink Community Ops — Webhook Listener

    +

    ✓ Running

    +

    Listening for issue events at ${listenUrl}webhook

    +

    When a new Issue is created, this server will:

    +
      +
    1. Receive the webhook payload from GitLink
    2. +
    3. Validate HMAC signature (if secret is configured)
    4. +
    5. Extract the issue number
    6. +
    7. Call 01a-issue-triage.ps1 to AI-classify and assign
    8. +
    +

    Started: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') | Port: $Port | HMAC: $(if($Secret){'enabled'}else{'disabled'})

    + +"@ + $buffer = [System.Text.Encoding]::UTF8.GetBytes($html) + $response.ContentType = "text/html; charset=utf-8" + $response.ContentLength64 = $buffer.Length + $response.OutputStream.Write($buffer, 0, $buffer.Length) + $response.OutputStream.Close() + Log-Ok "Health check OK" + continue + } + + # Read request body + $reader = New-Object System.IO.StreamReader($request.InputStream, $request.ContentEncoding) + $body = $reader.ReadToEnd() + $reader.Close() + + # Get event headers + $eventType = $request.Headers.Get("X-GitLink-Event") + if (-not $eventType) { + $eventType = $request.Headers.Get("X-GitHub-Event") # GitHub-compatible + } + if (-not $eventType) { + $eventType = $request.Headers.Get("X-Event-Type") + } + + $signatureHeader = $request.Headers.Get("X-GitLink-Signature") + if (-not $signatureHeader) { + $signatureHeader = $request.Headers.Get("X-Hub-Signature-256") # GitHub-compatible + } + + Log-Info "Event: $eventType | Body length: $($body.Length) bytes" + + # Process the webhook + $result = Invoke-WebhookHandler -Body $body -EventType $eventType -EventHeader $eventType -SignatureHeader $signatureHeader + + # Send response + $response.StatusCode = $result.StatusCode + $responseBuffer = [System.Text.Encoding]::UTF8.GetBytes($result.Body) + $response.ContentType = "application/json; charset=utf-8" + $response.ContentLength64 = $responseBuffer.Length + $response.OutputStream.Write($responseBuffer, 0, $responseBuffer.Length) + $response.OutputStream.Close() + + } catch [System.Net.HttpListenerException] { + if ($_.Exception.ErrorCode -eq 995) { + # Operation aborted — likely shutting down + Log-Info "Listener shutting down..." + $keepRunning = $false + } else { + Log-Err "HTTP error: $_" + } + } catch { + Log-Err "Unexpected error: $_" + Start-Sleep -Milliseconds 100 + } +} + +# Cleanup +if ($listener -and $listener.IsListening) { + $listener.Stop() + $listener.Close() + Log-Ok "HTTP listener stopped" +} + +Log-Ok "Webhook listener exited" diff --git a/workflows/01a-webhook-listener.py b/workflows/01a-webhook-listener.py new file mode 100644 index 0000000..4db54c6 --- /dev/null +++ b/workflows/01a-webhook-listener.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +# ================================================================ +# Scenario 1a: Webhook HTTP Listener (Linux 版) +# Role: HTTP server 监听 GitLink webhook → 解析 Issue 编号 → 调用分类脚本 +# +# 部署: systemd 管理,端口 8080,无需 root(用 systemd socket activation 或 sudo) +# 依赖: Python 3.6+ (无需额外 pip 包) +# ================================================================ + +import os +import sys +import json +import hmac +import hashlib +import subprocess +import threading +from http.server import HTTPServer, BaseHTTPRequestHandler +from datetime import datetime + +# ── 配置 ──────────────────────────────────────────────────────── +PORT = int(os.environ.get("WEBHOOK_PORT", "8080")) +SECRET = os.environ.get("WEBHOOK_SECRET", "") +SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) +TRIAGE_SCRIPT = os.path.join(SCRIPT_DIR, "01a-issue-triage.sh") +GATEKEEPER_SCRIPT = os.path.join(SCRIPT_DIR, "02a-pr-gatekeeper.sh") +LOG_DIR = os.path.join(SCRIPT_DIR, "webhook-logs") + +os.makedirs(LOG_DIR, exist_ok=True) + + +def log(msg, level="INFO"): + ts = datetime.now().strftime("%H:%M:%S") + color = {"INFO": "\033[36m", "OK": "\033[32m", "WARN": "\033[33m", "ERR": "\033[31m"}.get(level, "") + reset = "\033[0m" + print(f"[{ts}] {color}[{level:>4}]{reset} {msg}", flush=True) + # Also append to log file + logfile = os.path.join(LOG_DIR, datetime.now().strftime("webhook-%Y%m%d.log")) + with open(logfile, "a", encoding="utf-8") as f: + f.write(f"[{ts}] [{level:>4}] {msg}\n") + + +def verify_signature(body: bytes, signature_header: str) -> bool: + """HMAC-SHA256 签名验证""" + if not SECRET: + return True # 未配置密钥,跳过验证 + if not signature_header: + log("No signature header in request", "WARN") + return False + + try: + expected = hmac.new(SECRET.encode(), body, hashlib.sha256).hexdigest() + # Support both "sha256=abc..." and plain "abc..." formats + received = signature_header + if received.startswith("sha256="): + received = received[7:] + return hmac.compare_digest(expected, received) + except Exception as e: + log(f"HMAC error: {e}", "ERR") + return False + + +def extract_issue_number(payload: dict) -> str: + """从 webhook payload 提取 Issue 编号""" + issue = payload.get("issue", {}) + if issue: + num = issue.get("project_issues_index") or issue.get("number") or issue.get("id") + if num: + log(f"Extracted issue number: #{num}", "OK") + return str(num) + if "number" in payload: + return str(payload["number"]) + if "id" in payload: + return str(payload["id"]) + log("Could not extract issue number from payload", "WARN") + return None + + +def extract_pr_number(payload: dict) -> str: + """从 webhook payload 提取 PR 编号""" + pr = payload.get("pull_request", {}) or payload.get("pullrequest", {}) + if pr: + num = pr.get("number") or pr.get("pull_request_number") or pr.get("id") + if num: + log(f"Extracted PR number: #{num}", "OK") + return str(num) + if "pull_request_number" in payload: + return str(payload["pull_request_number"]) + log("Could not extract PR number from payload", "WARN") + return None + + +def extract_repo_info(payload: dict): + """从 payload 提取 owner/repo""" + repo = payload.get("repository", {}) + owner = repo.get("owner", {}) + owner_name = owner.get("login") or owner.get("username") or str(owner) if isinstance(owner, dict) else str(owner) + repo_name = repo.get("name", "") + return owner_name, repo_name + + +def run_triage_async(issue_number: str, owner: str = "", repo: str = ""): + """异步调用分类脚本,另起线程避免阻塞 webhook 响应""" + def _run(): + cmd = ["bash", TRIAGE_SCRIPT, "--issue-number", issue_number] + if owner: + cmd += ["--owner", owner] + if repo: + cmd += ["--repo", repo] + + log(f"Dispatching triage: {' '.join(cmd)}", "INFO") + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=120, cwd=SCRIPT_DIR) + # Save output to log + outfile = os.path.join(LOG_DIR, f"triage-{issue_number}-{datetime.now().strftime('%Y%m%d-%H%M%S')}.log") + with open(outfile, "w", encoding="utf-8") as f: + f.write(f"=== STDOUT ===\n{result.stdout}\n=== STDERR ===\n{result.stderr}\n") + if result.returncode == 0: + log(f"Triage #{issue_number} completed successfully → {outfile}", "OK") + else: + log(f"Triage #{issue_number} failed (exit={result.returncode}) → {outfile}", "ERR") + except subprocess.TimeoutExpired: + log(f"Triage #{issue_number} TIMEOUT after 120s", "ERR") + except Exception as e: + log(f"Triage #{issue_number} error: {e}", "ERR") + + t = threading.Thread(target=_run, daemon=True) + t.start() + + +def run_gatekeeper_async(pr_number: str, owner: str = "", repo: str = ""): + """异步调用 PR gatekeeper 脚本""" + def _run(): + cmd = ["bash", GATEKEEPER_SCRIPT, "--pr-id", pr_number] + if owner: + cmd += ["--owner", owner] + if repo: + cmd += ["--repo", repo] + + log(f"Dispatching gatekeeper: {' '.join(cmd)}", "INFO") + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300, cwd=SCRIPT_DIR) + outfile = os.path.join(LOG_DIR, f"gatekeeper-{pr_number}-{datetime.now().strftime('%Y%m%d-%H%M%S')}.log") + with open(outfile, "w", encoding="utf-8") as f: + f.write(f"=== STDOUT ===\n{result.stdout}\n=== STDERR ===\n{result.stderr}\n") + if result.returncode == 0: + log(f"Gatekeeper PR #{pr_number} completed successfully -> {outfile}", "OK") + else: + log(f"Gatekeeper PR #{pr_number} failed (exit={result.returncode}) -> {outfile}", "ERR") + except subprocess.TimeoutExpired: + log(f"Gatekeeper PR #{pr_number} TIMEOUT after 300s", "ERR") + except Exception as e: + log(f"Gatekeeper PR #{pr_number} error: {e}", "ERR") + + t = threading.Thread(target=_run, daemon=True) + t.start() + + +class WebhookHandler(BaseHTTPRequestHandler): + def log_message(self, format, *args): + log(f"{self.client_address[0]} - {format % args}", "INFO") + + def do_GET(self): + """健康检查 / 根页面""" + html = f""" +GitLink Webhook Listener + + +

    GitLink Community Ops — Webhook Listener

    +

    ✓ Running

    +

    Listening for issue and pull_request events at /webhook

    +

    {datetime.now().strftime('%Y-%m-%d %H:%M:%S')} | Port: {PORT} | HMAC: {'enabled' if SECRET else 'disabled'}

    +""" + self._respond(200, html, "text/html") + + def do_POST(self): + """接收 webhook""" + content_length = int(self.headers.get("Content-Length", 0)) + body = self.rfile.read(content_length) if content_length > 0 else b"" + + event_type = self.headers.get("X-GitLink-Event") or self.headers.get("X-GitHub-Event") or "" + signature = self.headers.get("X-GitLink-Signature") or self.headers.get("X-Hub-Signature-256") or "" + + log(f"POST {self.path} | Event: {event_type} | Size: {content_length}B | From: {self.client_address[0]}") + + # HMAC 验证 + if SECRET and not verify_signature(body, signature): + self._respond(403, '{"error":"Invalid signature"}') + return + + # 解析 payload + try: + payload = json.loads(body) + except json.JSONDecodeError: + log("Failed to parse JSON payload", "ERR") + self._respond(400, '{"error":"Invalid JSON"}') + return + + action = payload.get("action", "") + is_issue = "issue" in event_type.lower() + is_pr = "pull_request" in event_type.lower() or "pullrequest" in event_type.lower() + + if not is_issue and not is_pr: + log(f"Ignoring unsupported event: {event_type}", "INFO") + self._respond(200, '{"status":"ignored","reason":"unsupported event type"}') + return + + # 只处理 "opened" 动作 + if action and action != "opened": + log(f"Ignoring {event_type} event with action: {action}", "INFO") + self._respond(200, f'{{"status":"ignored","reason":"action={action}"}}') + return + + # 提取 owner/repo + owner, repo = extract_repo_info(payload) + + # ── Issue 事件 → 调起分类 ── + if is_issue: + issue_number = extract_issue_number(payload) + if not issue_number: + self._respond(400, '{"error":"Cannot extract issue number"}') + return + log(f"=== New Issue #{issue_number} — dispatching to triage ===", "OK") + run_triage_async(issue_number, owner or "", repo or "") + self._respond(200, f'{{"status":"accepted","issue_number":{issue_number}}}') + return + + # ── PR 事件 → 调起 gatekeeper ── + if is_pr: + pr_number = extract_pr_number(payload) + if not pr_number: + self._respond(400, '{"error":"Cannot extract PR number"}') + return + log(f"=== New PR #{pr_number} — dispatching to gatekeeper ===", "OK") + run_gatekeeper_async(pr_number, owner or "", repo or "") + self._respond(200, f'{{"status":"accepted","pr_number":{pr_number}}}') + return + + def _respond(self, code, body, content_type="application/json"): + self.send_response(code) + self.send_header("Content-Type", f"{content_type}; charset=utf-8") + self.send_header("Access-Control-Allow-Origin", "*") + self.end_headers() + self.wfile.write(body.encode("utf-8")) + + +def main(): + print() + print("\033[36m╔══════════════════════════════════════════════════════════════╗\033[0m") + print("\033[36m║ GitLink Community Ops — Webhook Listener (Linux) ║\033[0m") + print("\033[36m╚══════════════════════════════════════════════════════════════╝\033[0m") + print() + + log(f"Starting on port {PORT}") + log(f" Issue -> {os.path.basename(TRIAGE_SCRIPT)}") + log(f" PR -> {os.path.basename(GATEKEEPER_SCRIPT)}") + log(f"HMAC verification: {'ENABLED' if SECRET else 'DISABLED (set WEBHOOK_SECRET env var)'}") + print() + + server = HTTPServer(("0.0.0.0", PORT), WebhookHandler) + log(f"Listening on http://0.0.0.0:{PORT}/webhook", "OK") + log(f"Health check: http://0.0.0.0:{PORT}/", "OK") + print() + log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "OK") + log(" Press Ctrl+C to stop", "OK") + log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━", "OK") + print() + + try: + server.serve_forever() + except KeyboardInterrupt: + log("Shutting down...", "INFO") + server.shutdown() + log("Stopped", "OK") + + +if __name__ == "__main__": + main() diff --git a/workflows/01a-webhook-setup.ps1 b/workflows/01a-webhook-setup.ps1 new file mode 100644 index 0000000..f367870 --- /dev/null +++ b/workflows/01a-webhook-setup.ps1 @@ -0,0 +1,323 @@ +# ---------------------------------------------------------------- +# Scenario 1a: Webhook Setup — Register on GitLink Platform +# Role: 在 GitLink 平台上注册 webhook,连接"平台事件"到"本地接收器" +# +# 这是整个实时链路的最后一块拼图——把 GitLink 平台的 Issue 创建事件 +# 和本地的 01a-webhook-listener.ps1 接收器连接起来。 +# +# 完整链路: +# GitLink Issue 创建 +# → Webhook POST 到 +# → 01a-webhook-listener.ps1 (接收 HTTP 请求) +# → 01a-issue-triage.ps1 (AI 分类 + 打标签 + 分配) +# +# 前置条件: +# 方案A (本地开发): 先启动 ngrok → 再启动 01a-webhook-listener.ps1 → 再运行本脚本 +# 方案B (公网服务器): 先启动 01a-webhook-listener.ps1 → 再运行本脚本(直接给公网URL) +# ---------------------------------------------------------------- +#Requires -Version 5.1 + +param( + [string]$WebhookUrl = "", + + [string]$Owner = "", + [string]$Repo = "", + [string]$Secret = "", + [string]$Events = "issue", + [string]$Description = "Community Ops — Issue Auto-Triage (created by gitlink-cli)", + [switch]$DryRun, + [switch]$ListExisting, + [switch]$DeleteExisting, + [switch]$Force, + [switch]$Help +) + +$ErrorActionPreference = "Stop" +Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue + +if ($Help) { + Write-Host "Usage: powershell 01a-webhook-setup.ps1 -WebhookUrl URL [-Owner OWNER] [-Repo REPO] [-Secret SECRET] [-Events EVENTS] [-DryRun]" + Write-Host "" + Write-Host " 在 GitLink 平台上注册 webhook,将 Issue 创建事件连接到本地接收器。" + Write-Host "" + Write-Host " -WebhookUrl URL (必填) Webhook 回调地址,GitLink 会向此 URL POST 事件" + Write-Host " - 本地开发: https://xxx.ngrok-free.app/webhook" + Write-Host " - 公网服务器: https://your-server.com:8080/webhook" + Write-Host " -Owner OWNER 仓库所有者(在git仓库内可自动检测)" + Write-Host " -Repo REPO 仓库名称(在git仓库内可自动检测)" + Write-Host " -Secret SECRET HMAC 密钥(需与 01a-webhook-listener.ps1 的 -Secret 一致)" + Write-Host " -Events EVENTS 触发事件类型(默认: issue)" + Write-Host " -Description DESC Webhook 描述" + Write-Host " -ListExisting 列出当前仓库已有的 webhook" + Write-Host " -DeleteExisting 删除当前仓库所有非 gitlink-cli 创建的 issue 类 webhook(需配合 -Force)" + Write-Host " -Force 配合 -DeleteExisting 使用" + Write-Host " -DryRun 预览模式" + Write-Host "" + Write-Host " 部署步骤:" + Write-Host " # 终端 1: 启动 ngrok(本地开发)" + Write-Host " ngrok http 8080" + Write-Host "" + Write-Host " # 终端 2: 启动接收器" + Write-Host " powershell workflows/01a-webhook-listener.ps1 -Port 8080 -Secret 'your-secret'" + Write-Host "" + Write-Host " # 终端 3: 注册 webhook(ngrok 提供的 URL)" + Write-Host " powershell workflows/01a-webhook-setup.ps1 -WebhookUrl 'https://xxx.ngrok-free.app/webhook' -Secret 'your-secret'" + exit 0 +} + +if (-not $WebhookUrl) { Log-Err "WebhookUrl is required (use -Help for usage)"; exit 1 } + +Check-Auth +$r = Resolve-OwnerRepo $Owner $Repo +$Owner = $r.Owner; $Repo = $r.Repo + +# ================================================================ +# Validation +# ================================================================ +Log-Title "Webhook Setup: $Owner/$Repo" + +# Validate URL format +if ($WebhookUrl -notmatch '^https?://') { + Log-Err "Webhook URL must start with http:// or https://" + Log-Info "For GitLink platform, HTTPS is required. Use ngrok for local dev: ngrok http 8080" + exit 1 +} + +if ($WebhookUrl -notmatch '^https://') { + Log-Warn "GitLink requires HTTPS URLs for webhooks. Your URL uses HTTP which may be rejected." + Log-Info "Consider using ngrok to get an HTTPS URL: ngrok http " +} + +Write-Host " Owner: $Owner" +Write-Host " Repo: $Repo" +Write-Host " Webhook URL: $WebhookUrl" +Write-Host " Events: $Events" +Write-Host " Secret: $(if ($Secret) { '***configured***' } else { '(not set)' })" +Write-Host " Description: $Description" +Divider + +# ================================================================ +# List existing webhooks +# ================================================================ +if ($ListExisting -or $DeleteExisting) { + Log-Title "Existing Webhooks" + + $listResult = Invoke-GL webhook,+list,--owner,$Owner,--repo,$Repo + if (-not $listResult) { + Log-Warn "Could not list webhooks (API may not be available or no webhooks)" + } else { + try { + $listData = $listResult | ConvertFrom-Json + if ($listData.ok -and $listData.data) { + $webhooks = @() + if ($listData.data.webhooks) { $webhooks = @($listData.data.webhooks) } + elseif ($listData.data -is [array]) { $webhooks = $listData.data } + + if ($webhooks.Count -eq 0) { + Log-Info "No webhooks configured for this repo" + } else { + Write-Host "" + foreach ($wh in $webhooks) { + $whId = if ($wh.id) { $wh.id } else { "?" } + $whUrl = if ($wh.hook_url) { $wh.hook_url } elseif ($wh.url) { $wh.url } else { "?" } + $whActive = if ($wh.is_active -ne $null) { $wh.is_active } elseif ($wh.active -ne $null) { $wh.active } else { "?" } + $whEvents = if ($wh.events) { ($wh.events -join ',') } else { "?" } + $whDesc = if ($wh.description) { $wh.description } else { "(no description)" } + Write-Host " [#$whId] $whUrl" -ForegroundColor Cyan + Write-Host " Active: $whActive | Events: $whEvents" + Write-Host " $whDesc" + Write-Host "" + } + } + } + } catch { + Log-Warn "Could not parse webhook list: $_" + Log-Info "Raw output: $listResult" + } + } +} + +# ================================================================ +# Delete existing issue-related webhooks (cleanup before create) +# ================================================================ +if ($DeleteExisting) { + if (-not $Force) { + Log-Warn "-DeleteExisting requires -Force flag for safety. Add -Force to confirm deletion." + } else { + Log-Warn "Removing existing webhooks that match issue events..." + + $listResult = Invoke-GL webhook,+list,--owner,$Owner,--repo,$Repo + if ($listResult) { + try { + $listData = $listResult | ConvertFrom-Json + $webhooks = @() + if ($listData.data.webhooks) { $webhooks = @($listData.data.webhooks) } + elseif ($listData.data -is [array]) { $webhooks = $listData.data } + + foreach ($wh in $webhooks) { + $whId = if ($wh.id) { $wh.id } else { $null } + $whEvents = if ($wh.events) { $wh.events } else { @() } + $whUrl = if ($wh.hook_url) { $wh.hook_url } else { "" } + + # Only delete issue-related ones that point to gitlink-cli created URLs + $isIssueWebhook = ($whEvents -contains "issue") -or ($whEvents -is [string] -and $whEvents -match "issue") + if ($isIssueWebhook) { + if ($DryRun) { + Log-Warn "[DRY RUN] Would delete webhook #$whId ($whUrl)" + } else { + Log-Step "Deleting webhook #$whId..." + $delResult = Invoke-GL webhook,+delete,--owner,$Owner,--repo,$Repo,--id,$whId + if ($delResult) { + Log-Ok "Deleted webhook #$whId" + } else { + Log-Warn "Failed to delete webhook #$whId" + } + } + } + } + } catch { } + } + } + + if (-not $ListExisting) { + Log-Info "Cleanup complete. Proceeding to create new webhook..." + } else { + # User just wanted to list, exit + exit 0 + } +} + +if ($ListExisting) { exit 0 } + +# ================================================================ +# Check for existing webhook with same URL +# ================================================================ +Log-Step "Checking for existing webhooks with same URL..." +$listResult = Invoke-GL webhook,+list,--owner,$Owner,--repo,$Repo +$existingId = $null +if ($listResult) { + try { + $listData = $listResult | ConvertFrom-Json + $webhooks = @() + if ($listData.data.webhooks) { $webhooks = @($listData.data.webhooks) } + elseif ($listData.data -is [array]) { $webhooks = $listData.data } + + foreach ($wh in $webhooks) { + if ($wh.hook_url -eq $WebhookUrl -or $wh.url -eq $WebhookUrl) { + $existingId = $wh.id + break + } + } + } catch { } +} + +if ($existingId) { + Log-Warn "A webhook with URL '$WebhookUrl' already exists (ID: #$existingId)" + Log-Info "To replace it, delete the existing one first with:" + Log-Info " gitlink-cli webhook +delete --owner $Owner --repo $Repo --id $existingId" + Log-Info "Or run this script with -DeleteExisting -Force to clean up" + exit 1 +} +Log-Ok "No duplicate webhook found" + +# ================================================================ +# Register Webhook on GitLink +# ================================================================ +Log-Title "Registering Webhook" + +# Build command arguments +$createArgs = @( + "webhook", "+create", + "--owner", $Owner, + "--repo", $Repo, + "--url", $WebhookUrl, + "--events", $Events, + "--description", $Description +) + +if ($Secret) { + $createArgs += @("--secret", $Secret) +} + +Log-Step "Creating webhook on GitLink platform..." +Log-Info "Command: gitlink-cli $($createArgs -join ' ')" + +if ($DryRun) { + Log-Warn "[DRY RUN] Would create webhook with above parameters" + exit 0 +} + +$createResult = Invoke-GLCheck @createArgs +if (-not $createResult) { + Log-Err "Webhook creation failed" + Log-Info "Common issues:" + Log-Info " 1. URL must be HTTPS (GitLink requirement)" + Log-Info " 2. URL must be publicly accessible from GitLink's servers" + Log-Info " 3. You may need admin permissions on the repo" + Log-Info " 4. Max 20 webhooks per repo — use -ListExisting to check" + exit 1 +} + +$webhookId = "" +if ($createResult.data.id) { $webhookId = $createResult.data.id } +elseif ($createResult.data.webhook.id) { $webhookId = $createResult.data.webhook.id } +Log-Ok "Webhook created! ID: #$webhookId" + +# ================================================================ +# Test Webhook +# ================================================================ +Log-Step "Testing webhook connectivity..." +$testResult = Invoke-GL webhook,+test,--owner,$Owner,--repo,$Repo,--id,$webhookId,--event,issue +if ($testResult) { + try { + $testOk = (($testResult | ConvertFrom-Json).ok -eq $true) + } catch { $testOk = $false } + + if ($testOk) { + Log-Ok "Webhook test ping sent successfully" + Log-Info "Check the listener console for the test event" + } else { + Log-Warn "Webhook test may have failed — check that your listener is running and accessible" + Log-Info "Verify: curl -X POST $WebhookUrl -H 'Content-Type: application/json' -d '{}'" + } +} else { + Log-Warn "Could not test webhook — check that your listener is running" +} + +# ================================================================ +# Complete +# ================================================================ +Log-Title "Webhook Setup Complete" +Write-Host "" + +$checkmark = [char]0x2714 +Write-Host " ${checkmark} GitLink Platform: Webhook registered" -ForegroundColor Green +Write-Host " → When a new Issue is created in $Owner/$Repo" -ForegroundColor Gray +Write-Host " → GitLink POSTs to: $WebhookUrl" -ForegroundColor Gray +Write-Host " → Webhook ID: #$webhookId" -ForegroundColor Gray +Write-Host "" +Write-Host " ${checkmark} Local Listener: 01a-webhook-listener.ps1" -ForegroundColor Green +Write-Host " → Receives HTTP POST from GitLink" -ForegroundColor Gray +Write-Host " → Validates HMAC signature" -ForegroundColor Gray +Write-Host " → Calls 01a-issue-triage.ps1" -ForegroundColor Gray +Write-Host "" +Write-Host " ${checkmark} Triage Script: 01a-issue-triage.ps1" -ForegroundColor Green +Write-Host " → AI analyzes issue content" -ForegroundColor Gray +Write-Host " → Selects matching label from repo's existing labels" -ForegroundColor Gray +Write-Host " → Assigns to issue creator" -ForegroundColor Gray +Write-Host "" + +Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan +Write-Host " 验证方式:" -ForegroundColor Cyan +Write-Host " 1. 在 GitLink 网页端 $Owner/$Repo 创建一个新 Issue" -ForegroundColor White +Write-Host " 2. 观察 01a-webhook-listener.ps1 的控制台输出" -ForegroundColor White +Write-Host " 3. 检查 Issue 是否自动被打上标签并分配了负责人" -ForegroundColor White +Write-Host "" +Write-Host " 手动测试(不走 webhook,直接触发分类):" -ForegroundColor Cyan +Write-Host " powershell workflows/01a-issue-triage.ps1 -IssueNumber " -ForegroundColor White +Write-Host "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" -ForegroundColor Cyan +Write-Host "" +Write-Host " Webhook 管理:" -ForegroundColor Cyan +Write-Host " - 查看: gitlink-cli webhook +list" +Write-Host " - 详情: gitlink-cli webhook +info --id $webhookId" +Write-Host " - 删除: gitlink-cli webhook +delete --id $webhookId" diff --git a/workflows/01a-webhook-setup.sh b/workflows/01a-webhook-setup.sh new file mode 100644 index 0000000..f368dbf --- /dev/null +++ b/workflows/01a-webhook-setup.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash +# ================================================================ +# Scenario 1a: Webhook Setup — 在 GitLink 平台注册 webhook +# ================================================================ + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" + +WEBHOOK_URL="" +OWNER="" +REPO="" +SECRET="" +EVENTS="issue" +DESCRIPTION="Community Ops - Issue Auto-Triage (gitlink-cli)" +DRY_RUN=false + +usage() { + echo "Usage: $0 --webhook-url URL [--owner OWNER] [--repo REPO] [--secret SECRET] [--events EVENTS] [--dry-run]" + echo "" + echo " 在 GitLink 平台注册 webhook,连接 Issue 创建事件到服务器接收器。" + echo "" + echo " --webhook-url URL (必填) Webhook 回调地址" + echo " 例: https://your-server.com:8080/webhook" + echo " --owner OWNER 仓库所有者" + echo " --repo REPO 仓库名称" + echo " --secret SECRET HMAC 密钥(需与监听器环境变量一致)" + echo " --events EVENTS 触发事件(默认: issue)" + echo " --dry-run 预览模式" + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --webhook-url) WEBHOOK_URL="$2"; shift 2 ;; + --owner) OWNER="$2"; shift 2 ;; + --repo) REPO="$2"; shift 2 ;; + --secret) SECRET="$2"; shift 2 ;; + --events) EVENTS="$2"; shift 2 ;; + --dry-run) DRY_RUN="true"; shift ;; + --help|-h) usage ;; + *) log_err "Unknown arg: $1"; usage ;; + esac +done + +[[ -z "$WEBHOOK_URL" ]] && { log_err "--webhook-url is required"; usage; } + +check_auth +require_owner_repo + +log_title "Webhook Setup: $OWNER/$REPO" +echo " Webhook URL: $WEBHOOK_URL" +echo " Events: $EVENTS" +echo " Secret: $( [[ -n "$SECRET" ]] && echo '***configured***' || echo '(not set)' )" + +# 检查重复 +log_step "Checking for existing webhooks with same URL..." +EXISTING_ID=$(gl_run webhook +list --owner "$OWNER" --repo "$REPO" 2>/dev/null | \ + jq -r --arg url "$WEBHOOK_URL" '(.data.webhooks // .data // [])[] | select(.url == $url or .hook_url == $url) | .id' 2>/dev/null || true) + +if [[ -n "$EXISTING_ID" && "$EXISTING_ID" != "null" ]]; then + log_warn "A webhook with URL '$WEBHOOK_URL' already exists (ID: #$EXISTING_ID)" + log_info "Delete it first: gitlink-cli webhook +delete --id $EXISTING_ID" + exit 1 +fi +log_ok "No duplicate found" + +# 注册 +log_step "Creating webhook on GitLink..." + +CREATE_ARGS=(webhook +create --owner "$OWNER" --repo "$REPO" --url "$WEBHOOK_URL" --events "$EVENTS" --description "$DESCRIPTION") +[[ -n "$SECRET" ]] && CREATE_ARGS+=(--secret "$SECRET") + +if [[ "$DRY_RUN" == "true" ]]; then + log_warn "[DRY RUN] Would run: gitlink-cli ${CREATE_ARGS[*]}" + exit 0 +fi + +CREATE_RESULT=$(gl_check "${CREATE_ARGS[@]}" 2>&1) || { + log_err "Webhook creation failed" + log_info "Make sure the URL is HTTPS and publicly accessible" + exit 1 +} + +WEBHOOK_ID=$(echo "$CREATE_RESULT" | jq -r '.data.id // .data.webhook.id') +log_ok "Webhook created! ID: #$WEBHOOK_ID" + +# 测试 +log_step "Testing webhook connectivity..." +gl_run webhook +test --owner "$OWNER" --repo "$REPO" --id "$WEBHOOK_ID" --event issue > /dev/null 2>&1 && { + log_ok "Webhook test ping sent successfully" +} || { + log_warn "Webhook test failed — check that the listener is running" +} + +echo "" +echo -e "${GREEN} ✓ Webhook registered: https://www.gitlink.org.cn/$OWNER/$REPO${NC}" +echo " → When a new Issue is created" +echo " → GitLink POSTs to: $WEBHOOK_URL" +echo " → Webhook ID: #$WEBHOOK_ID" diff --git a/workflows/02-code-quality-gatekeeper.ps1 b/workflows/02-code-quality-gatekeeper.ps1 new file mode 100644 index 0000000..2847a5d --- /dev/null +++ b/workflows/02-code-quality-gatekeeper.ps1 @@ -0,0 +1,567 @@ +# ---------------------------------------------------------------- +# Scenario 2: Code Quality Gatekeeper +# Flow: PR submit -> AI Review -> Check CI -> Auto-merge if pass +# +# Commands chained: +# 1. pr +list -- list open PRs +# 2. pr +view -- get PR details +# 3. pr +files -- get changed files +# 4. pr +diff -- get diff content +# 5. gitlink-code-review -- AI code review (claude CLI or keyword fallback) +# 6. api POST .../reviews -- post review comment +# 7. ci +builds -- check CI status +# 8. pr +merge -- auto-merge if quality threshold met +# ---------------------------------------------------------------- +#Requires -Version 5.1 + +param( + [string]$Owner = "", + [string]$Repo = "", + [string]$PrId = "", + [int]$Threshold = 80, + [switch]$DryRun, + [switch]$Help +) + +$ErrorActionPreference = "Stop" +Import-Module "$PSScriptRoot/lib/common.psm1" -Force + +if ($Help) { + Write-Host "Usage: powershell 02-code-quality-gatekeeper.ps1 -Owner OWNER -Repo REPO [-PrId ID] [-Threshold SCORE] [-DryRun]" + Write-Host "" + Write-Host " -Owner OWNER Repository owner" + Write-Host " -Repo REPO Repository name" + Write-Host " -PrId ID Specific PR to review (default: all open PRs)" + Write-Host " -Threshold SCORE Min quality score to auto-merge (default: 80)" + Write-Host " -DryRun Preview actions without executing" + exit 0 +} + +Check-Auth +$r = Resolve-OwnerRepo $Owner $Repo +$Owner = $r.Owner; $Repo = $r.Repo + +# ---------------------------------------------------------------- +# Default fallback strings (defined at script scope to avoid indented here-string issue) +$DefaultSkillDimensions = "1. 代码质量: 复杂度、命名、注释、格式`n2. 安全性: SQL注入、XSS、敏感信息、认证、输入验证`n3. 性能: 循环效率、资源泄漏、N+1查询、内存`n4. 可维护性: 代码重复、职责单一、依赖耦合、测试覆盖" + +$DefaultReviewPrompt = @' +你是代码审查专家。请按 gitlink-code-review skill 的审查维度分析以下 PR。 + +## 审查维度与检查项 + +{SKILL_DIMENSIONS} + +## 评分标准 +- 90-100: 优秀,可直接合并 +- 75-89: 良好,建议合并 +- 60-74: 一般,需要改进 +- <60: 较差,不建议合并 + +## 问题严重级别 +- CRITICAL: 阻止合并 +- HIGH: 强烈建议修复 +- MEDIUM: 建议修复 +- LOW: 可选修复 + +## PR 数据 + +PR 标题: {PR_TITLE} +变更文件: +{FILE_LIST} +代码差异: +{DIFF_CONTENT} + +## 输出要求 + +请严格按以下 JSON 格式输出,不要输出其他内容: +{"total": <0-100>, "quality": <0-25>, "security": <0-25>, "performance": <0-25>, "maintainability": <0-25>, "issues": [{"severity": "HIGH/MEDIUM/LOW", "category": "quality/security/performance/maintainability", "file": "文件路径", "rule": "规则名", "description": "问题描述", "suggestion": "修复建议"}], "positive_notes": [{"description": "优秀实践描述"}], "recommendations": ["改进建议1"], "verdict": "PASS或FAIL"} +'@ + +function Review-PR { + param([string]$PrId) + + Log-Title "Reviewing PR #$PrId" + + # -- Step 1: Get PR details -- + Log-Step "Fetching PR details..." + $prJson = Invoke-GLCheck pr,+view,--owner,$Owner,--repo,$Repo,--id,$PrId + if (-not $prJson) { + Log-Warn "Failed to fetch PR #$PrId, skipping" + return + } + $prData = $prJson.data + $prTitle = if ($prData.title) { $prData.title } + elseif ($prData.subject) { $prData.subject } + elseif ($prData.issue.subject) { $prData.issue.subject } + else { "N/A" } + $prState = if ($prData.state) { $prData.state } + elseif ($prData.status) { $prData.status } + else { "N/A" } + $prAuthor = if ($prData.author.login) { $prData.author.login } + elseif ($prData.author.username) { $prData.author.username } + elseif ($prData.issue.author.login) { $prData.issue.author.login } + else { "N/A" } + Log-Ok "PR #${PrId}: `"$prTitle`" by @$prAuthor (state: $prState)" + + # -- Step 2: Get changed files -- + Log-Step "Fetching changed files..." + $filesJson = Invoke-GL pr,+files,--owner,$Owner,--repo,$Repo,--id,$PrId + $fileNames = @() + $fileCount = 0 + if ($filesJson) { + try { + $filesData = $filesJson | ConvertFrom-Json + if ($filesData.data.files) { + $fileNames = @($filesData.data.files | ForEach-Object { + if ($_.name) { $_.name } elseif ($_.filename) { $_.filename } else { "unknown" } + }) + $fileCount = $fileNames.Count + } + } catch { } + } + Log-Ok "Changed files: $fileCount" + foreach ($f in $fileNames) { Write-Host " $f" } + + # -- Step 3: Get diff -- + Log-Step "Fetching diff..." + $diffContent = "" + $diffLines = 0 + $diffJson = Invoke-GL pr,+diff,--owner,$Owner,--repo,$Repo,--id,$PrId + if ($diffJson) { + try { + $diffData = $diffJson | ConvertFrom-Json + $diffLines_arr = @() + if ($diffData.data.files) { + foreach ($f in $diffData.data.files) { + if ($f.sections) { + foreach ($s in $f.sections) { + if ($s.lines) { + foreach ($l in $s.lines) { + if ($l.content) { $diffLines_arr += $l.content } + } + } + } + } + } + } + $diffContent = ($diffLines_arr -join "`n") + if ($diffContent.Length -gt 5000) { $diffContent = $diffContent.Substring(0, 5000) } + $diffLines = $diffLines_arr.Count + } catch { } + } + Log-Ok "Diff: $diffLines lines" + + # -- Step 4: AI-powered code review -- + Log-Step "AI analyzing code quality..." + + $fileListText = "" + foreach ($f in $fileNames) { $fileListText += "- $f`n" } + + $diffTruncated = $diffContent + if ($diffTruncated.Length -gt 4000) { $diffTruncated = $diffTruncated.Substring(0, 4000) } + + # Load skill dimensions from SKILL.md + $skillDir = "$PSScriptRoot/../skills/gitlink-code-review" + $skillDimensions = "" + if (Test-Path "$skillDir/SKILL.md") { + $skillMd = Get-Content "$skillDir/SKILL.md" -Raw -Encoding UTF8 + if ($skillMd -match '(?s)## 📊 审查维度(.+?)## 🔧 使用方式') { + $section = $Matches[1] + $dimLines = ($section -split "`n" | Where-Object { $_ -match '^- \*\*' } | Select-Object -First 20) + $skillDimensions = $dimLines -join "`n" + } + } + + if (-not $skillDimensions) { + $skillDimensions = $DefaultSkillDimensions + } + + $reviewPrompt = $DefaultReviewPrompt -replace '\{PR_TITLE\}', $prTitle -replace '\{FILE_LIST\}', $fileListText -replace '\{DIFF_CONTENT\}', $diffTruncated -replace '\{SKILL_DIMENSIONS\}', $skillDimensions + + $aiAvailable = $false + $totalScore = 0 + $scoreQuality = 25 + $scoreSecurity = 25 + $scorePerformance = 25 + $scoreMaintainability = 25 + $issuesFound = @() + $aiPositive = @() + $aiRecommendations = @() + $aiVerdict = "PASS" + + $claudePath = (Get-Command claude -ErrorAction SilentlyContinue).Source + if ($claudePath) { + Log-Info "Calling AI agent for code review (may take 30-60s)..." + + try { + $promptFile = [System.IO.Path]::GetTempFileName() + $outFile = [System.IO.Path]::GetTempFileName() + [System.IO.File]::WriteAllText($promptFile, $reviewPrompt, [System.Text.Encoding]::UTF8) + + $aiResult = $null + $proc = Start-Process -FilePath $claudePath ` + -ArgumentList @("-p", "--output-format", "json") ` + -RedirectStandardInput $promptFile ` + -RedirectStandardOutput $outFile ` + -NoNewWindow -Wait -PassThru + + if ($proc.ExitCode -eq 0 -and (Test-Path $outFile)) { + $aiOutput = [System.IO.File]::ReadAllText($outFile, [System.Text.Encoding]::UTF8) + try { + $aiOutputJson = $aiOutput | ConvertFrom-Json + $aiResult = $aiOutputJson.result + } catch { + $aiResult = $aiOutput + } + } + + Remove-Item $promptFile -Force -ErrorAction SilentlyContinue + Remove-Item $outFile -Force -ErrorAction SilentlyContinue + + if ($aiResult) { + # Extract JSON from AI response (may contain markdown wrapping) + $aiJson = $null + $jsonCandidate = Extract-JsonBlock $aiResult + if ($jsonCandidate) { + try { + $testJson = $jsonCandidate | ConvertFrom-Json + if ($testJson.total -ne $null -and $testJson.verdict) { + $aiJson = $testJson + } + } catch { } + } + # Fallback: try direct parse + if (-not $aiJson) { + try { + $testJson = $aiResult | ConvertFrom-Json + if ($testJson.total -ne $null -and $testJson.verdict) { + $aiJson = $testJson + } + } catch { } + } + + if ($aiJson) { + $totalScore = [int]($aiJson.total -as [int]) + $scoreQuality = [int]($aiJson.quality -as [int]) + $scoreSecurity = [int]($aiJson.security -as [int]) + $scorePerformance = [int]($aiJson.performance -as [int]) + $scoreMaintainability = [int]($aiJson.maintainability -as [int]) + $aiVerdict = if ($aiJson.verdict) { $aiJson.verdict } else { "PASS" } + + if ($aiJson.issues) { + foreach ($issue in $aiJson.issues) { + if ($issue -is [string]) { + $issuesFound += $issue + } else { + $sev = if ($issue.severity) { $issue.severity } else { "?" } + $cat = if ($issue.category) { $issue.category } else { "?" } + $desc = if ($issue.description) { $issue.description } + elseif ($issue.rule) { $issue.rule } else { "unknown" } + $file = if ($issue.file) { " ($($issue.file))" } else { "" } + $sug = if ($issue.suggestion) { " -> $($issue.suggestion)" } else { "" } + $issuesFound += "[$sev] ${cat}: $desc${file}${sug}" + } + } + } + if ($aiJson.positive_notes) { + foreach ($note in $aiJson.positive_notes) { + if ($note.description) { $aiPositive += $note.description } + elseif ($note -is [string]) { $aiPositive += $note } + } + } + if ($aiJson.recommendations) { + foreach ($rec in $aiJson.recommendations) { + if ($rec -is [string]) { $aiRecommendations += $rec } + } + } + + $aiAvailable = $true + Log-Ok "AI review complete (verdict: $aiVerdict)" + } else { + Log-Warn "Could not parse AI response JSON, falling back to keyword-based" + } + } + } catch { + Log-Warn "AI call failed: $_" + } + } + + # -- Fallback: keyword-based heuristics -- + if (-not $aiAvailable) { + Log-Warn "AI not available, falling back to keyword-based analysis" + + $scoreQuality = 25 + $scoreSecurity = 25 + $scorePerformance = 25 + $scoreMaintainability = 25 + $issuesFound = @() + + if ($diffContent -match '(?i)password|secret|token|api_key|apikey|private_key') { + $scoreSecurity -= 15 + $issuesFound += "SECURITY: 检测到可能的硬编码凭证" + } + if ($diffContent -match '(?i)eval\(|exec\(|system\(|shell_exec|os\.system|subprocess\.call') { + $scoreSecurity -= 10 + $issuesFound += "SECURITY: 检测到危险函数调用" + } + if ($diffContent -match '(?i)TODO|FIXME|HACK|XXX') { + $scoreQuality -= 5 + $issuesFound += "QUALITY: 存在 TODO/FIXME/HACK 注释" + } + if ($diffContent -match '(?i)SELECT \*|\.findAll\(\)|\.all\(\)') { + $scorePerformance -= 10 + $issuesFound += "PERFORMANCE: 可能的全表查询" + } + if ($diffContent -match '(?i)sleep\(|time\.sleep|Thread\.sleep') { + $scorePerformance -= 5 + $issuesFound += "PERFORMANCE: 检测到阻塞式 sleep" + } + if ($fileCount -gt 20) { + $scoreMaintainability -= 10 + $issuesFound += "MAINTAINABILITY: 变更文件数量过多 ($fileCount)" + } + + $totalScore = [Math]::Max(0, $scoreQuality + $scoreSecurity + $scorePerformance + $scoreMaintainability) + } + + # -- Print review report -- + Divider + if ($aiAvailable) { + Log-Info "AI Review Report for PR #$PrId" + } else { + Log-Info "Review Report for PR #$PrId (keyword-based)" + } + Write-Host "" + Write-Host " Overall Score: $totalScore / 100" + Write-Host " Code Quality: $scoreQuality / 25" + Write-Host " Security: $scoreSecurity / 25" + Write-Host " Performance: $scorePerformance / 25" + Write-Host " Maintainability: $scoreMaintainability / 25" + Write-Host "" + + if ($issuesFound.Count -gt 0) { + Write-Host " Issues Found:" + foreach ($issue in $issuesFound) { + Write-Host " - $issue" + } + Write-Host "" + } + + if ($aiPositive.Count -gt 0) { + Write-Host " Positive Notes:" + foreach ($note in $aiPositive) { + Write-Host " + $note" + } + Write-Host "" + } + + if ($aiRecommendations.Count -gt 0) { + Write-Host " Recommendations:" + foreach ($rec in $aiRecommendations) { + Write-Host " > $rec" + } + Write-Host "" + } + + # -- Step 5: Post review comment -- + if ($aiAvailable) { + $reviewHeader = "## AI Code Quality Review - PR #$PrId" + } else { + $reviewHeader = "## Code Quality Review - PR #$PrId (keyword-based)" + } + + $reviewBody = "$reviewHeader`n`n### Scores`n" + $reviewBody += "| Dimension | Score | Max |`n" + $reviewBody += "|-----------|-------|-----|`n" + $reviewBody += "| Code Quality | $scoreQuality | 25 |`n" + $reviewBody += "| Security | $scoreSecurity | 25 |`n" + $reviewBody += "| Performance | $scorePerformance | 25 |`n" + $reviewBody += "| Maintainability | $scoreMaintainability | 25 |`n" + $reviewBody += "| **Total** | **$totalScore** | **100** |`n`n" + $reviewBody += "### Issues Found`n" + + if ($issuesFound.Count -gt 0) { + foreach ($issue in $issuesFound) { + $reviewBody += "- $issue`n" + } + } else { + $reviewBody += "No issues found.`n" + } + + if ($aiPositive.Count -gt 0) { + $reviewBody += "`n### Positive Notes`n" + foreach ($note in $aiPositive) { + $reviewBody += "- $note`n" + } + } + + if ($aiRecommendations.Count -gt 0) { + $reviewBody += "`n### Recommendations`n" + foreach ($rec in $aiRecommendations) { + $reviewBody += "- $rec`n" + } + } + + $verdictText = if ($totalScore -ge $Threshold) { + "**PASS** - Score $totalScore >= threshold $Threshold. Ready to merge." + } else { + "**FAIL** - Score $totalScore < threshold $Threshold. Please address the issues above." + } + $reviewBody += "`n### Verdict`n${verdictText}`n`n---`n*Auto-reviewed by gitlink-cli code-quality-gatekeeper workflow (skill: gitlink-code-review)*" + + Log-Step "Posting review comment..." + $reviewEvent = if ($totalScore -ge $Threshold) { "APPROVE" } else { "COMMENT" } + $reviewPayload = @{ + body = $reviewBody + event = $reviewEvent + } | ConvertTo-Json -Compress + + $reviewResult = Invoke-GL api,POST,"/$Owner/$Repo/pulls/$PrId/reviews",--body,$reviewPayload + if ($reviewResult) { + try { + $reviewOk = (($reviewResult | ConvertFrom-Json).ok -eq $true) + } catch { $reviewOk = $false } + if ($reviewOk) { + Log-Ok "Review posted" + } else { + Log-Warn "Review post may have failed (review API might not be available)" + } + } else { + Log-Warn "Review post may have failed" + } + + # -- Step 6: Check CI -- + Log-Step "Checking CI build status..." + $ciPresent = $false + $ciPassed = $true + $ciJson = Invoke-GL ci,+builds,--owner,$Owner,--repo,$Repo + if ($ciJson) { + try { + $ciData = $ciJson | ConvertFrom-Json + $builds = @() + if ($ciData.data.builds) { $builds = @($ciData.data.builds) } + elseif ($ciData.data -is [array]) { $builds = @($ciData.data) } + if ($builds.Count -gt 0) { + $ciPresent = $true + foreach ($b in $builds) { + $status = if ($b.status) { $b.status } elseif ($b.state) { $b.state } else { "unknown" } + $name = if ($b.name) { $b.name } else { "build" } + if ($status -notin @("success", "passed", "completed")) { + $ciPassed = $false + Log-Warn "CI '$name' status: $status" + } else { + Log-Ok "CI '$name' status: $status" + } + } + } + } catch { } + } + if (-not $ciPresent) { Log-Info "No CI builds found" } + + # -- Step 7: Auto-merge -- + if ($totalScore -ge $Threshold -and $ciPassed) { + Log-Step "Quality score $totalScore >= $Threshold and CI passed" + if ($DryRun) { + Log-Warn "[DRY RUN] Would auto-merge PR #$PrId" + } else { + Log-Step "Auto-merging PR #$PrId..." + $mergeResult = Invoke-GL pr,+merge,--owner,$Owner,--repo,$Repo,--id,$PrId,--method,merge + if ($mergeResult) { + try { + $mergeOk = (($mergeResult | ConvertFrom-Json).ok -eq $true) + } catch { $mergeOk = $false } + if ($mergeOk) { + Log-Ok "PR #$PrId merged successfully!" + } else { + Log-Err "Auto-merge failed" + } + } else { + Log-Err "Auto-merge failed" + } + } + } else { + Log-Warn "PR #$PrId not auto-merged (score: $totalScore, threshold: $Threshold, CI passed: $ciPassed)" + } + + Write-Host "" +} + +# ---------------------------------------------------------------- +function Extract-JsonBlock { + param([string]$Text) + # Find JSON by balanced brace matching + $depth = 0 + $start = -1 + $results = @() + for ($i = 0; $i -lt $Text.Length; $i++) { + $c = $Text[$i] + if ($c -eq '{') { + if ($depth -eq 0) { $start = $i } + $depth++ + } elseif ($c -eq '}') { + $depth-- + if ($depth -eq 0 -and $start -ge 0) { + $results += $Text.Substring($start, $i - $start + 1) + $start = -1 + } + } + } + # Return the last valid JSON object (usually the most complete) + for ($i = $results.Count - 1; $i -ge 0; $i--) { + try { + $obj = $results[$i] | ConvertFrom-Json + if ($obj.total -ne $null -and $obj.verdict) { + return $results[$i] + } + } catch { } + } + return $null +} + +# ================================================================ +# Main +# ================================================================ +Log-Title "Code Quality Gatekeeper" + +if ($PrId) { + Review-PR $PrId +} else { + Log-Step "Fetching open PRs..." + $prsResult = Invoke-GL pr,+list,--owner,$Owner,--repo,$Repo,--state,open,--limit,50 + $prList = @() + if ($prsResult) { + try { + $prsJson = $prsResult | ConvertFrom-Json + $prData = $prsJson.data + if ($prData.issues) { $prList = @($prData.issues) } + elseif ($prData.pulls) { $prList = @($prData.pulls) } + elseif ($prData -is [array]) { $prList = $prData } + } catch { } + } + $prCount = $prList.Count + Log-Ok "Found $prCount open PRs" + + if ($prCount -eq 0) { + Log-Info "No open PRs to review" + exit 0 + } + + $reviewed = 0 + $passed = 0 + $failed = 0 + + foreach ($pr in $prList) { + $prNum = if ($pr.pull_request_number) { $pr.pull_request_number } + elseif ($pr.number) { $pr.number } + elseif ($pr.id) { $pr.id } + else { $null } + if (-not $prNum) { continue } + Review-PR $prNum + $reviewed++ + } + + Log-Title "Gatekeeper Summary" + Write-Host " PRs Reviewed: $reviewed" -ForegroundColor Green + Write-Host " Threshold: $Threshold" -ForegroundColor Green +} diff --git a/workflows/02-code-quality-gatekeeper.sh b/workflows/02-code-quality-gatekeeper.sh new file mode 100644 index 0000000..9e39f2d --- /dev/null +++ b/workflows/02-code-quality-gatekeeper.sh @@ -0,0 +1,494 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────── +# Scenario 2: Code Quality Gatekeeper +# Flow: PR submit → Load Skill → Auto Review → Check CI → Auto-merge +# +# Commands/Skills chained: +# 1. pr +list -- list open PRs +# 2. pr +view -- get PR details +# 3. pr +files -- get changed files +# 4. pr +diff -- get diff content +# 5. gitlink-code-review -- AI code review (skill-driven) +# 6. api POST .../reviews -- post review comment with scores +# 7. ci +builds -- check CI build status +# 8. pr +merge -- auto-merge if quality passes threshold +# ───────────────────────────────────────────────────────────────────── + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" + +usage() { + echo "Usage: $0 --owner OWNER --repo REPO [--pr-id ID] [--threshold SCORE] [--dry-run]" + echo "" + echo " --owner OWNER Repository owner" + echo " --repo REPO Repository name" + echo " --pr-id ID Specific PR to review (default: all open PRs)" + echo " --threshold SCORE Min quality score to auto-merge (default: 80)" + echo " --dry-run Preview actions without executing" + exit 1 +} + +THRESHOLD=80 +DRY_RUN=false +OWNER="" +REPO="" +PR_ID="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --owner) OWNER="$2"; shift 2 ;; + --repo) REPO="$2"; shift 2 ;; + --pr-id) PR_ID="$2"; shift 2 ;; + --threshold) THRESHOLD="$2"; shift 2 ;; + --dry-run) DRY_RUN="true"; shift ;; + --help|-h) usage ;; + *) log_err "Unknown arg: $1"; usage ;; + esac +done + +check_auth +require_owner_repo + +# ── Review a single PR ─────────────────────────────────────────────── +review_pr() { + local pr_id="$1" + + log_title "Reviewing PR #$pr_id" + + # Initialize arrays + ISSUES_FOUND=() + AI_POSITIVE=() + AI_RECOMMENDATIONS=() + + # Step 1: Get PR details + log_step "Fetching PR details..." + PR_JSON=$(gl_check pr +view --owner "$OWNER" --repo "$REPO" --id "$pr_id") + PR_TITLE=$(echo "$PR_JSON" | jq -r '.data.title // .data.subject // .data.issue.subject // "N/A"') + PR_STATE=$(echo "$PR_JSON" | jq -r '.data.state // .data.status // "N/A"') + PR_AUTHOR=$(echo "$PR_JSON" | jq -r '.data.author.login // .data.author.username // .data.issue.author.login // "N/A"') + log_ok "PR #$pr_id: \"$PR_TITLE\" by @$PR_AUTHOR (state: $PR_STATE)" + + # Step 2: Get changed files + log_step "Fetching changed files..." + FILES_JSON=$(gl_run pr +files --owner "$OWNER" --repo "$REPO" --id "$pr_id") + FILE_COUNT=0 + if echo "$FILES_JSON" | jq empty 2>/dev/null; then + FILE_COUNT=$(echo "$FILES_JSON" | jq '.data.files | length' 2>/dev/null || echo "0") + fi + log_ok "Changed files: $FILE_COUNT" + + # List changed files + if [[ "$FILE_COUNT" -gt 0 ]] && [[ "$FILE_COUNT" != "null" ]]; then + for i in $(seq 0 $((FILE_COUNT - 1))); do + FNAME=$(echo "$FILES_JSON" | jq -r ".data.files[$i].name // .data.files[$i].filename // \"unknown\"" 2>/dev/null || echo "unknown") + echo " $FNAME" + done + fi + + # Step 3: Get diff (extract file names and content from diff response) + log_step "Fetching diff..." + DIFF_JSON=$(gl_run pr +diff --owner "$OWNER" --repo "$REPO" --id "$pr_id") + DIFF_CONTENT="" + if echo "$DIFF_JSON" | jq empty 2>/dev/null; then + DIFF_CONTENT=$(echo "$DIFF_JSON" | jq -r ' + [.data.files[]?.sections[]?.lines[]?.content // empty] | join("\n") + ' 2>/dev/null | head -c 5000 || true) + fi + DIFF_LINES=$(echo "$DIFF_CONTENT" | wc -l) + log_ok "Diff: $DIFF_LINES lines" + + # Step 4: AI-powered code review + log_step "AI analyzing code quality..." + + # Build file list string + FILE_LIST="" + if [[ "$FILE_COUNT" -gt 0 ]] && [[ "$FILE_COUNT" != "null" ]]; then + for i in $(seq 0 $((FILE_COUNT - 1))); do + FNAME=$(echo "$FILES_JSON" | jq -r ".data.files[$i].name // .data.files[$i].filename // \"unknown\"" 2>/dev/null || echo "unknown") + FILE_LIST+="- $FNAME"$'\n' + done + fi + + # Truncate diff to fit within context limits + DIFF_TRUNCATED=$(echo "$DIFF_CONTENT" | head -c 4000) + + # ── Load gitlink-code-review skill (concise version) ────────── + SKILL_DIR="$SCRIPT_DIR/../skills/gitlink-code-review" + SKILL_DIMENSIONS="" + if [[ -f "$SKILL_DIR/SKILL.md" ]]; then + SKILL_DIMENSIONS=$(sed -n '/^## 📊 审查维度/,/^## 🔧 使用方式/p' "$SKILL_DIR/SKILL.md" | grep '^\- \*\*' | head -20) + fi + + REVIEW_PROMPT="你是代码审查专家。请按 gitlink-code-review skill 的审查维度分析以下 PR。 + +## 审查维度与检查项 + +${SKILL_DIMENSIONS:-1. 代码质量: 复杂度、命名、注释、格式 +2. 安全性: SQL注入、XSS、敏感信息、认证、输入验证 +3. 性能: 循环效率、资源泄漏、N+1查询、内存 +4. 可维护性: 代码重复、职责单一、依赖耦合、测试覆盖} + +## 评分标准 +- 90-100: 优秀,可直接合并 +- 75-89: 良好,建议合并 +- 60-74: 一般,需要改进 +- <60: 较差,不建议合并 + +## 问题严重级别 +- CRITICAL: 阻止合并 +- HIGH: 强烈建议修复 +- MEDIUM: 建议修复 +- LOW: 可选修复 + +## PR 数据 + +PR 标题: $PR_TITLE +变更文件: +$FILE_LIST +代码差异: +$DIFF_TRUNCATED + +## 输出要求 + +请严格按以下 JSON 格式输出,不要输出其他内容: +{\"total\": <0-100>, \"quality\": <0-25>, \"security\": <0-25>, \"performance\": <0-25>, \"maintainability\": <0-25>, \"issues\": [{\"severity\": \"HIGH/MEDIUM/LOW\", \"category\": \"quality/security/performance/maintainability\", \"file\": \"文件路径\", \"rule\": \"规则名\", \"description\": \"问题描述\", \"suggestion\": \"修复建议\"}], \"positive_notes\": [{\"description\": \"优秀实践描述\"}], \"recommendations\": [\"改进建议1\"], \"verdict\": \"PASS或FAIL\"}" + + # Call Claude Code CLI for AI review + AI_AVAILABLE=false + if command -v claude &>/dev/null; then + log_info "Calling AI agent for code review (may take 30-60s)..." + PROMPT_FILE=$(mktemp) + AI_OUT_FILE=$(mktemp) + echo "$REVIEW_PROMPT" > "$PROMPT_FILE" + + # Ensure CLAUDE_CODE_GIT_BASH_PATH is set for Windows + if [[ -z "${CLAUDE_CODE_GIT_BASH_PATH:-}" ]] && command -v cygpath &>/dev/null; then + export CLAUDE_CODE_GIT_BASH_PATH="$(cygpath -w "$(which bash)")" + fi + + # Run claude in a subshell to isolate from set -euo pipefail + # NOTE: must use pipe (not file redirect) for claude -p on Windows + AI_EXIT=0 + ( + cat "$PROMPT_FILE" | timeout 300 claude -p --output-format json > "$AI_OUT_FILE" 2>/dev/null + ) || AI_EXIT=$? + + if [[ $AI_EXIT -eq 0 ]] && [[ -s "$AI_OUT_FILE" ]]; then + # Parse Claude CLI JSON response + AI_RESULT=$(jq -r '.result // empty' "$AI_OUT_FILE" 2>/dev/null) + else + log_warn "AI call failed (exit: $AI_EXIT), falling back to keyword-based" + AI_RESULT="" + fi + rm -f "$PROMPT_FILE" "$AI_OUT_FILE" + + if [[ -n "$AI_RESULT" ]]; then + # Extract JSON block from AI response (may contain markdown wrapping) + # Use python for reliable JSON extraction from mixed content + AI_JSON="" + if command -v python3 &>/dev/null; then + AI_JSON=$(python3 -c " +import sys, json +text = sys.stdin.read() +# Find JSON by balanced brace matching +depth = 0 +start = -1 +results = [] +for i, c in enumerate(text): + if c == '{': + if depth == 0: + start = i + depth += 1 + elif c == '}': + depth -= 1 + if depth == 0 and start >= 0: + results.append(text[start:i+1]) + start = -1 +for m in reversed(results): + try: + obj = json.loads(m) + if 'total' in obj and 'verdict' in obj: + print(json.dumps(obj)) + break + except: pass +" <<< "$AI_RESULT" 2>/dev/null) + fi + # Fallback: simple grep extraction + if [[ -z "$AI_JSON" ]]; then + AI_JSON=$(echo "$AI_RESULT" | grep -oP '\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}' | tail -1) + fi + + if [[ -n "$AI_JSON" ]] && echo "$AI_JSON" | jq empty 2>/dev/null; then + TOTAL_SCORE=$(echo "$AI_JSON" | jq -r '.total // 0' 2>/dev/null) + SCORE_QUALITY=$(echo "$AI_JSON" | jq -r '.quality // 0' 2>/dev/null) + SCORE_SECURITY=$(echo "$AI_JSON" | jq -r '.security // 0' 2>/dev/null) + SCORE_PERFORMANCE=$(echo "$AI_JSON" | jq -r '.performance // 0' 2>/dev/null) + SCORE_MAINTAINABILITY=$(echo "$AI_JSON" | jq -r '.maintainability // 0' 2>/dev/null) + AI_VERDICT=$(echo "$AI_JSON" | jq -r '.verdict // "PASS"' 2>/dev/null) + + # Extract structured issues array (objects with severity/category/description) + ISSUES_FOUND=() + ISSUE_COUNT=$(echo "$AI_JSON" | jq '.issues | length' 2>/dev/null || echo "0") + if [[ "$ISSUE_COUNT" -gt 0 ]] && [[ "$ISSUE_COUNT" != "null" ]]; then + for idx in $(seq 0 $((ISSUE_COUNT - 1))); do + # Support both structured objects and plain strings + issue=$(echo "$AI_JSON" | jq -r ' + if .issues['"$idx"'] | type == "object" then + "[" + (.issues['"$idx"'].severity // "?") + "] " + + (.issues['"$idx"'].category // "?") + ": " + + (.issues['"$idx"'].description // .issues['"$idx"'].rule // "unknown") + + (if .issues['"$idx"'].file then " (" + .issues['"$idx"'].file + ")" else "" end) + + (if .issues['"$idx"'].suggestion then " → " + .issues['"$idx"'].suggestion else "" end) + else + .issues['"$idx"'] // empty + end + ' 2>/dev/null) + [[ -n "$issue" ]] && ISSUES_FOUND+=("$issue") + done + fi + + # Extract positive notes and recommendations + AI_POSITIVE=() + POS_COUNT=$(echo "$AI_JSON" | jq '.positive_notes | length' 2>/dev/null || echo "0") + if [[ "$POS_COUNT" -gt 0 ]] && [[ "$POS_COUNT" != "null" ]]; then + for idx in $(seq 0 $((POS_COUNT - 1))); do + note=$(echo "$AI_JSON" | jq -r '.positive_notes['"$idx"'].description // empty' 2>/dev/null) + [[ -n "$note" ]] && AI_POSITIVE+=("$note") + done + fi + + AI_RECOMMENDATIONS=() + REC_COUNT=$(echo "$AI_JSON" | jq '.recommendations | length' 2>/dev/null || echo "0") + if [[ "$REC_COUNT" -gt 0 ]] && [[ "$REC_COUNT" != "null" ]]; then + for idx in $(seq 0 $((REC_COUNT - 1))); do + rec=$(echo "$AI_JSON" | jq -r '.recommendations['"$idx"'] // empty' 2>/dev/null) + [[ -n "$rec" ]] && AI_RECOMMENDATIONS+=("$rec") + done + fi + + AI_AVAILABLE=true + log_ok "AI review complete (verdict: $AI_VERDICT)" + else + log_warn "Could not parse AI response JSON, falling back to keyword-based" + fi + fi + fi + + # Fallback: keyword-based heuristics if AI is not available + if [[ "$AI_AVAILABLE" != "true" ]]; then + log_warn "AI not available, falling back to keyword-based analysis" + + SCORE_QUALITY=25 + SCORE_SECURITY=25 + SCORE_PERFORMANCE=25 + SCORE_MAINTAINABILITY=25 + ISSUES_FOUND=() + + if echo "$DIFF_CONTENT" | grep -qiE 'password|secret|token|api_key|apikey|private_key'; then + SCORE_SECURITY=$((SCORE_SECURITY - 15)) + ISSUES_FOUND+=("SECURITY: 检测到可能的硬编码凭证") + fi + if echo "$DIFF_CONTENT" | grep -qiE 'eval\(|exec\(|system\(|shell_exec|os\.system|subprocess\.call'; then + SCORE_SECURITY=$((SCORE_SECURITY - 10)) + ISSUES_FOUND+=("SECURITY: 检测到危险函数调用") + fi + if echo "$DIFF_CONTENT" | grep -qiE 'TODO|FIXME|HACK|XXX'; then + SCORE_QUALITY=$((SCORE_QUALITY - 5)) + ISSUES_FOUND+=("QUALITY: 存在 TODO/FIXME/HACK 注释") + fi + if echo "$DIFF_CONTENT" | grep -qiE 'SELECT \*|\.findAll\(\)|\.all\(\)'; then + SCORE_PERFORMANCE=$((SCORE_PERFORMANCE - 10)) + ISSUES_FOUND+=("PERFORMANCE: 可能的全表查询") + fi + if echo "$DIFF_CONTENT" | grep -qiE 'sleep\(|time\.sleep|Thread\.sleep'; then + SCORE_PERFORMANCE=$((SCORE_PERFORMANCE - 5)) + ISSUES_FOUND+=("PERFORMANCE: 检测到阻塞式 sleep") + fi + if [[ "$FILE_COUNT" -gt 20 ]]; then + SCORE_MAINTAINABILITY=$((SCORE_MAINTAINABILITY - 10)) + ISSUES_FOUND+=("MAINTAINABILITY: 变更文件数量过多 ($FILE_COUNT)") + fi + + TOTAL_SCORE=$((SCORE_QUALITY + SCORE_SECURITY + SCORE_PERFORMANCE + SCORE_MAINTAINABILITY)) + TOTAL_SCORE=$((TOTAL_SCORE < 0 ? 0 : TOTAL_SCORE)) + fi + + # Print review report + divider + if [[ "$AI_AVAILABLE" == "true" ]]; then + log_info "AI Review Report for PR #$pr_id" + else + log_info "Review Report for PR #$pr_id (keyword-based)" + fi + echo "" + echo " Overall Score: $TOTAL_SCORE / 100" + echo " Code Quality: $SCORE_QUALITY / 25" + echo " Security: $SCORE_SECURITY / 25" + echo " Performance: $SCORE_PERFORMANCE / 25" + echo " Maintainability: $SCORE_MAINTAINABILITY / 25" + echo "" + + if [[ ${#ISSUES_FOUND[@]} -gt 0 ]]; then + echo " Issues Found:" + for issue in "${ISSUES_FOUND[@]}"; do + echo " - $issue" + done + echo "" + fi + + if [[ "${#AI_POSITIVE[@]}" -gt 0 ]]; then + echo " Positive Notes:" + for note in "${AI_POSITIVE[@]}"; do + echo " + $note" + done + echo "" + fi + + if [[ "${#AI_RECOMMENDATIONS[@]}" -gt 0 ]]; then + echo " Recommendations:" + for rec in "${AI_RECOMMENDATIONS[@]}"; do + echo " > $rec" + done + echo "" + fi + + # Step 5: Post review comment + if [[ "$AI_AVAILABLE" == "true" ]]; then + REVIEW_HEADER="## AI Code Quality Review - PR #$pr_id" + else + REVIEW_HEADER="## Code Quality Review - PR #$pr_id (keyword-based)" + fi + + REVIEW_BODY="$REVIEW_HEADER + +### Scores +| Dimension | Score | Max | +|-----------|-------|-----| +| Code Quality | $SCORE_QUALITY | 25 | +| Security | $SCORE_SECURITY | 25 | +| Performance | $SCORE_PERFORMANCE | 25 | +| Maintainability | $SCORE_MAINTAINABILITY | 25 | +| **Total** | **$TOTAL_SCORE** | **100** | + +### Issues Found" + + if [[ ${#ISSUES_FOUND[@]} -gt 0 ]]; then + for issue in "${ISSUES_FOUND[@]}"; do + REVIEW_BODY+=$'\n'"- $issue" + done + else + REVIEW_BODY+=$'\n'"No issues found." + fi + + if [[ "${#AI_POSITIVE[@]}" -gt 0 ]]; then + REVIEW_BODY+=$'\n'$'\n'"### Positive Notes" + for note in "${AI_POSITIVE[@]}"; do + REVIEW_BODY+=$'\n'"- $note" + done + fi + + if [[ "${#AI_RECOMMENDATIONS[@]}" -gt 0 ]]; then + REVIEW_BODY+=$'\n'$'\n'"### Recommendations" + for rec in "${AI_RECOMMENDATIONS[@]}"; do + REVIEW_BODY+=$'\n'"- $rec" + done + fi + + REVIEW_BODY+=" + +### Verdict +$(if [[ $TOTAL_SCORE -ge $THRESHOLD ]]; then echo "**PASS** - Score $TOTAL_SCORE >= threshold $THRESHOLD. Ready to merge."; else echo "**FAIL** - Score $TOTAL_SCORE < threshold $THRESHOLD. Please address the issues above."; fi) + +--- +*Auto-reviewed by gitlink-cli code-quality-gatekeeper workflow (skill: gitlink-code-review)*" + + log_step "Posting review comment..." + REVIEW_STATE=$(if [[ $TOTAL_SCORE -ge $THRESHOLD ]]; then echo "approved"; else echo "commented"; fi) + REVIEW_JSON=$(jq -n --arg body "$REVIEW_BODY" --arg state "$REVIEW_STATE" \ + '{body: $body, state: $state}') + REVIEW_RESULT=$(gl_run api POST "/$OWNER/$REPO/pulls/$pr_id/reviews" \ + --body "$REVIEW_JSON" 2>&1) || true + + if [[ "$(json_ok "$REVIEW_RESULT")" == "true" ]]; then + log_ok "Review posted" + else + log_warn "Review post may have failed (review API might not be available)" + fi + + # Step 6: Check CI status (API may not be available) + log_step "Checking CI build status..." + CI_JSON=$(gl_run ci +builds --owner "$OWNER" --repo "$REPO") + CI_COUNT=$(echo "$CI_JSON" | jq '.data.builds // .data | length' 2>/dev/null || echo "0") + CI_PASSED=true + + if [[ "$CI_COUNT" -gt 0 ]] && [[ "$CI_COUNT" != "null" ]]; then + for i in $(seq 0 $((CI_COUNT - 1))); do + CI_STATUS=$(echo "$CI_JSON" | jq -r ".data.builds[$i].status // .data.builds[$i].state // .data[$i].status // .data[$i].state // \"unknown\"") + CI_NAME=$(echo "$CI_JSON" | jq -r ".data.builds[$i].name // .data[$i].name // \"build\"") + if [[ "$CI_STATUS" != "success" && "$CI_STATUS" != "passed" && "$CI_STATUS" != "completed" ]]; then + CI_PASSED=false + log_warn "CI '$CI_NAME' status: $CI_STATUS" + else + log_ok "CI '$CI_NAME' status: $CI_STATUS" + fi + done + else + log_info "No CI builds found" + fi + + # Step 7: Auto-merge if quality passes + if [[ $TOTAL_SCORE -ge $THRESHOLD && "$CI_PASSED" == "true" ]]; then + log_step "Quality score $TOTAL_SCORE >= $THRESHOLD and CI passed" + if [[ "$DRY_RUN" == "true" ]]; then + log_warn "[DRY RUN] Would auto-merge PR #$pr_id" + else + log_step "Auto-merging PR #$pr_id..." + MERGE_RESULT=$(gl_run pr +merge --owner "$OWNER" --repo "$REPO" --id "$pr_id" --method merge 2>&1) || true + if [[ "$(json_ok "$MERGE_RESULT")" == "true" ]]; then + log_ok "PR #$pr_id merged successfully!" + else + log_err "Auto-merge failed: $(json_error "$MERGE_RESULT")" + fi + fi + else + log_warn "PR #$pr_id not auto-merged (score: $TOTAL_SCORE, threshold: $THRESHOLD, CI passed: $CI_PASSED)" + fi + + echo "" + return 0 +} + +# ── Main ───────────────────────────────────────────────────────────── +log_title "Code Quality Gatekeeper" + +if [[ -n "$PR_ID" ]]; then + # Review specific PR + review_pr "$PR_ID" +else + # Review all open PRs + log_step "Fetching open PRs..." + PRS_JSON=$(gl_check pr +list --owner "$OWNER" --repo "$REPO" --state open --limit 50) + PR_COUNT=$(echo "$PRS_JSON" | jq '(.data.issues // .data.pulls // .data | if type == "array" then . else [] end) | length') + log_ok "Found $PR_COUNT open PRs" + + if [[ "$PR_COUNT" -eq 0 ]]; then + log_info "No open PRs to review" + exit 0 + fi + + REVIEWED=0 + PASSED=0 + FAILED=0 + + PR_DATA_PATH='(.data.issues // .data.pulls // .data | if type == "array" then . else [] end)' + for i in $(seq 0 $((PR_COUNT - 1))); do + pid=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$i].pull_request_number // $PR_DATA_PATH[$i].number // $PR_DATA_PATH[$i].id // empty") + [[ -z "$pid" ]] && continue + review_pr "$pid" + ((REVIEWED++)) + done + + log_title "Gatekeeper Summary" + echo " PRs Reviewed: $REVIEWED" + echo " Threshold: $THRESHOLD" +fi diff --git a/workflows/02a-pr-gatekeeper.ps1 b/workflows/02a-pr-gatekeeper.ps1 new file mode 100644 index 0000000..d825e21 --- /dev/null +++ b/workflows/02a-pr-gatekeeper.ps1 @@ -0,0 +1,413 @@ +# ---------------------------------------------------------------- +# Scenario 2a: Real-Time PR Gatekeeper (Webhook 触发) +# Flow: Webhook 推送 PR → 拉取详情 → AI Review → 查 CI → 发评论 → 达标自动合并 +# ---------------------------------------------------------------- +#Requires -Version 5.1 + +param( + [string]$Owner = "", + [string]$Repo = "", + [string]$PrId = "", + [int]$Threshold = 80, + [switch]$DryRun, + [switch]$Help +) + +$ErrorActionPreference = "Stop" +Import-Module "$PSScriptRoot/lib/common.psm1" -Force + +if ($Help) { + Write-Host "Usage: powershell 02a-pr-gatekeeper.ps1 -PrId ID [-Owner OWNER] [-Repo REPO] [-Threshold SCORE] [-DryRun]" + exit 0 +} + +if (-not $PrId) { Log-Err "PrId is required (use -Help for usage)"; exit 1 } + +Check-Auth +$r = Resolve-OwnerRepo $Owner $Repo +$Owner = $r.Owner; $Repo = $r.Repo + +# Default fallback strings +$DefaultSkillDimensions = "1. 代码质量: 复杂度、命名、注释、格式`n2. 安全性: SQL注入、XSS、敏感信息、认证、输入验证`n3. 性能: 循环效率、资源泄漏、N+1查询、内存`n4. 可维护性: 代码重复、职责单一、依赖耦合、测试覆盖" + +$DefaultReviewPrompt = @' +你是代码审查专家。请按 gitlink-code-review skill 的审查维度分析以下 PR。 + +## 审查维度与检查项 + +{SKILL_DIMENSIONS} + +## 评分标准 +- 90-100: 优秀,可直接合并 +- 75-89: 良好,建议合并 +- 60-74: 一般,需要改进 +- <60: 较差,不建议合并 + +## 问题严重级别 +- CRITICAL: 阻止合并 +- HIGH: 强烈建议修复 +- MEDIUM: 建议修复 +- LOW: 可选修复 + +## PR 数据 + +PR 标题: {PR_TITLE} +变更文件: +{FILE_LIST} +代码差异: +{DIFF_CONTENT} + +## 输出要求 + +请严格按以下 JSON 格式输出,不要输出其他内容: +{"total": <0-100>, "quality": <0-25>, "security": <0-25>, "performance": <0-25>, "maintainability": <0-25>, "issues": [{"severity": "HIGH/MEDIUM/LOW", "category": "quality/security/performance/maintainability", "file": "文件路径", "rule": "规则名", "description": "问题描述", "suggestion": "修复建议"}], "positive_notes": [{"description": "优秀实践描述"}], "recommendations": ["改进建议1"], "verdict": "PASS或FAIL"} +'@ + +function Extract-JsonBlock { + param([string]$Text) + $depth = 0; $start = -1; $results = @() + for ($i = 0; $i -lt $Text.Length; $i++) { + $c = $Text[$i] + if ($c -eq '{') { + if ($depth -eq 0) { $start = $i } + $depth++ + } elseif ($c -eq '}') { + $depth-- + if ($depth -eq 0 -and $start -ge 0) { + $results += $Text.Substring($start, $i - $start + 1) + $start = -1 + } + } + } + for ($i = $results.Count - 1; $i -ge 0; $i--) { + try { + $obj = $results[$i] | ConvertFrom-Json + if ($obj.total -ne $null -and $obj.verdict) { return $results[$i] } + } catch { } + } + return $null +} + +# ================================================================ +Log-Title "PR Gatekeeper: #$PrId ($Owner/$Repo)" + +# -- Step 1: 拉取 PR 详情 -- +Log-Step "Fetching PR details..." +$prJson = Invoke-GLCheck pr,+view,--owner,$Owner,--repo,$Repo,--id,$PrId +if (-not $prJson) { Log-Err "Failed to fetch PR #$PrId"; exit 1 } + +$prData = $prJson.data +$prTitle = if ($prData.title) { $prData.title } + elseif ($prData.subject) { $prData.subject } + elseif ($prData.issue.subject) { $prData.issue.subject } + else { "N/A" } +$prState = if ($prData.state) { $prData.state } + elseif ($prData.status) { $prData.status } + else { "N/A" } +$prAuthor = if ($prData.author.login) { $prData.author.login } + elseif ($prData.author.username) { $prData.author.username } + elseif ($prData.issue.author.login) { $prData.issue.author.login } + else { "N/A" } +Log-Ok "PR #${PrId}: `"$prTitle`" by @$prAuthor (state: $prState)" + +# -- Step 2: 获取变更文件列表 -- +Log-Step "Fetching changed files..." +$filesJson = Invoke-GL pr,+files,--owner,$Owner,--repo,$Repo,--id,$PrId +$fileNames = @(); $fileCount = 0 +if ($filesJson) { + try { + $filesData = $filesJson | ConvertFrom-Json + if ($filesData.data.files) { + $fileNames = @($filesData.data.files | ForEach-Object { + if ($_.name) { $_.name } elseif ($_.filename) { $_.filename } else { "unknown" } + }) + $fileCount = $fileNames.Count + } + } catch { } +} +Log-Ok "Changed files: $fileCount" +foreach ($f in $fileNames) { Write-Host " $f" } + +# -- Step 3: 获取 diff -- +Log-Step "Fetching diff..." +$diffContent = ""; $diffLines = 0 +$diffJson = Invoke-GL pr,+diff,--owner,$Owner,--repo,$Repo,--id,$PrId +if ($diffJson) { + try { + $diffData = $diffJson | ConvertFrom-Json + $diffLines_arr = @() + if ($diffData.data.files) { + foreach ($f in $diffData.data.files) { + if ($f.sections) { + foreach ($s in $f.sections) { + if ($s.lines) { + foreach ($l in $s.lines) { + if ($l.content) { $diffLines_arr += $l.content } + } + } + } + } + } + } + $diffContent = ($diffLines_arr -join "`n") + if ($diffContent.Length -gt 5000) { $diffContent = $diffContent.Substring(0, 5000) } + $diffLines = $diffLines_arr.Count + } catch { } +} +Log-Ok "Diff: $diffLines lines" + +# -- Step 4: AI 代码审查 -- +Log-Step "AI analyzing code quality..." + +$fileListText = "" +foreach ($f in $fileNames) { $fileListText += "- $f`n" } + +$diffTruncated = $diffContent +if ($diffTruncated.Length -gt 4000) { $diffTruncated = $diffTruncated.Substring(0, 4000) } + +$skillDir = "$PSScriptRoot/../skills/gitlink-code-review" +$skillDimensions = "" +if (Test-Path "$skillDir/SKILL.md") { + $skillMd = Get-Content "$skillDir/SKILL.md" -Raw -Encoding UTF8 + if ($skillMd -match '(?s)## 📊 审查维度(.+?)## 🔧 使用方式') { + $section = $Matches[1] + $dimLines = ($section -split "`n" | Where-Object { $_ -match '^- \*\*' } | Select-Object -First 20) + $skillDimensions = $dimLines -join "`n" + } +} +if (-not $skillDimensions) { $skillDimensions = $DefaultSkillDimensions } + +$reviewPrompt = $DefaultReviewPrompt -replace '\{PR_TITLE\}', $prTitle -replace '\{FILE_LIST\}', $fileListText -replace '\{DIFF_CONTENT\}', $diffTruncated -replace '\{SKILL_DIMENSIONS\}', $skillDimensions + +$aiAvailable = $false +$totalScore = 0 +$scoreQuality = 25; $scoreSecurity = 25; $scorePerformance = 25; $scoreMaintainability = 25 +$issuesFound = @(); $aiPositive = @(); $aiRecommendations = @(); $aiVerdict = "PASS" + +$claudePath = (Get-Command claude -ErrorAction SilentlyContinue).Source +if ($claudePath) { + Log-Info "Calling AI agent for code review (may take 30-60s)..." + try { + $promptFile = [System.IO.Path]::GetTempFileName() + $outFile = [System.IO.Path]::GetTempFileName() + [System.IO.File]::WriteAllText($promptFile, $reviewPrompt, [System.Text.Encoding]::UTF8) + + $proc = Start-Process -FilePath $claudePath ` + -ArgumentList @("-p", "--output-format", "json") ` + -RedirectStandardInput $promptFile ` + -RedirectStandardOutput $outFile ` + -NoNewWindow -Wait -PassThru + + if ($proc.ExitCode -eq 0 -and (Test-Path $outFile)) { + $aiOutput = [System.IO.File]::ReadAllText($outFile, [System.Text.Encoding]::UTF8) + try { $aiResult = ($aiOutput | ConvertFrom-Json).result } catch { $aiResult = $aiOutput } + } + + Remove-Item $promptFile -Force -ErrorAction SilentlyContinue + Remove-Item $outFile -Force -ErrorAction SilentlyContinue + + if ($aiResult) { + $aiJson = $null + $jsonCandidate = Extract-JsonBlock $aiResult + if ($jsonCandidate) { + try { + $testJson = $jsonCandidate | ConvertFrom-Json + if ($testJson.total -ne $null -and $testJson.verdict) { $aiJson = $testJson } + } catch { } + } + if (-not $aiJson) { + try { + $testJson = $aiResult | ConvertFrom-Json + if ($testJson.total -ne $null -and $testJson.verdict) { $aiJson = $testJson } + } catch { } + } + + if ($aiJson) { + $totalScore = [int]($aiJson.total -as [int]) + $scoreQuality = [int]($aiJson.quality -as [int]) + $scoreSecurity = [int]($aiJson.security -as [int]) + $scorePerformance = [int]($aiJson.performance -as [int]) + $scoreMaintainability = [int]($aiJson.maintainability -as [int]) + $aiVerdict = if ($aiJson.verdict) { $aiJson.verdict } else { "PASS" } + + if ($aiJson.issues) { + foreach ($issue in $aiJson.issues) { + if ($issue -is [string]) { $issuesFound += $issue } + else { + $sev = if ($issue.severity) { $issue.severity } else { "?" } + $cat = if ($issue.category) { $issue.category } else { "?" } + $desc = if ($issue.description) { $issue.description } + elseif ($issue.rule) { $issue.rule } else { "unknown" } + $file = if ($issue.file) { " ($($issue.file))" } else { "" } + $sug = if ($issue.suggestion) { " -> $($issue.suggestion)" } else { "" } + $issuesFound += "[$sev] ${cat}: $desc${file}${sug}" + } + } + } + if ($aiJson.positive_notes) { + foreach ($note in $aiJson.positive_notes) { + if ($note.description) { $aiPositive += $note.description } + elseif ($note -is [string]) { $aiPositive += $note } + } + } + if ($aiJson.recommendations) { + foreach ($rec in $aiJson.recommendations) { + if ($rec -is [string]) { $aiRecommendations += $rec } + } + } + $aiAvailable = $true + Log-Ok "AI review complete (verdict: $aiVerdict)" + } else { + Log-Warn "Could not parse AI response JSON, falling back to keyword-based" + } + } + } catch { Log-Warn "AI call failed: $_" } +} + +# -- Fallback: keyword-based -- +if (-not $aiAvailable) { + Log-Warn "AI not available, falling back to keyword-based analysis" + $scoreQuality = 25; $scoreSecurity = 25; $scorePerformance = 25; $scoreMaintainability = 25 + $issuesFound = @() + + if ($diffContent -match '(?i)password|secret|token|api_key|apikey|private_key') { + $scoreSecurity -= 15; $issuesFound += "SECURITY: 检测到可能的硬编码凭证" + } + if ($diffContent -match '(?i)eval\(|exec\(|system\(|shell_exec|os\.system|subprocess\.call') { + $scoreSecurity -= 10; $issuesFound += "SECURITY: 检测到危险函数调用" + } + if ($diffContent -match '(?i)TODO|FIXME|HACK|XXX') { + $scoreQuality -= 5; $issuesFound += "QUALITY: 存在 TODO/FIXME/HACK 注释" + } + if ($diffContent -match '(?i)SELECT \*|\.findAll\(\)|\.all\(\)') { + $scorePerformance -= 10; $issuesFound += "PERFORMANCE: 可能的全表查询" + } + if ($diffContent -match '(?i)sleep\(|time\.sleep|Thread\.sleep') { + $scorePerformance -= 5; $issuesFound += "PERFORMANCE: 检测到阻塞式 sleep" + } + if ($fileCount -gt 20) { + $scoreMaintainability -= 10; $issuesFound += "MAINTAINABILITY: 变更文件数量过多 ($fileCount)" + } + $totalScore = [Math]::Max(0, $scoreQuality + $scoreSecurity + $scorePerformance + $scoreMaintainability) +} + +# -- 打印审查报告 -- +Divider +if ($aiAvailable) { Log-Info "AI Review Report for PR #$PrId" } +else { Log-Info "Review Report for PR #$PrId (keyword-based)" } +Write-Host "" +Write-Host " Overall Score: $totalScore / 100" +Write-Host " Code Quality: $scoreQuality / 25" +Write-Host " Security: $scoreSecurity / 25" +Write-Host " Performance: $scorePerformance / 25" +Write-Host " Maintainability: $scoreMaintainability / 25" +Write-Host "" + +if ($issuesFound.Count -gt 0) { + Write-Host " Issues Found:" + foreach ($issue in $issuesFound) { Write-Host " - $issue" } + Write-Host "" +} +if ($aiPositive.Count -gt 0) { + Write-Host " Positive Notes:" + foreach ($note in $aiPositive) { Write-Host " + $note" } + Write-Host "" +} +if ($aiRecommendations.Count -gt 0) { + Write-Host " Recommendations:" + foreach ($rec in $aiRecommendations) { Write-Host " > $rec" } + Write-Host "" +} + +# -- Step 5: 发布审查评论 -- +if ($aiAvailable) { $reviewHeader = "## AI Code Quality Review - PR #$PrId" } +else { $reviewHeader = "## Code Quality Review - PR #$PrId (keyword-based)" } + +$reviewBody = "$reviewHeader`n`n### Scores`n" +$reviewBody += "| Dimension | Score | Max |`n" +$reviewBody += "|-----------|-------|-----|`n" +$reviewBody += "| Code Quality | $scoreQuality | 25 |`n" +$reviewBody += "| Security | $scoreSecurity | 25 |`n" +$reviewBody += "| Performance | $scorePerformance | 25 |`n" +$reviewBody += "| Maintainability | $scoreMaintainability | 25 |`n" +$reviewBody += "| **Total** | **$totalScore** | **100** |`n`n" +$reviewBody += "### Issues Found`n" + +if ($issuesFound.Count -gt 0) { + foreach ($issue in $issuesFound) { $reviewBody += "- $issue`n" } +} else { $reviewBody += "No issues found.`n" } + +if ($aiPositive.Count -gt 0) { + $reviewBody += "`n### Positive Notes`n" + foreach ($note in $aiPositive) { $reviewBody += "- $note`n" } +} +if ($aiRecommendations.Count -gt 0) { + $reviewBody += "`n### Recommendations`n" + foreach ($rec in $aiRecommendations) { $reviewBody += "- $rec`n" } +} + +$verdictText = if ($totalScore -ge $Threshold) { + "**PASS** - Score $totalScore >= threshold $Threshold. Ready to merge." +} else { + "**FAIL** - Score $totalScore < threshold $Threshold. Please address the issues above." +} +$reviewBody += "`n### Verdict`n${verdictText}`n`n---`n*Auto-reviewed by gitlink-cli code-quality-gatekeeper workflow (skill: gitlink-code-review)*" + +Log-Step "Posting review comment..." +$reviewEvent = if ($totalScore -ge $Threshold) { "APPROVE" } else { "COMMENT" } +$reviewPayload = @{ body = $reviewBody; event = $reviewEvent } | ConvertTo-Json -Compress +$reviewResult = Invoke-GL api,POST,"/$Owner/$Repo/pulls/$PrId/reviews",--body,$reviewPayload +if ($reviewResult) { + try { $reviewOk = (($reviewResult | ConvertFrom-Json).ok -eq $true) } catch { $reviewOk = $false } + if ($reviewOk) { Log-Ok "Review posted" } + else { Log-Warn "Review post may have failed (review API might not be available)" } +} else { Log-Warn "Review post may have failed" } + +# -- Step 6: 检查 CI 状态 -- +Log-Step "Checking CI build status..." +$ciPresent = $false; $ciPassed = $true +$ciJson = Invoke-GL ci,+builds,--owner,$Owner,--repo,$Repo +if ($ciJson) { + try { + $ciData = $ciJson | ConvertFrom-Json + $builds = @() + if ($ciData.data.builds) { $builds = @($ciData.data.builds) } + elseif ($ciData.data -is [array]) { $builds = @($ciData.data) } + if ($builds.Count -gt 0) { + $ciPresent = $true + foreach ($b in $builds) { + $status = if ($b.status) { $b.status } elseif ($b.state) { $b.state } else { "unknown" } + $name = if ($b.name) { $b.name } else { "build" } + if ($status -notin @("success", "passed", "completed")) { + $ciPassed = $false; Log-Warn "CI '$name' status: $status" + } else { Log-Ok "CI '$name' status: $status" } + } + } + } catch { } +} +if (-not $ciPresent) { Log-Info "No CI builds found" } + +# -- Step 7: 质量达标 → 自动合并 -- +if ($totalScore -ge $Threshold -and $ciPassed) { + Log-Step "Quality score $totalScore >= $Threshold and CI passed" + if ($DryRun) { + Log-Warn "[DRY RUN] Would auto-merge PR #$PrId" + } else { + Log-Step "Auto-merging PR #$PrId..." + $mergeResult = Invoke-GL pr,+merge,--owner,$Owner,--repo,$Repo,--id,$PrId,--method,merge + if ($mergeResult) { + try { $mergeOk = (($mergeResult | ConvertFrom-Json).ok -eq $true) } catch { $mergeOk = $false } + if ($mergeOk) { Log-Ok "PR #$PrId merged successfully!" } + else { Log-Err "Auto-merge failed" } + } else { Log-Err "Auto-merge failed" } + } +} else { + Log-Warn "PR #$PrId not auto-merged (score: $totalScore, threshold: $Threshold, CI passed: $ciPassed)" +} + +# ── Complete ────────────────────────────────────────────────────── +Log-Title "Gatekeeper Complete" +Write-Host " PR: #$PrId - $prTitle" -ForegroundColor Green +Write-Host " Author: @$prAuthor" -ForegroundColor Green +Write-Host " Score: $totalScore / 100" -ForegroundColor Green +Write-Host " CI Passed: $ciPassed" -ForegroundColor Green diff --git a/workflows/02a-pr-gatekeeper.sh b/workflows/02a-pr-gatekeeper.sh new file mode 100644 index 0000000..0a97da3 --- /dev/null +++ b/workflows/02a-pr-gatekeeper.sh @@ -0,0 +1,603 @@ +#!/usr/bin/env bash +# ================================================================ +# Scenario 2a: Real-Time PR Gatekeeper (Webhook 触发) +# Flow: Webhook 推送 PR → 拉取详情 → AI Review → 查 CI → 发评论 → 达标自动合并 +# ================================================================ +set -eo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +source "$SCRIPT_DIR/lib/common.sh" + +PR_ID="" +OWNER="" +REPO="" +THRESHOLD=80 +DRY_RUN=false + +usage() { + echo "Usage: $0 --pr-id N [--owner OWNER] [--repo REPO] [--threshold SCORE] [--dry-run]" + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --pr-id) PR_ID="$2"; shift 2 ;; + --owner) OWNER="$2"; shift 2 ;; + --repo) REPO="$2"; shift 2 ;; + --threshold) THRESHOLD="$2"; shift 2 ;; + --dry-run) DRY_RUN="true"; shift ;; + --help|-h) usage ;; + *) log_err "Unknown arg: $1"; usage ;; + esac +done + +[[ -z "$PR_ID" ]] && { log_err "--pr-id is required"; usage; } + +check_auth +require_owner_repo + +# ================================================================ +log_title "PR Gatekeeper: #$PR_ID ($OWNER/$REPO)" + +# ── Step 1: 拉取 PR 详情 ───────────────────────────────────────── +log_step "Fetching PR details..." +PR_JSON=$(gl_check pr +view --owner "$OWNER" --repo "$REPO" --id "$PR_ID") +[[ -z "$PR_JSON" ]] && { log_err "Failed to fetch PR #$PR_ID"; exit 1; } + +PR_TITLE=$(echo "$PR_JSON" | jq -r '.data.title // .data.subject // .data.issue.subject // "N/A"') +PR_STATE=$(echo "$PR_JSON" | jq -r '.data.state // .data.status // "N/A"') +PR_AUTHOR=$(echo "$PR_JSON" | jq -r '.data.author.login // .data.author.username // .data.issue.author.login // "N/A"') +ISSUE_ID=$(echo "$PR_JSON" | jq -r '.data.id // .data.issue.id // empty') +PR_HEAD=$(echo "$PR_JSON" | jq -r '.data.pull_request.head // empty') +log_ok "PR #$PR_ID: \"$PR_TITLE\" by @$PR_AUTHOR (state: $PR_STATE, branch: $PR_HEAD)" + +# ── Step 2: 获取变更文件列表 ────────────────────────────────────── +log_step "Fetching changed files..." +FILES_JSON=$(gl_run pr +files --owner "$OWNER" --repo "$REPO" --id "$PR_ID") +FILE_COUNT=0 +if echo "$FILES_JSON" | jq empty 2>/dev/null; then + FILE_COUNT=$(echo "$FILES_JSON" | jq '.data.files | length' 2>/dev/null || echo "0") +fi +log_ok "Changed files: $FILE_COUNT" + +FILE_LIST="" +if [[ "$FILE_COUNT" -gt 0 ]] && [[ "$FILE_COUNT" != "null" ]]; then + for i in $(seq 0 $((FILE_COUNT - 1))); do + FNAME=$(echo "$FILES_JSON" | jq -r ".data.files[$i].name // .data.files[$i].filename // \"unknown\"" 2>/dev/null || echo "unknown") + echo " $FNAME" + FILE_LIST+="- $FNAME"$'\n' + done +fi + +# ── Step 3: 获取 diff ──────────────────────────────────────────── +log_step "Fetching diff..." +DIFF_JSON=$(gl_run pr +diff --owner "$OWNER" --repo "$REPO" --id "$PR_ID") +DIFF_CONTENT="" +if echo "$DIFF_JSON" | jq empty 2>/dev/null; then + DIFF_CONTENT=$(echo "$DIFF_JSON" | jq -r ' + [.data.files[]?.sections[]?.lines[]?.content // empty] | join("\n") + ' 2>/dev/null | head -c 5000 || true) +fi +DIFF_LINES=$(echo "$DIFF_CONTENT" | wc -l) +log_ok "Diff: $DIFF_LINES lines" + +# ── Step 4: AI 代码审查 ─────────────────────────────────────────── +log_step "AI analyzing code quality..." + +DIFF_TRUNCATED=$(echo "$DIFF_CONTENT" | head -c 4000) + +# Load skill dimensions from SKILL.md +SKILL_DIR="$SCRIPT_DIR/../skills/gitlink-code-review" +SKILL_DIMENSIONS="" +if [[ -f "$SKILL_DIR/SKILL.md" ]]; then + SKILL_DIMENSIONS=$(sed -n '/^## 📊 审查维度/,/^## 🔧 使用方式/p' "$SKILL_DIR/SKILL.md" | grep '^\- \*\*' | head -20) +fi + +REVIEW_PROMPT="你是代码审查专家。请按 gitlink-code-review skill 的审查维度分析以下 PR。 + +## 审查维度与检查项 + +${SKILL_DIMENSIONS:-1. 代码质量: 复杂度、命名、注释、格式 +2. 安全性: SQL注入、XSS、敏感信息、认证、输入验证 +3. 性能: 循环效率、资源泄漏、N+1查询、内存 +4. 可维护性: 代码重复、职责单一、依赖耦合、测试覆盖} + +## 评分标准 +- 90-100: 优秀,可直接合并 +- 75-89: 良好,建议合并 +- 60-74: 一般,需要改进 +- <60: 较差,不建议合并 + +## 问题严重级别 +- CRITICAL: 阻止合并 +- HIGH: 强烈建议修复 +- MEDIUM: 建议修复 +- LOW: 可选修复 + +## PR 数据 + +PR 标题: $PR_TITLE +变更文件: +$FILE_LIST +代码差异: +$DIFF_TRUNCATED + +## 输出要求 + +请严格按以下 JSON 格式输出,不要输出其他内容: +{\"total\": <0-100>, \"quality\": <0-25>, \"security\": <0-25>, \"performance\": <0-25>, \"maintainability\": <0-25>, \"issues\": [{\"severity\": \"HIGH/MEDIUM/LOW\", \"category\": \"quality/security/performance/maintainability\", \"file\": \"文件路径\", \"rule\": \"规则名\", \"description\": \"问题描述\", \"suggestion\": \"修复建议\"}], \"positive_notes\": [{\"description\": \"优秀实践描述\"}], \"recommendations\": [\"改进建议1\"], \"verdict\": \"PASS或FAIL\"}" + +AI_AVAILABLE=false +SCORE_QUALITY=25 +SCORE_SECURITY=25 +SCORE_PERFORMANCE=25 +SCORE_MAINTAINABILITY=25 +ISSUES_FOUND=() +AI_POSITIVE=() +AI_RECOMMENDATIONS=() +TOTAL_SCORE=0 + +if command -v claude &>/dev/null; then + log_info "Calling AI agent for code review (may take 30-60s)..." + PROMPT_FILE=$(mktemp) + AI_OUT_FILE=$(mktemp) + echo "$REVIEW_PROMPT" > "$PROMPT_FILE" + + if [[ -z "${CLAUDE_CODE_GIT_BASH_PATH:-}" ]] && command -v cygpath &>/dev/null; then + export CLAUDE_CODE_GIT_BASH_PATH="$(cygpath -w "$(which bash)")" + fi + + AI_EXIT=0 + (cat "$PROMPT_FILE" | timeout 300 claude -p --output-format json > "$AI_OUT_FILE" 2>/dev/null) || AI_EXIT=$? + + if [[ $AI_EXIT -eq 0 ]] && [[ -s "$AI_OUT_FILE" ]]; then + AI_RESULT=$(jq -r '.result // empty' "$AI_OUT_FILE" 2>/dev/null) + else + log_warn "AI call failed (exit: $AI_EXIT), falling back to keyword-based" + AI_RESULT="" + fi + rm -f "$PROMPT_FILE" "$AI_OUT_FILE" + + if [[ -n "$AI_RESULT" ]]; then + AI_JSON="" + if command -v python3 &>/dev/null; then + AI_JSON=$(python3 -c " +import sys, json +text = sys.stdin.read() +depth = 0 +start = -1 +results = [] +for i, c in enumerate(text): + if c == '{': + if depth == 0: start = i + depth += 1 + elif c == '}': + depth -= 1 + if depth == 0 and start >= 0: + results.append(text[start:i+1]) + start = -1 +for m in reversed(results): + try: + obj = json.loads(m) + if 'total' in obj and 'verdict' in obj: + print(json.dumps(obj)) + break + except: pass +" <<< "$AI_RESULT" 2>/dev/null) + fi + if [[ -z "$AI_JSON" ]]; then + AI_JSON=$(echo "$AI_RESULT" | grep -oP '\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}' | tail -1) + fi + + if [[ -n "$AI_JSON" ]] && echo "$AI_JSON" | jq empty 2>/dev/null; then + TOTAL_SCORE=$(echo "$AI_JSON" | jq -r '.total // 0' 2>/dev/null) + SCORE_QUALITY=$(echo "$AI_JSON" | jq -r '.quality // 0' 2>/dev/null) + SCORE_SECURITY=$(echo "$AI_JSON" | jq -r '.security // 0' 2>/dev/null) + SCORE_PERFORMANCE=$(echo "$AI_JSON" | jq -r '.performance // 0' 2>/dev/null) + SCORE_MAINTAINABILITY=$(echo "$AI_JSON" | jq -r '.maintainability // 0' 2>/dev/null) + AI_VERDICT=$(echo "$AI_JSON" | jq -r '.verdict // "PASS"' 2>/dev/null) + + ISSUE_COUNT=$(echo "$AI_JSON" | jq '.issues | length' 2>/dev/null || echo "0") + if [[ "$ISSUE_COUNT" -gt 0 ]] && [[ "$ISSUE_COUNT" != "null" ]]; then + for idx in $(seq 0 $((ISSUE_COUNT - 1))); do + issue=$(echo "$AI_JSON" | jq -r ' + if .issues['"$idx"'] | type == "object" then + "[" + (.issues['"$idx"'].severity // "?") + "] " + + (.issues['"$idx"'].category // "?") + ": " + + (.issues['"$idx"'].description // .issues['"$idx"'].rule // "unknown") + + (if .issues['"$idx"'].file then " (" + .issues['"$idx"'].file + ")" else "" end) + + (if .issues['"$idx"'].suggestion then " -> " + .issues['"$idx"'].suggestion else "" end) + else .issues['"$idx"'] // empty end' 2>/dev/null) + [[ -n "$issue" ]] && ISSUES_FOUND+=("$issue") + done + fi + + POS_COUNT=$(echo "$AI_JSON" | jq '.positive_notes | length' 2>/dev/null || echo "0") + if [[ "$POS_COUNT" -gt 0 ]] && [[ "$POS_COUNT" != "null" ]]; then + for idx in $(seq 0 $((POS_COUNT - 1))); do + note=$(echo "$AI_JSON" | jq -r '.positive_notes['"$idx"'].description // empty' 2>/dev/null) + [[ -n "$note" ]] && AI_POSITIVE+=("$note") + done + fi + + REC_COUNT=$(echo "$AI_JSON" | jq '.recommendations | length' 2>/dev/null || echo "0") + if [[ "$REC_COUNT" -gt 0 ]] && [[ "$REC_COUNT" != "null" ]]; then + for idx in $(seq 0 $((REC_COUNT - 1))); do + rec=$(echo "$AI_JSON" | jq -r '.recommendations['"$idx"'] // empty' 2>/dev/null) + [[ -n "$rec" ]] && AI_RECOMMENDATIONS+=("$rec") + done + fi + + AI_AVAILABLE=true + log_ok "AI review complete (verdict: $AI_VERDICT)" + else + log_warn "Could not parse AI response JSON, falling back to keyword-based" + fi + fi +fi + +# Fallback: keyword-based heuristics +if [[ "$AI_AVAILABLE" != "true" ]]; then + log_warn "AI not available, falling back to keyword-based analysis" + + SCORE_QUALITY=25 + SCORE_SECURITY=25 + SCORE_PERFORMANCE=25 + SCORE_MAINTAINABILITY=25 + ISSUES_FOUND=() + + if echo "$DIFF_CONTENT" | grep -qiE 'password|secret|token|api_key|apikey|private_key'; then + SCORE_SECURITY=$((SCORE_SECURITY - 15)) + ISSUES_FOUND+=("SECURITY: 检测到可能的硬编码凭证") + fi + if echo "$DIFF_CONTENT" | grep -qiE 'eval\(|exec\(|system\(|shell_exec|os\.system|subprocess\.call'; then + SCORE_SECURITY=$((SCORE_SECURITY - 10)) + ISSUES_FOUND+=("SECURITY: 检测到危险函数调用") + fi + if echo "$DIFF_CONTENT" | grep -qiE 'TODO|FIXME|HACK|XXX'; then + SCORE_QUALITY=$((SCORE_QUALITY - 5)) + ISSUES_FOUND+=("QUALITY: 存在 TODO/FIXME/HACK 注释") + fi + if echo "$DIFF_CONTENT" | grep -qiE 'SELECT \*|\.findAll\(\)|\.all\(\)'; then + SCORE_PERFORMANCE=$((SCORE_PERFORMANCE - 10)) + ISSUES_FOUND+=("PERFORMANCE: 可能的全表查询") + fi + if echo "$DIFF_CONTENT" | grep -qiE 'sleep\(|time\.sleep|Thread\.sleep'; then + SCORE_PERFORMANCE=$((SCORE_PERFORMANCE - 5)) + ISSUES_FOUND+=("PERFORMANCE: 检测到阻塞式 sleep") + fi + if [[ "$FILE_COUNT" -gt 20 ]]; then + SCORE_MAINTAINABILITY=$((SCORE_MAINTAINABILITY - 10)) + ISSUES_FOUND+=("MAINTAINABILITY: 变更文件数量过多 ($FILE_COUNT)") + fi + + TOTAL_SCORE=$((SCORE_QUALITY + SCORE_SECURITY + SCORE_PERFORMANCE + SCORE_MAINTAINABILITY)) + TOTAL_SCORE=$((TOTAL_SCORE < 0 ? 0 : TOTAL_SCORE)) +fi + +# ── 打印审查报告 ────────────────────────────────────────────────── +divider +if [[ "$AI_AVAILABLE" == "true" ]]; then + log_info "AI Review Report for PR #$PR_ID" +else + log_info "Review Report for PR #$PR_ID (keyword-based)" +fi +echo "" +echo " Overall Score: $TOTAL_SCORE / 100" +echo " Code Quality: $SCORE_QUALITY / 25" +echo " Security: $SCORE_SECURITY / 25" +echo " Performance: $SCORE_PERFORMANCE / 25" +echo " Maintainability: $SCORE_MAINTAINABILITY / 25" +echo "" + +if [[ ${#ISSUES_FOUND[@]} -gt 0 ]]; then + echo " Issues Found:" + for issue in "${ISSUES_FOUND[@]}"; do + echo " - $issue" + done + echo "" +fi + +if [[ "${#AI_POSITIVE[@]}" -gt 0 ]]; then + echo " Positive Notes:" + for note in "${AI_POSITIVE[@]}"; do + echo " + $note" + done + echo "" +fi + +if [[ "${#AI_RECOMMENDATIONS[@]}" -gt 0 ]]; then + echo " Recommendations:" + for rec in "${AI_RECOMMENDATIONS[@]}"; do + echo " > $rec" + done + echo "" +fi + +# ── Step 5: 发布审查评论 ────────────────────────────────────────── +if [[ "$AI_AVAILABLE" == "true" ]]; then + REVIEW_HEADER="## AI Code Quality Review - PR #$PR_ID" +else + REVIEW_HEADER="## Code Quality Review - PR #$PR_ID (keyword-based)" +fi + +REVIEW_BODY="$REVIEW_HEADER + +### Scores +| Dimension | Score | Max | +|-----------|-------|-----| +| Code Quality | $SCORE_QUALITY | 25 | +| Security | $SCORE_SECURITY | 25 | +| Performance | $SCORE_PERFORMANCE | 25 | +| Maintainability | $SCORE_MAINTAINABILITY | 25 | +| **Total** | **$TOTAL_SCORE** | **100** | + +### Issues Found" + +if [[ ${#ISSUES_FOUND[@]} -gt 0 ]]; then + for issue in "${ISSUES_FOUND[@]}"; do + REVIEW_BODY+=$'\n'"- $issue" + done +else + REVIEW_BODY+=$'\n'"No issues found." +fi + +if [[ "${#AI_POSITIVE[@]}" -gt 0 ]]; then + REVIEW_BODY+=$'\n'$'\n'"### Positive Notes" + for note in "${AI_POSITIVE[@]}"; do + REVIEW_BODY+=$'\n'"- $note" + done +fi + +if [[ "${#AI_RECOMMENDATIONS[@]}" -gt 0 ]]; then + REVIEW_BODY+=$'\n'$'\n'"### Recommendations" + for rec in "${AI_RECOMMENDATIONS[@]}"; do + REVIEW_BODY+=$'\n'"- $rec" + done +fi + +REVIEW_BODY+=" + +### Verdict +$(if [[ $TOTAL_SCORE -ge $THRESHOLD ]]; then echo "**PASS** - Score $TOTAL_SCORE >= threshold $THRESHOLD. Ready to merge."; else echo "**FAIL** - Score $TOTAL_SCORE < threshold $THRESHOLD. Please address the issues above."; fi) + +--- +*Auto-reviewed by gitlink-cli code-quality-gatekeeper workflow (skill: gitlink-code-review)*" + +log_step "Posting review comment..." +REVIEW_JSON=$(jq -n --arg notes "$REVIEW_BODY" '{notes: $notes}') +REVIEW_RESULT=$(gl_run api POST "/v1/$OWNER/$REPO/issues/$ISSUE_ID/journals" \ + --body "$REVIEW_JSON" 2>&1) || true + +if [[ "$(json_ok "$REVIEW_RESULT")" == "true" ]]; then + log_ok "Review comment posted" +else + log_warn "Review comment may have failed" +fi + +# ── Step 6: 本地 CI (编译 + 测试) ───────────────────────────────── +log_step "Running local CI: build & test..." + +REPOS_DIR="/opt/gitlink-webhook/repos" +REPO_CLONE_DIR="$REPOS_DIR/$REPO" +GIT_URL="https://www.gitlink.org.cn/${OWNER}/${REPO}.git" + +BUILD_LOG=$(mktemp) +TEST_LOG=$(mktemp) +CI_BUILD_PASSED=false +CI_TEST_PASSED=false +CI_PASSED=false + +if [[ -z "$PR_HEAD" ]]; then + log_warn "Cannot determine PR head branch, skipping CI" + CI_BUILD_OUTPUT="CI skipped: unknown PR branch" + CI_TEST_OUTPUT="CI skipped: unknown PR branch" +else + # Clone repo if not exists, otherwise fetch latest + if [[ ! -d "$REPO_CLONE_DIR/.git" ]]; then + mkdir -p "$REPOS_DIR" + log_info "Cloning $GIT_URL..." + git clone "$GIT_URL" "$REPO_CLONE_DIR" 2>&1 || true + fi + + { + cd "$REPO_CLONE_DIR" + log_info "Fetching and checking out branch '$PR_HEAD'..." + git fetch origin 2>&1 || true + git checkout "$PR_HEAD" 2>&1 || true + git pull origin "$PR_HEAD" 2>&1 || true + + log_info "Running: go build ./..." + if go build ./... >> "$BUILD_LOG" 2>&1; then + CI_BUILD_PASSED=true + log_ok "Build passed" + else + log_err "Build failed" + fi + + log_info "Running: go test ./..." + if go test ./... >> "$TEST_LOG" 2>&1; then + CI_TEST_PASSED=true + log_ok "Tests passed" + else + log_err "Tests failed" + fi + } + + CI_BUILD_OUTPUT=$(tail -c 3000 "$BUILD_LOG" 2>/dev/null || echo "no output") + CI_TEST_OUTPUT=$(tail -c 3000 "$TEST_LOG" 2>/dev/null || echo "no output") +fi + +if $CI_BUILD_PASSED && $CI_TEST_PASSED; then + CI_PASSED=true + CI_SUMMARY="| Build | PASSED | | |\n| Tests | PASSED | | |" +elif $CI_BUILD_PASSED && ! $CI_TEST_PASSED; then + CI_SUMMARY="| Build | PASSED | | |\n| Tests | **FAILED** | | |" +elif ! $CI_BUILD_PASSED && $CI_TEST_PASSED; then + CI_SUMMARY="| Build | **FAILED** | | |\n| Tests | PASSED | | |" +else + CI_SUMMARY="| Build | **FAILED** | | |\n| Tests | **FAILED** | | |" +fi + +# Post CI results as a follow-up comment +log_step "Posting CI results..." +CI_BODY="## CI Results - PR #$PR_ID + +### Build & Test +| Stage | Status | +|-------|--------| +$CI_SUMMARY + +### Build Output +\`\`\` +$(echo "$CI_BUILD_OUTPUT" | tail -20) +\`\`\` + +### Test Output +\`\`\` +$(echo "$CI_TEST_OUTPUT" | tail -20) +\`\`\` + +--- +*Local CI executed on server*" + +CI_COMMENT_JSON=$(jq -n --arg notes "$CI_BODY" '{notes: $notes}') +CI_RESULT=$(gl_run api POST "/v1/$OWNER/$REPO/issues/$ISSUE_ID/journals" \ + --body "$CI_COMMENT_JSON" 2>&1) || true + +if [[ "$(json_ok "$CI_RESULT")" == "true" ]]; then + log_ok "CI results posted" +else + log_warn "CI results post may have failed" +fi + +rm -f "$BUILD_LOG" "$TEST_LOG" + +# ── Step 7: 冲突检测 + 自动合并 ─────────────────────────────────── +MERGED=false +MERGE_RESULT_MSG="" +CONFLICT_DETECTED=false + +# Build merge summary: determine why merge can/cannot proceed +MERGE_BLOCK_REASONS=() +if [[ $TOTAL_SCORE -lt $THRESHOLD ]]; then + MERGE_BLOCK_REASONS+=("score $TOTAL_SCORE < threshold $THRESHOLD") +fi +if [[ "$CI_PASSED" != "true" ]]; then + MERGE_BLOCK_REASONS+=("CI not passed") +fi + +if [[ ${#MERGE_BLOCK_REASONS[@]} -gt 0 ]]; then + # Cannot merge: log reasons + BLOCK_LIST=$(printf '; %s' "${MERGE_BLOCK_REASONS[@]}") + BLOCK_LIST="${BLOCK_LIST:2}" + log_warn "Merge blocked: $BLOCK_LIST" +else + log_step "Score $TOTAL_SCORE >= $THRESHOLD and CI passed — checking mergeability..." + + # Check for merge conflicts + if [[ -d "$REPO_CLONE_DIR/.git" ]]; then + log_info "Checking merge conflicts with base branch..." + ( + cd "$REPO_CLONE_DIR" + # Ensure git identity is set for merge test + git config user.email "gatekeeper@gitlink-cli.local" 2>&1 || true + git config user.name "gitlink-gatekeeper" 2>&1 || true + git fetch origin 2>&1 || true + # Determine base branch from PR data + BASE_BRANCH=$(echo "$PR_JSON" | jq -r '.data.pull_request.base // .data.base // "master"') + git checkout "$BASE_BRANCH" 2>&1 || true + git pull origin "$BASE_BRANCH" 2>&1 || true + # Test merge without committing + if git merge --no-commit --no-ff "$PR_HEAD" 2>&1; then + log_ok "No merge conflicts detected" + git merge --abort 2>&1 || true + CONFLICT_DETECTED=false + else + CONFLICT_DETECTED=true + CONFLICT_FILES=$(git diff --name-only --diff-filter=U 2>/dev/null || echo "unknown") + log_err "Merge conflict detected in: $CONFLICT_FILES" + git merge --abort 2>&1 || true + fi + git checkout "$PR_HEAD" 2>&1 || true + ) + else + log_info "Skipping conflict check (repo not cloned)" + fi + + if [[ "$CONFLICT_DETECTED" == "true" ]]; then + MERGE_RESULT_MSG="Merge blocked: conflicts detected" + log_err "$MERGE_RESULT_MSG" + elif [[ "$DRY_RUN" == "true" ]]; then + MERGE_RESULT_MSG="[DRY RUN] Would auto-merge" + log_warn "$MERGE_RESULT_MSG" + else + log_step "Auto-merging PR #$PR_ID..." + MERGE_OUT=$(gl_run pr +merge --owner "$OWNER" --repo "$REPO" --id "$PR_ID" --method merge 2>&1) || true + if [[ "$(json_ok "$MERGE_OUT")" == "true" ]]; then + MERGED=true + MERGE_RESULT_MSG="Merge succeeded" + log_ok "PR #$PR_ID merged successfully!" + else + MERGE_RESULT_MSG="Merge failed: $(json_error "$MERGE_OUT")" + log_err "$MERGE_RESULT_MSG" + fi + fi +fi + +# ── Step 8: 发布最终汇总评论 ────────────────────────────────────── +log_step "Posting final summary..." + +FINAL_BODY="## Gatekeeper Summary - PR #$PR_ID + +| Item | Detail | +|------|--------| +| Score | **$TOTAL_SCORE / 100** (threshold: $THRESHOLD) | +| CI Build | $([[ "$CI_BUILD_PASSED" == "true" ]] && echo "PASSED" || echo "FAILED") | +| CI Test | $([[ "$CI_TEST_PASSED" == "true" ]] && echo "PASSED" || echo "FAILED") | +| Merge Conflict | $([[ "$CONFLICT_DETECTED" == "true" ]] && echo "**DETECTED**" || echo "None") | +| Result | $([[ "$MERGED" == "true" ]] && echo "**MERGED**" || echo "$MERGE_RESULT_MSG") | +" + +if [[ ${#MERGE_BLOCK_REASONS[@]} -gt 0 ]]; then + FINAL_BODY+=" +### Blocked Reasons +" + for reason in "${MERGE_BLOCK_REASONS[@]}"; do + FINAL_BODY+="- $reason +" + done +fi + +if [[ "$CONFLICT_DETECTED" == "true" ]]; then + FINAL_BODY+=" +### Conflict Files +\`\`\` +$CONFLICT_FILES +\`\`\` +" +fi + +FINAL_BODY+=" +--- +*Gatekeeper workflow completed at $(date '+%Y-%m-%d %H:%M:%S')*" + +FINAL_JSON=$(jq -n --arg notes "$FINAL_BODY" '{notes: $notes}') +FINAL_RESULT=$(gl_run api POST "/v1/$OWNER/$REPO/issues/$ISSUE_ID/journals" \ + --body "$FINAL_JSON" 2>&1) || true + +if [[ "$(json_ok "$FINAL_RESULT")" == "true" ]]; then + log_ok "Final summary posted" +else + log_warn "Final summary post may have failed" +fi + +# ── Complete ────────────────────────────────────────────────────── +log_title "Gatekeeper Complete" +echo -e "${GREEN} PR: #$PR_ID - $PR_TITLE${NC}" +echo -e "${GREEN} Author: @$PR_AUTHOR${NC}" +echo -e "${GREEN} Score: $TOTAL_SCORE / 100${NC}" +echo -e "${GREEN} CI Passed: $CI_PASSED${NC}" +echo -e "${GREEN} Conflict: $CONFLICT_DETECTED${NC}" +echo -e "${GREEN} Merged: $( [[ "$MERGED" == "true" ]] && echo 'YES' || echo 'NO')${NC}" diff --git a/workflows/03-project-init.ps1 b/workflows/03-project-init.ps1 new file mode 100644 index 0000000..ff998cb --- /dev/null +++ b/workflows/03-project-init.ps1 @@ -0,0 +1,542 @@ +# ---------------------------------------------------------------- +# Scenario 3: One-Click Project Initialization +# Flow: Input description -> Create repo -> Generate files (README/LICENSE/ +# CONTRIBUTING/CI) -> Push to repo -> Milestones -> Issues -> +# Branch protection -> Initial Release +# +# Commands chained: +# 1. repo +create -- create repository +# 2. git clone -- clone the empty repo locally +# 3. file generation -- README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml +# 4. git add/commit/push -- push scaffold files to repo +# 5. milestone +create -- create project milestones +# 6. issue +create -- create initial issues +# 7. branch +protect -- protect default branch +# 8. release +create -- create initial release +# ---------------------------------------------------------------- +#Requires -Version 5.1 + +param( + [string]$Owner = "", + [string]$Name = "", + [string]$Description = "", + [string]$Lang = "go", + [switch]$Private, + [switch]$DryRun, + [switch]$Help +) + +$ErrorActionPreference = "Stop" +Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue + +if ($Help) { + Write-Host "Usage: powershell 03-project-init.ps1 -Owner OWNER -Name REPO_NAME -Description DESC [-Lang go|python|node|java] [-Private] [-DryRun]" + exit 0 +} + +if (-not $Name) { Log-Err "Name is required (use -Help for usage)"; exit 1 } +if (-not $Description) { Log-Err "Description is required (use -Help for usage)"; exit 1 } + +Check-Auth +if (-not $Owner) { + $detected = Detect-OwnerRepo + $Owner = $detected.Owner +} + +# ---------------------------------------------------------------- +Log-Title "Project Initialization: $Owner/$Name" +# ---------------------------------------------------------------- +Write-Host " Owner: $Owner" +Write-Host " Name: $Name" +Write-Host " Description: $Description" +Write-Host " Language: $Lang" +Write-Host " Private: $($Private.IsPresent)" +Divider + +# -- Step 1: Create Repository -- +Log-Step "Step 1/8: Creating repository..." +if ($DryRun) { + Log-Warn "[DRY RUN] Would create repository: $Owner/$Name" +} else { + $privateStr = if ($Private) { "true" } else { "false" } + $repoResult = Invoke-GLCheck "repo", "+create", "--owner", $Owner, "--name", $Name, "--description", $Description, "--private", $privateStr + if ($repoResult) { + Log-Ok "Repository created: $Owner/$Name" + } else { + Log-Err "Repository creation failed" + exit 1 + } +} + +# -- Step 2: Clone empty repo -- +Log-Step "Step 2/8: Cloning repository..." +$repoUrl = "https://gitlink.org.cn/$Owner/$Name.git" +if ($env:GITLINK_TOKEN) { + $cloneUrl = "https://oauth2:$($env:GITLINK_TOKEN)@gitlink.org.cn/$Owner/$Name.git" +} else { + $cloneUrl = $repoUrl +} +$workDir = Join-Path $env:TEMP "gitlink-init-$([System.Guid]::NewGuid().ToString('N').Substring(0, 8))" + +if ($DryRun) { + Log-Warn "[DRY RUN] Would clone: $repoUrl -> $workDir" +} else { + git clone $cloneUrl $workDir 2>&1 | Select-Object -Last 1 + Log-Ok "Cloned to $workDir" +} + +# -- Step 3: Generate project files -- +Log-Step "Step 3/8: Generating project scaffold files..." + +function Get-ReadmeContent { + $langSection = switch ($Lang) { + "go" { +@" +### 环境要求 + +- Go 1.21+ +- Git + +### 安装 + +```bash +git clone https://gitlink.org.cn/$Owner/$Name.git +cd $Name +go mod download +go build ./... +``` + +### 使用 + +```bash +go run main.go +``` + +### 测试 + +```bash +go test ./... +``` +"@ + } + "python" { +@" +### 环境要求 + +- Python 3.9+ +- pip + +### 安装 + +```bash +git clone https://gitlink.org.cn/$Owner/$Name.git +cd $Name +pip install -r requirements.txt +``` + +### 使用 + +```bash +python main.py +``` + +### 测试 + +```bash +pytest +``` +"@ + } + "node" { +@" +### 环境要求 + +- Node.js 18+ +- npm or yarn + +### 安装 + +```bash +git clone https://gitlink.org.cn/$Owner/$Name.git +cd $Name +npm install +``` + +### 使用 + +```bash +npm start +``` + +### 测试 + +```bash +npm test +``` +"@ + } + "java" { +@" +### 环境要求 + +- JDK 17+ +- Maven 3.8+ + +### 安装 + +```bash +git clone https://gitlink.org.cn/$Owner/$Name.git +cd $Name +mvn clean install +``` + +### 使用 + +```bash +mvn exec:java +``` + +### 测试 + +```bash +mvn test +``` +"@ + } + default { "" } + } + + return @" +# $Name + +$Description + +## 快速开始 + +$langSection + +## 贡献指南 + +详见 [CONTRIBUTING](./CONTRIBUTING.md)。 + +## 许可证 + +本项目基于 MIT 许可证开源 — 详见 [LICENSE](./LICENSE)。 +"@ +} + +function Get-LicenseContent { + $year = (Get-Date).Year + return @" +MIT License + +Copyright (c) $year $Owner + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +"@ +} + +function Get-ContributingContent { + return @" +# Contributing to $Name + +Thank you for your interest in contributing! + +## How to Contribute + +1. Fork the repository +2. Create a feature branch: `git checkout -b feature/my-feature` +3. Make your changes +4. Run tests to ensure everything passes +5. Commit your changes: `git commit -m 'feat: add my feature'` +6. Push to your fork: `git push origin feature/my-feature` +7. Create a Pull Request + +## Code Style + +- Follow the existing code style +- Write meaningful commit messages following [Conventional Commits](https://www.conventionalcommits.org/) +- Add tests for new features +- Update documentation as needed + +## Reporting Issues + +- Use the [issue tracker](https://gitlink.org.cn/$Owner/$Name/issues) +- Include steps to reproduce +- Include environment details (OS, language version, etc.) + +## Code of Conduct + +Please be respectful and constructive in all interactions. +"@ +} + +function Get-CIConfigContent { + switch ($Lang) { + "go" { + return @" +image: golang:1.21 + +stages: + - test + - build + +test: + stage: test + script: + - go mod download + - go test ./... -v -cover + +build: + stage: build + script: + - go build ./... +"@ + } + "python" { + return @" +image: python:3.9 + +stages: + - test + - lint + +test: + stage: test + script: + - pip install -r requirements.txt + - pytest -v --cov + +lint: + stage: lint + script: + - pip install flake8 + - flake8 . +"@ + } + "node" { + return @" +image: node:18 + +stages: + - test + - build + +test: + stage: test + script: + - npm ci + - npm test -- --coverage + +build: + stage: build + script: + - npm ci + - npm run build +"@ + } + "java" { + return @" +image: maven:3.8-openjdk-17 + +stages: + - test + - build + +test: + stage: test + script: + - mvn test + +build: + stage: build + script: + - mvn package -DskipTests +"@ + } + default { + return "# TODO: configure CI/CD pipeline for $Lang" + } + } +} + +if ($DryRun) { + Log-Warn "[DRY RUN] Would generate: README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml" +} else { + $utf8 = [System.Text.UTF8Encoding]::new($false) + [System.IO.File]::WriteAllText("$workDir\README.md", (Get-ReadmeContent), $utf8) + [System.IO.File]::WriteAllText("$workDir\LICENSE", (Get-LicenseContent), $utf8) + [System.IO.File]::WriteAllText("$workDir\CONTRIBUTING.md", (Get-ContributingContent), $utf8) + [System.IO.File]::WriteAllText("$workDir\.gitlink-ci.yml", (Get-CIConfigContent), $utf8) + Log-Ok "Generated: README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml" +} + +# -- Step 4: Push files to repo -- +Log-Step "Step 4/8: Pushing scaffold files to repository..." +if ($DryRun) { + Log-Warn "[DRY RUN] Would git add/commit/push to $repoUrl" +} else { + Push-Location $workDir + try { + $commitMsg = "chore: initialize project scaffold`n`n- Add README.md with getting-started guide`n- Add MIT LICENSE`n- Add CONTRIBUTING.md with guidelines`n- Add .gitlink-ci.yml CI pipeline configuration" + git add README.md LICENSE CONTRIBUTING.md .gitlink-ci.yml 2>&1 | Out-Null + git -c user.name="$Owner" -c user.email="$Owner@gitlink.org.cn" commit -m $commitMsg 2>&1 | Select-Object -Last 1 + git push -u origin master 2>&1 | Select-Object -Last 1 + Log-Ok "Scaffold files pushed to master" + } finally { + Pop-Location + } +} + +# -- Step 5: Create Milestones -- +Log-Step "Step 5/8: Creating initial milestone..." + +$milestones = @( + @{ Name = "v1.0.0 - First Release"; Description = "First official release milestone" } +) + +if ($DryRun) { + Log-Warn "[DRY RUN] Would create $($milestones.Count) milestones" +} else { + foreach ($ms in $milestones) { + $msResult = Invoke-GL "milestone", "+create", "--owner", $Owner, "--repo", $Name, "--name", $ms.Name, "--description", $ms.Description + if ($msResult) { + try { + $msJson = $msResult | ConvertFrom-Json + if ($msJson.ok) { + Log-Ok "Milestone created: $($ms.Name)" + } else { + Log-Warn "Milestone creation failed: $($ms.Name)" + } + } catch { + Log-Warn "Milestone creation may have failed: $($ms.Name)" + } + } + } +} + +# -- Step 6: Create Initial Issues -- +Log-Step "Step 6/8: Creating welcome issue..." + +$issuesToCreate = @( + @{ Title = "Welcome to $Name"; Body = "欢迎来到 **${Name}** 的仓库,这是本仓库的第一条issue!!"; Label = "welcome" } +) + +if ($DryRun) { + Log-Warn "[DRY RUN] Would create $($issuesToCreate.Count) initial issues" +} else { + foreach ($entry in $issuesToCreate) { + $issueResult = Invoke-GL "issue", "+create", "--owner", $Owner, "--repo", $Name, "--title", $entry.Title, "--body", $entry.Body + if ($issueResult) { + try { + $issueJson = $issueResult | ConvertFrom-Json + $issueNum = if ($issueJson.ok -and $issueJson.data.id) { $issueJson.data.id } elseif ($issueJson.ok -and $issueJson.data.number) { $issueJson.data.number } else { $null } + if ($issueNum) { + Invoke-GL "issue", "+label-add", "--owner", $Owner, "--repo", $Name, "--number", $issueNum, "--labels", $entry.Label | Out-Null + Log-Ok "Issue created: #$issueNum - $($entry.Title)" + } + } catch { + Log-Warn "Issue creation may have failed: $($entry.Title)" + } + } + } +} + +# -- Step 7: Protect Default Branch -- +Log-Step "Step 7/8: Protecting master branch..." +if ($DryRun) { + Log-Warn "[DRY RUN] Would protect branch 'master'" +} else { + $protectResult = Invoke-GL "branch", "+protect", "--owner", $Owner, "--repo", $Name, "--name", "master" + if ($protectResult) { + try { + $protectJson = $protectResult | ConvertFrom-Json + if ($protectJson.ok) { + Log-Ok "Branch 'master' protected" + } else { + Log-Warn "Branch protection may have failed (may require admin permissions)" + } + } catch { + Log-Warn "Branch protection may have failed (may require admin permissions)" + } + } +} + +# -- Step 8: Create Initial Release -- +Log-Step "Step 8/8: Creating initial release v0.1.0..." + +$releaseBody = @" +# v0.1.0 - Initial Release + +## What's New +- Project initialized with $Lang template +- README, LICENSE, CONTRIBUTING guide in repository +- CI/CD pipeline configuration (`.gitlink-ci.yml`) +- $($milestones.Count) project milestone created +- $($issuesToCreate.Count) initial issues filed +- Branch protection enabled + +## Next Steps +- [ ] Setup CI/CD pipeline +- [ ] Write comprehensive tests +- [ ] Complete documentation +- [ ] First feature implementation + +--- +*Auto-initialized by gitlink-cli project-init workflow* +"@ + +if ($DryRun) { + Log-Warn "[DRY RUN] Would create release v0.1.0" +} else { + $releaseResult = Invoke-GL "release", "+create", "--owner", $Owner, "--repo", $Name, "--tag", "v0.1.0", "--name", "Initial Release", "--body", $releaseBody + if ($releaseResult) { + try { + $releaseJson = $releaseResult | ConvertFrom-Json + if ($releaseJson.ok) { + Log-Ok "Release v0.1.0 created" + } else { + Log-Warn "Release creation may have failed" + } + } catch { + Log-Warn "Release creation may have failed" + } + } +} + +# ---------------------------------------------------------------- +Log-Title "Project Initialization Complete" +# ---------------------------------------------------------------- + +Write-Host " Repository: $Owner/$Name" -ForegroundColor Green +Write-Host " Files: README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml" -ForegroundColor Green +Write-Host " Milestones: $($milestones.Count) milestone" -ForegroundColor Green +Write-Host " Issues: $($issuesToCreate.Count) initial issues" -ForegroundColor Green +Write-Host " Branch: master (protected)" -ForegroundColor Green +Write-Host " Release: v0.1.0" -ForegroundColor Green +Write-Host "" +Write-Host "Next steps:" -ForegroundColor Cyan +Write-Host " 1. Clone: git clone $repoUrl" +Write-Host " 2. Start coding and close the first issue" +Write-Host " 3. CI/CD will trigger on your first push" + +# Cleanup temp directory +if (-not $DryRun -and (Test-Path $workDir)) { + Remove-Item -Recurse -Force $workDir -ErrorAction SilentlyContinue +} diff --git a/workflows/03-project-init.sh b/workflows/03-project-init.sh new file mode 100644 index 0000000..acbbf97 --- /dev/null +++ b/workflows/03-project-init.sh @@ -0,0 +1,524 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────── +# Scenario 3: One-Click Project Initialization +# Flow: Input description → Create repo → Generate files (README/LICENSE/ +# CONTRIBUTING/CI) → Push to repo → Milestones → Issues → +# Branch protection → Initial Release +# +# Commands chained: +# 1. repo +create -- create repository +# 2. git clone -- clone the empty repo locally +# 3. file generation -- README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml +# 4. git add/commit/push -- push scaffold files to repo +# 5. milestone +create -- create project milestones +# 6. issue +create -- create initial issues +# 7. branch +protect -- protect default branch +# 8. release +create -- create initial release +# ───────────────────────────────────────────────────────────────────── + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" + +usage() { + echo "Usage: $0 --owner OWNER --name REPO_NAME --description DESC [--lang LANG] [--private] [--dry-run]" + echo "" + echo " --owner OWNER Repository owner (org or user)" + echo " --name REPO_NAME Repository name" + echo " --description DESC Repository description" + echo " --lang LANG Primary language: go|python|node|java (default: go)" + echo " --private Make repository private" + echo " --dry-run Preview actions without executing" + exit 1 +} + +PROJ_LANG="go" +DRY_RUN=false +OWNER="" +REPO_NAME="" +DESCRIPTION="" +PRIVATE="false" + +while [[ $# -gt 0 ]]; do + case "$1" in + --owner) OWNER="$2"; shift 2 ;; + --name) REPO_NAME="$2"; shift 2 ;; + --description) DESCRIPTION="$2"; shift 2 ;; + --lang) PROJ_LANG="$2"; shift 2 ;; + --private) PRIVATE="true"; shift ;; + --dry-run) DRY_RUN="true"; shift ;; + --help|-h) usage ;; + *) log_err "Unknown arg: $1"; usage ;; + esac +done + +if [[ -z "$OWNER" || -z "$REPO_NAME" || -z "$DESCRIPTION" ]]; then + log_err "Missing required parameters: --owner, --name, --description" + usage +fi + +check_auth + +# ───────────────────────────────────────────────────────────────────── +log_title "Project Initialization: $OWNER/$REPO_NAME" +# ───────────────────────────────────────────────────────────────────── +echo " Owner: $OWNER" +echo " Name: $REPO_NAME" +echo " Description: $DESCRIPTION" +echo " Language: $PROJ_LANG" +echo " Private: $PRIVATE" +divider + +# ── Step 1: Create Repository ──────────────────────────────────────── +log_step "Step 1/8: Creating repository..." +if [[ "$DRY_RUN" == "true" ]]; then + log_warn "[DRY RUN] Would create repository: $OWNER/$REPO_NAME" +else + REPO_RESULT=$(gl_check repo +create --owner "$OWNER" --name "$REPO_NAME" --description "$DESCRIPTION" --private "$PRIVATE") + REPO_ID=$(echo "$REPO_RESULT" | jq -r '.data.id // .data.project_id // empty') + log_ok "Repository created: $OWNER/$REPO_NAME (id: $REPO_ID)" +fi + +# ── Step 2: Clone empty repo ───────────────────────────────────────── +log_step "Step 2/8: Cloning repository..." +# Build auth URL using token so clone/push don't prompt for password +if [[ -n "${GITLINK_TOKEN:-}" ]]; then + CLONE_URL="https://oauth2:${GITLINK_TOKEN}@gitlink.org.cn/$OWNER/$REPO_NAME.git" +else + CLONE_URL="https://gitlink.org.cn/$OWNER/$REPO_NAME.git" +fi +REPO_URL="https://gitlink.org.cn/$OWNER/$REPO_NAME.git" +WORK_DIR=$(mktemp -d /tmp/gitlink-init-XXXXXX) +cleanup() { rm -rf "$WORK_DIR"; } +trap cleanup EXIT + +if [[ "$DRY_RUN" == "true" ]]; then + log_warn "[DRY RUN] Would clone: $REPO_URL → $WORK_DIR" +else + git clone "$CLONE_URL" "$WORK_DIR" 2>&1 | tail -1 || true + log_ok "Cloned to $WORK_DIR" +fi + +# ── Step 3: Generate project files ─────────────────────────────────── +log_step "Step 3/8: Generating project scaffold files..." + +# --- README.md --- +gen_readme() { + local lang_section + case "$PROJ_LANG" in + go) + lang_section="### 环境要求 + +- Go 1.21+ +- Git + +### 安装 + +\`\`\`bash +git clone https://gitlink.org.cn/$OWNER/$REPO_NAME.git +cd $REPO_NAME +go mod download +go build ./... +\`\`\` + +### 使用 + +\`\`\`bash +go run main.go +\`\`\` + +### 测试 + +\`\`\`bash +go test ./... +\`\`\`" + ;; + python) + lang_section="### 环境要求 + +- Python 3.9+ +- pip + +### 安装 + +\`\`\`bash +git clone https://gitlink.org.cn/$OWNER/$REPO_NAME.git +cd $REPO_NAME +pip install -r requirements.txt +\`\`\` + +### 使用 + +\`\`\`bash +python main.py +\`\`\` + +### 测试 + +\`\`\`bash +pytest +\`\`\`" + ;; + node) + lang_section="### 环境要求 + +- Node.js 18+ +- npm or yarn + +### 安装 + +\`\`\`bash +git clone https://gitlink.org.cn/$OWNER/$REPO_NAME.git +cd $REPO_NAME +npm install +\`\`\` + +### 使用 + +\`\`\`bash +npm start +\`\`\` + +### 测试 + +\`\`\`bash +npm test +\`\`\`" + ;; + java) + lang_section="### 环境要求 + +- JDK 17+ +- Maven 3.8+ + +### 安装 + +\`\`\`bash +git clone https://gitlink.org.cn/$OWNER/$REPO_NAME.git +cd $REPO_NAME +mvn clean install +\`\`\` + +### 使用 + +\`\`\`bash +mvn exec:java +\`\`\` + +### 测试 + +\`\`\`bash +mvn test +\`\`\`" + ;; + esac + + cat < "$WORK_DIR/README.md" + gen_license > "$WORK_DIR/LICENSE" + gen_contributing > "$WORK_DIR/CONTRIBUTING.md" + gen_ci_config > "$WORK_DIR/.gitlink-ci.yml" + log_ok "Generated: README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml" +fi + +# ── Step 4: Push files to repo ─────────────────────────────────────── +log_step "Step 4/8: Pushing scaffold files to repository..." +if [[ "$DRY_RUN" == "true" ]]; then + log_warn "[DRY RUN] Would git add/commit/push to $REPO_URL" +else + pushd "$WORK_DIR" > /dev/null + git add README.md LICENSE CONTRIBUTING.md .gitlink-ci.yml + git -c user.name="$OWNER" -c user.email="$OWNER@gitlink.org.cn" \ + commit -m "chore: initialize project scaffold + +- Add README.md with getting-started guide +- Add MIT LICENSE +- Add CONTRIBUTING.md with guidelines +- Add .gitlink-ci.yml CI pipeline configuration" 2>&1 | tail -1 + git push -u origin master 2>&1 | tail -1 + popd > /dev/null + log_ok "Scaffold files pushed to master" +fi + +# ── Step 5: Create Milestone ───────────────────────────────────────── +log_step "Step 5/8: Creating initial milestone..." + +MILESTONES=( + "v1.0.0 - First Release|First official release milestone" +) + +if [[ "$DRY_RUN" == "true" ]]; then + log_warn "[DRY RUN] Would create ${#MILESTONES[@]} milestone" +else + for entry in "${MILESTONES[@]}"; do + IFS='|' read -r ms_title ms_desc <<< "$entry" + MS_RESULT=$(gl_run milestone +create --owner "$OWNER" --repo "$REPO_NAME" \ + --name "$ms_title" --description "$ms_desc" 2>&1) || true + if [[ "$(json_ok "$MS_RESULT")" == "true" ]]; then + log_ok "Milestone created: $ms_title" + else + log_warn "Milestone creation may have failed: $ms_title" + fi + done +fi + +# ── Step 6: Create Welcome Issue ───────────────────────────────────── +log_step "Step 6/8: Creating welcome issue..." + +ISSUES_TO_CREATE=( + "Welcome to $REPO_NAME|欢迎来到 **${REPO_NAME}** 的仓库,这是本仓库的第一条issue!!|welcome" +) + +if [[ "$DRY_RUN" == "true" ]]; then + log_warn "[DRY RUN] Would create ${#ISSUES_TO_CREATE[@]} initial issues" +else + for entry in "${ISSUES_TO_CREATE[@]}"; do + IFS='|' read -r title body label <<< "$entry" + ISSUE_RESULT=$(gl_run issue +create --owner "$OWNER" --repo "$REPO_NAME" \ + --title "$title" --body "$body" 2>&1) || true + ISSUE_NUM=$(echo "$ISSUE_RESULT" | jq -r '.data.id // .data.number // empty') + if [[ -n "$ISSUE_NUM" ]]; then + gl_run issue +label-add --owner "$OWNER" --repo "$REPO_NAME" --number "$ISSUE_NUM" --labels "$label" > /dev/null 2>&1 || true + log_ok "Issue created: #$ISSUE_NUM - $title" + else + log_warn "Issue creation may have failed: $title" + fi + done +fi + +# ── Step 7: Protect Default Branch ─────────────────────────────────── +log_step "Step 7/8: Protecting master branch..." +if [[ "$DRY_RUN" == "true" ]]; then + log_warn "[DRY RUN] Would protect branch 'master'" +else + PROTECT_RESULT=$(gl_run branch +protect --owner "$OWNER" --repo "$REPO_NAME" --name master 2>&1) || true + if [[ "$(json_ok "$PROTECT_RESULT")" == "true" ]]; then + log_ok "Branch 'master' protected" + else + log_warn "Branch protection may have failed (may require admin permissions)" + fi +fi + +# ── Step 8: Create Initial Release ─────────────────────────────────── +log_step "Step 8/8: Creating initial release v0.1.0..." + +RELEASE_BODY="# v0.1.0 - Initial Release + +## What's New +- Project initialized with $PROJ_LANG template +- README, LICENSE, CONTRIBUTING guide in repository +- CI/CD pipeline configuration (.gitlink-ci.yml) +- ${#MILESTONES[@]} project milestone created +- ${#ISSUES_TO_CREATE[@]} initial issues filed +- Branch protection enabled + +## Next Steps +- [ ] Setup CI/CD pipeline +- [ ] Write comprehensive tests +- [ ] Complete documentation +- [ ] First feature implementation + +--- +*Auto-initialized by gitlink-cli project-init workflow*" + +if [[ "$DRY_RUN" == "true" ]]; then + log_warn "[DRY RUN] Would create release v0.1.0" +else + RELEASE_RESULT=$(gl_run release +create --owner "$OWNER" --repo "$REPO_NAME" \ + --tag "v0.1.0" --name "Initial Release" --body "$RELEASE_BODY" 2>&1) || true + if [[ "$(json_ok "$RELEASE_RESULT")" == "true" ]]; then + log_ok "Release v0.1.0 created" + else + log_warn "Release creation may have failed" + fi +fi + +# ───────────────────────────────────────────────────────────────────── +log_title "Project Initialization Complete" +# ───────────────────────────────────────────────────────────────────── + +echo -e "${GREEN}Created:${NC}" +echo " Repository: $OWNER/$REPO_NAME" +echo " Files: README.md, LICENSE, CONTRIBUTING.md, .gitlink-ci.yml" +echo " Milestones: ${#MILESTONES[@]} milestone" +echo " Issues: ${#ISSUES_TO_CREATE[@]} initial issues" +echo " Branch: master (protected)" +echo " Release: v0.1.0" +echo "" +echo -e "${CYAN}Next steps:${NC}" +echo " 1. Clone: git clone $REPO_URL" +echo " 2. Start coding and close the first issue" +echo " 3. CI/CD will trigger on your first push" +echo "" diff --git a/workflows/04-multi-repo-collab.ps1 b/workflows/04-multi-repo-collab.ps1 new file mode 100644 index 0000000..6e41b9b --- /dev/null +++ b/workflows/04-multi-repo-collab.ps1 @@ -0,0 +1,359 @@ +# ---------------------------------------------------------------- +# Scenario 4: Multi-Repo Collaboration +# Flow: Cross-repo issue tracking -> PR status dashboard -> Coordinated release +# +# Commands chained: +# 1. repo +list -- list all repos in org +# 2. issue +list -- fetch open/closed issues from each repo +# 3. pr +list -- fetch open/merged PRs from each repo +# 4. release +list -- check latest release across repos +# 5. Generate HTML dashboard (summary cards + table + per-repo details) +# 6. release +create -- (optional) coordinated release across all repos +# ---------------------------------------------------------------- +#Requires -Version 5.1 + +param( + [string]$Org = "", + [string]$Repos = "", + [string]$Release = "", + [string]$Output = "dashboard.html", + [int]$DetailLimit = 10, + [switch]$DryRun, + [switch]$Help +) + +$ErrorActionPreference = "Stop" +Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue + +if ($Help) { + Write-Host "Usage: powershell 04-multi-repo-collab.ps1 -Org ORG [-Repos 'repo1,repo2'] [-Release TAG] [-Output FILE] [-DetailLimit N] [-DryRun]" + exit 0 +} + +if (-not $Org) { Log-Err "Org is required (use -Help for usage)"; exit 1 } + +Check-Auth + +# -- Helpers -- +function Html-Escape([string]$s) { + if ($null -eq $s) { return "" } + $s = $s -replace '&', '&' + $s = $s -replace '<', '<' + $s = $s -replace '>', '>' + $s = $s -replace '"', '"' + return $s +} + +# Health score (0-100) aligned with gitlink-health skill. +# Core 75 (issue efficiency / PR efficiency / activity, -25 each), +# auxiliary 25 (issue backlog -10, PR backlog -10, no recent release -5). +# releaseAge: days since latest release, -1 = none. +function Compute-Health([int]$oi, [int]$ci, [int]$op, [int]$mp, [int]$age) { + $health = 100 + # 核心指标 (75) + $ti = $oi + $ci + $res = if ($ti -gt 0) { $ci / $ti } else { 1 } + if ($res -lt 0.5) { $health -= 25 } # Issue 处理效率 (响应慢代理) + $tp = $op + $mp + $mr = if ($tp -gt 0) { $mp / $tp } else { 1 } + if ($mr -lt 0.5) { $health -= 25 } # PR 合并效率 (合并慢代理) + if (($mp + $ci) -eq 0) { $health -= 25 } # 活跃度低: 无已完成工作 + # 辅助指标 (25) + if ($oi -gt 20) { $health -= 10 } # Issue 积压 + if ($op -gt 10) { $health -= 10 } # PR 积压 + if ($age -lt 0 -or $age -gt 30) { $health -= 5 } # 无近期发布 + if ($health -lt 0) { $health = 0 } + if ($health -gt 100) { $health = 100 } + return [int]$health +} + +# Map score -> @{ Label; Class } (5 bands per gitlink-health skill) +function Get-HealthGrade([int]$score) { + if ($score -ge 90) { return @{ Label = "优秀"; Class = "excellent" } } + elseif ($score -ge 70) { return @{ Label = "良好"; Class = "good" } } + elseif ($score -ge 50) { return @{ Label = "一般"; Class = "moderate" } } + elseif ($score -ge 30) { return @{ Label = "需关注"; Class = "attention" } } + else { return @{ Label = "严重"; Class = "critical" } } +} + +# -- Step 1: List Repositories -- +Log-Title "多仓库协同看板 (Multi-Repo Collaboration Dashboard)" + +Log-Step "获取组织 $Org 下的仓库列表..." +$reposJson = Invoke-GLCheck "repo", "+list", "--user", $Org, "--limit", "100" +if (-not $reposJson) { Log-Err "获取仓库列表失败"; exit 1 } + +$allRepos = @() +$rd = $reposJson.data +if ($rd.projects) { $allRepos = @($rd.projects) } +elseif ($rd -is [array]) { $allRepos = $rd } + +Log-Ok "组织下共有 $($allRepos.Count) 个仓库" + +$repoList = @() +if ($Repos) { + $repoList = $Repos -split ',' + Log-Info "仅处理指定仓库: $($repoList -join ', ')" +} else { + foreach ($r in $allRepos) { + $rname = if ($r.name) { $r.name } elseif ($r.identifier) { $r.identifier } else { $null } + if ($rname) { $repoList += $rname } + } +} + +if ($repoList.Count -eq 0) { Log-Err "没有可处理的仓库。请检查 -Org 或用 -Repos 指定。"; exit 1 } +Log-Ok "将处理 $($repoList.Count) 个仓库" + +# -- Step 2-4: Collect data across repos -- +Log-Title "跨仓库数据采集" + +$totalActivity = 0; $totalOpenIssues = 0; $totalOpenPRs = 0; $totalMergedPRs = 0; $healthSum = 0 +$dashboardRows = "" +$repoDetails = "" + +foreach ($repo in $repoList) { + Divider + Log-Step "处理 $Org/$repo..." + + # Open issues + $issuesObj = $null; $openIssues = 0; $openIssueItems = @() + $issuesRaw = Invoke-GL "issue", "+list", "--owner", $Org, "--repo", $repo, "--state", "open", "--limit", "50" + if ($issuesRaw) { try { + $issuesObj = $issuesRaw | ConvertFrom-Json + if ($issuesObj.ok -and $issuesObj.data.issues) { $openIssueItems = @($issuesObj.data.issues); $openIssues = $openIssueItems.Count } + } catch {} } + + # Closed issues + $closedIssues = 0 + $closedRaw = Invoke-GL "issue", "+list", "--owner", $Org, "--repo", $repo, "--state", "closed", "--limit", "50" + if ($closedRaw) { try { + $closedObj = $closedRaw | ConvertFrom-Json + if ($closedObj.ok -and $closedObj.data.issues) { $closedIssues = @($closedObj.data.issues).Count } + } catch {} } + + # Open PRs + $openPRs = 0; $openPRItems = @() + $prsRaw = Invoke-GL "pr", "+list", "--owner", $Org, "--repo", $repo, "--state", "open", "--limit", "50" + if ($prsRaw) { try { + $prsObj = $prsRaw | ConvertFrom-Json + if ($prsObj.ok) { + if ($prsObj.data.issues) { $openPRItems = @($prsObj.data.issues) } + elseif ($prsObj.data.pulls) { $openPRItems = @($prsObj.data.pulls) } + elseif ($prsObj.data -is [array]) { $openPRItems = @($prsObj.data) } + $openPRs = $openPRItems.Count + } + } catch {} } + + # Merged PRs + $mergedPRs = 0 + $mergedRaw = Invoke-GL "pr", "+list", "--owner", $Org, "--repo", $repo, "--state", "merged", "--limit", "50" + if ($mergedRaw) { try { + $mergedObj = $mergedRaw | ConvertFrom-Json + if ($mergedObj.ok) { + if ($mergedObj.data.issues) { $mergedPRs = @($mergedObj.data.issues).Count } + elseif ($mergedObj.data.pulls) { $mergedPRs = @($mergedObj.data.pulls).Count } + elseif ($mergedObj.data -is [array]) { $mergedPRs = @($mergedObj.data).Count } + } + } catch {} } + + # Latest release + age + $latestRelease = "none"; $releaseAge = -1 + $releaseRaw = Invoke-GL "release", "+list", "--owner", $Org, "--repo", $repo, "--limit", "1" + if ($releaseRaw) { try { + $releaseObj = $releaseRaw | ConvertFrom-Json + $rels = $null + if ($releaseObj.ok -and $releaseObj.data.releases) { $rels = @($releaseObj.data.releases) } + elseif ($releaseObj.ok -and $releaseObj.data -is [array]) { $rels = @($releaseObj.data) } + if ($rels -and $rels.Count -gt 0) { + $latestRelease = if ($rels[0].tag_name) { $rels[0].tag_name } elseif ($rels[0].name) { $rels[0].name } else { "none" } + $relDate = if ($rels[0].created_at) { $rels[0].created_at } elseif ($rels[0].created_on) { $rels[0].created_on } elseif ($rels[0].published_at) { $rels[0].published_at } else { $null } + if ($relDate) { + try { $releaseAge = [int]((Get-Date) - [datetime]$relDate).TotalDays } catch { $releaseAge = 0 } + } else { $releaseAge = 0 } + } + } catch {} } + + $health = Compute-Health $openIssues $closedIssues $openPRs $mergedPRs $releaseAge + $grade = Get-HealthGrade $health + + Log-Ok "$repo : Issue(开:$openIssues 闭:$closedIssues) PR(开:$openPRs 合:$mergedPRs) Release:$latestRelease 健康度:$health($($grade.Label))" + + # Open issue detail rows + $issueRows = "" + $limit = [Math]::Min($openIssues, $DetailLimit) + for ($i = 0; $i -lt $limit; $i++) { + $it = $openIssueItems[$i] + $iid = if ($it.id) { $it.id } elseif ($it.number) { $it.number } else { "" } + $ititle = if ($it.name) { $it.name } elseif ($it.subject) { $it.subject } elseif ($it.title) { $it.title } else { "" } + $iauthor = if ($it.author_name) { $it.author_name } elseif ($it.author_login) { $it.author_login } elseif ($it.author.login) { $it.author.login } else { "unknown" } + $issueRows += "#$(Html-Escape ([string]$iid))$(Html-Escape ([string]$ititle))@$(Html-Escape ([string]$iauthor))" + } + if (-not $issueRows) { $issueRows = '无未关闭 Issue' } + + # Open PR detail rows + $prRows = "" + $limit = [Math]::Min($openPRs, $DetailLimit) + for ($i = 0; $i -lt $limit; $i++) { + $pt = $openPRItems[$i] + $pid = if ($pt.pull_request_number) { $pt.pull_request_number } elseif ($pt.number) { $pt.number } elseif ($pt.id) { $pt.id } else { "" } + $ptitle = if ($pt.subject) { $pt.subject } elseif ($pt.title) { $pt.title } elseif ($pt.name) { $pt.name } else { "" } + $pauthor = if ($pt.author_name) { $pt.author_name } elseif ($pt.author_login) { $pt.author_login } elseif ($pt.author.login) { $pt.author.login } else { "unknown" } + $prRows += "#$(Html-Escape ([string]$pid))$(Html-Escape ([string]$ptitle))@$(Html-Escape ([string]$pauthor))" + } + if (-not $prRows) { $prRows = '无未合并 PR' } + + $repoDetails += @" +
    $health $(Html-Escape $repo) — Issue 开 $openIssues / PR 开 $openPRs +
    +

    未关闭 Issue(最多 $DetailLimit 条)

    + $issueRows
    编号标题作者
    +

    未合并 PR(最多 $DetailLimit 条)

    + $prRows
    编号标题作者
    +
    +
    +"@ + + $totalActivity += $openIssues + $closedIssues + $openPRs + $mergedPRs + $totalOpenIssues += $openIssues + $totalOpenPRs += $openPRs + $totalMergedPRs += $mergedPRs + $healthSum += $health + + $relDisplay = if ($latestRelease -eq "none") { "—" } else { Html-Escape $latestRelease } + + $dashboardRows += @" + + $(Html-Escape $repo) + $openIssues$closedIssues + $openPRs$mergedPRs$relDisplay + $health $($grade.Label) + +"@ +} + +$avgHealth = if ($repoList.Count -gt 0) { [int]($healthSum / $repoList.Count) } else { 0 } + +# -- Step 5: Generate HTML Dashboard -- +Log-Title "生成 HTML 看板" + +$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" +$repoCount = $repoList.Count + +$html = @" + + + + + +多仓库协同看板 - $Org + + + +
    +

    多仓库协同看板

    +

    生成时间:$timestamp | 组织:$Org

    +
    +

    仓库总数

    $repoCount
    +

    未关闭 Issue

    $totalOpenIssues
    +

    未合并 PR

    $totalOpenPRs
    +

    已合并 PR

    $totalMergedPRs
    +

    平均健康度

    $avgHealth
    +
    + +

    仓库概览

    + + + +$dashboardRows + +
    仓库未关闭 Issue已关闭 Issue未合并 PR已合并 PR最新 Release健康度
    + +

    仓库详情

    +$repoDetails +
    + + +"@ + +$utf8 = [System.Text.UTF8Encoding]::new($false) +[System.IO.File]::WriteAllText((Join-Path (Get-Location) $Output), $html, $utf8) +Log-Ok "看板已保存: $Output" + +# -- Step 6: Coordinated Release -- +if ($Release) { + Log-Title "协调发版: $Release" + + if ($DryRun) { + Log-Warn "[DRY RUN] 将为以下 $($repoList.Count) 个仓库创建 Release ${Release}:" + foreach ($repo in $repoList) { Log-Warn " [DRY RUN] $Org/$repo -> $Release" } + } else { + $relOk = 0; $relFail = 0 + foreach ($repo in $repoList) { + Log-Step "为 $Org/$repo 创建 Release..." + $relBody = "Coordinated release $Release for $Org/$repo" + $relResult = Invoke-GL "release", "+create", "--owner", $Org, "--repo", $repo, "--tag", $Release, "--name", "Release $Release", "--body", $relBody + $ok = $false + if ($relResult) { try { $ok = ($relResult | ConvertFrom-Json).ok } catch { $ok = $false } } + if ($ok) { Log-Ok "Release $Release 已在 $repo 创建"; $relOk++ } + else { Log-Warn "Release 创建失败: $repo (tag 可能已存在)"; $relFail++ } + } + Log-Info "协调发版结果: 成功 $relOk / 失败 $relFail" + } +} + +# ---------------------------------------------------------------- +Log-Title "多仓库看板完成" +# ---------------------------------------------------------------- + +Write-Host " 处理仓库: $repoCount" -ForegroundColor Green +Write-Host " 未关闭 Issue: $totalOpenIssues" -ForegroundColor Green +Write-Host " 未合并 PR: $totalOpenPRs" -ForegroundColor Green +Write-Host " 已合并 PR: $totalMergedPRs" -ForegroundColor Green +Write-Host " 平均健康度: $avgHealth / 100" -ForegroundColor Green +Write-Host " 看板文件: $Output" -ForegroundColor Green +if ($Release) { + $suffix = if ($DryRun) { " (dry-run)" } else { "" } + Write-Host " 协调发版: $Release$suffix" -ForegroundColor Green +} +Write-Host "" +Write-Host "打开看板:" -ForegroundColor Cyan +Write-Host " Start-Process $Output" diff --git a/workflows/04-multi-repo-collab.sh b/workflows/04-multi-repo-collab.sh new file mode 100644 index 0000000..0585d8f --- /dev/null +++ b/workflows/04-multi-repo-collab.sh @@ -0,0 +1,383 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────── +# Scenario 4: Multi-Repo Collaboration +# Flow: Cross-repo issue tracking → PR status dashboard → Coordinated release +# +# Commands chained: +# 1. repo +list -- list all repos in org +# 2. issue +list -- fetch open/closed issues from each repo +# 3. pr +list -- fetch open/merged PRs from each repo +# 4. release +list -- check latest release across repos +# 5. Generate HTML dashboard (summary cards + table + per-repo details) +# 6. release +create -- (optional) coordinated release across all repos +# ───────────────────────────────────────────────────────────────────── + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" + +usage() { + echo "Usage: $0 --org ORG [--repos REPO1,REPO2,...] [--release TAG] [--output FILE] [--dry-run]" + echo "" + echo " --org ORG Organization / user name (required)" + echo " --repos REPO1,REPO2 Comma-separated repo list (default: all repos in org)" + echo " --release TAG Coordinated release tag to create across all repos" + echo " --output FILE Output HTML dashboard file (default: dashboard.html)" + echo " --detail-limit N Max open issues/PRs listed per repo in details (default: 10)" + echo " --dry-run Preview actions without creating releases" + exit 1 +} + +DRY_RUN=false +ORG="" +REPOS="" +RELEASE_TAG="" +OUTPUT_FILE="dashboard.html" +DETAIL_LIMIT=10 + +while [[ $# -gt 0 ]]; do + case "$1" in + --org) ORG="$2"; shift 2 ;; + --repos) REPOS="$2"; shift 2 ;; + --release) RELEASE_TAG="$2"; shift 2 ;; + --output) OUTPUT_FILE="$2"; shift 2 ;; + --detail-limit) DETAIL_LIMIT="$2"; shift 2 ;; + --dry-run) DRY_RUN="true"; shift ;; + --help|-h) usage ;; + *) log_err "Unknown arg: $1"; usage ;; + esac +done + +if [[ -z "$ORG" ]]; then + log_err "Missing required parameter: --org" + usage +fi + +check_auth + +# ── HTML escape helper ─────────────────────────────────────────────── +html_escape() { + local s="$1" + s="${s//&/&}" + s="${s///>}" + s="${s//\"/"}" + echo "$s" +} + +# ── Health score (0-100) — aligned with gitlink-health skill ───────── +# Model: start 100, deduct. Core 75 (issue efficiency / PR efficiency / +# activity, -25 each), auxiliary 25 (issue backlog -10, PR backlog -10, +# no recent release -5). Time-based core metrics are approximated by +# list-level ratios for a fast multi-repo scan. +# release_age: days since latest release, -1 = no release. +compute_health() { + local open_issues="$1" closed_issues="$2" open_prs="$3" merged_prs="$4" release_age="$5" + awk -v oi="$open_issues" -v ci="$closed_issues" -v op="$open_prs" \ + -v mp="$merged_prs" -v age="$release_age" 'BEGIN { + health = 100 + # ── 核心指标 (75) ── + # Issue 处理效率 (proxy for 响应慢): 解决率 < 50% 扣 25 + ti = oi + ci + res = (ti > 0) ? ci / ti : 1 + if (res < 0.5) health -= 25 + # PR 合并效率 (proxy for 合并慢): 合并率 < 50% 扣 25 + tp = op + mp + mr = (tp > 0) ? mp / tp : 1 + if (mr < 0.5) health -= 25 + # 活跃度低: 无任何已完成工作 (合并 PR + 关闭 Issue == 0) 扣 25 + if ((mp + ci) == 0) health -= 25 + # ── 辅助指标 (25) ── + if (oi > 20) health -= 10 # Issue 积压 + if (op > 10) health -= 10 # PR 积压 + if (age < 0 || age > 30) health -= 5 # 无近期发布 (>30天或无) + if (health < 0) health = 0 + if (health > 100) health = 100 + printf "%d", health + }' +} + +# Map score → (grade, css class) — 5 bands per gitlink-health skill +health_grade() { + local score="$1" + if [[ "$score" -ge 90 ]]; then echo "优秀|excellent" + elif [[ "$score" -ge 70 ]]; then echo "良好|good" + elif [[ "$score" -ge 50 ]]; then echo "一般|moderate" + elif [[ "$score" -ge 30 ]]; then echo "需关注|attention" + else echo "严重|critical" + fi +} + +# ── Step 1: List Repositories ──────────────────────────────────────── +log_title "多仓库协同看板 (Multi-Repo Collaboration Dashboard)" + +log_step "获取组织 $ORG 下的仓库列表..." +REPOS_JSON=$(gl_check repo +list --user "$ORG" --limit 100) +REPOS_DATA_PATH='(.data.projects // .data | if type == "array" then . else [] end)' +ALL_REPO_COUNT=$(echo "$REPOS_JSON" | jq "$REPOS_DATA_PATH | length") +log_ok "组织下共有 $ALL_REPO_COUNT 个仓库" + +# Filter repos if --repos specified +REPO_LIST=() +if [[ -n "$REPOS" ]]; then + IFS=',' read -ra REPO_LIST <<< "$REPOS" + log_info "仅处理指定仓库: ${REPO_LIST[*]}" +else + for i in $(seq 0 $((ALL_REPO_COUNT - 1))); do + RNAME=$(echo "$REPOS_JSON" | jq -r "$REPOS_DATA_PATH[$i].name // $REPOS_DATA_PATH[$i].identifier // empty") + [[ -n "$RNAME" ]] && REPO_LIST+=("$RNAME") + done +fi + +if [[ "${#REPO_LIST[@]}" -eq 0 ]]; then + log_err "没有可处理的仓库。请检查 --org 是否正确,或用 --repos 指定。" + exit 1 +fi +log_ok "将处理 ${#REPO_LIST[@]} 个仓库" + +# ── Step 2-4: Collect data across repos ────────────────────────────── +log_title "跨仓库数据采集" + +DASHBOARD_ROWS="" +REPO_DETAILS="" +TOTAL_ACTIVITY=0 +TOTAL_OPEN_ISSUES=0 +TOTAL_OPEN_PRS=0 +TOTAL_MERGED_PRS=0 +HEALTH_SUM=0 + +ISSUE_DATA_PATH='(.data.issues // .data | if type == "array" then . else [] end)' +PR_DATA_PATH='(.data.issues // .data.pulls // .data | if type == "array" then . else [] end)' + +for repo in "${REPO_LIST[@]}"; do + divider + log_step "处理 $ORG/$repo..." + + ISSUES_JSON=$(gl_run issue +list --owner "$ORG" --repo "$repo" --state open --limit 50) + OPEN_ISSUES=$(echo "$ISSUES_JSON" | jq "$ISSUE_DATA_PATH | length" 2>/dev/null || echo "0") + [[ "$OPEN_ISSUES" =~ ^[0-9]+$ ]] || OPEN_ISSUES=0 + + CLOSED_JSON=$(gl_run issue +list --owner "$ORG" --repo "$repo" --state closed --limit 50) + CLOSED_ISSUES=$(echo "$CLOSED_JSON" | jq "$ISSUE_DATA_PATH | length" 2>/dev/null || echo "0") + [[ "$CLOSED_ISSUES" =~ ^[0-9]+$ ]] || CLOSED_ISSUES=0 + + PRS_JSON=$(gl_run pr +list --owner "$ORG" --repo "$repo" --state open --limit 50) + OPEN_PRS=$(echo "$PRS_JSON" | jq "$PR_DATA_PATH | length" 2>/dev/null || echo "0") + [[ "$OPEN_PRS" =~ ^[0-9]+$ ]] || OPEN_PRS=0 + + MERGED_JSON=$(gl_run pr +list --owner "$ORG" --repo "$repo" --state merged --limit 50) + MERGED_PRS=$(echo "$MERGED_JSON" | jq "$PR_DATA_PATH | length" 2>/dev/null || echo "0") + [[ "$MERGED_PRS" =~ ^[0-9]+$ ]] || MERGED_PRS=0 + + RELEASES_JSON=$(gl_run release +list --owner "$ORG" --repo "$repo" --limit 1) + REL_ARR='(.data.releases // .data | if type == "array" then . else [] end)' + LATEST_RELEASE=$(echo "$RELEASES_JSON" | jq -r "$REL_ARR[0].tag_name // $REL_ARR[0].name // \"none\"" 2>/dev/null) + [[ -z "$LATEST_RELEASE" || "$LATEST_RELEASE" == "null" ]] && LATEST_RELEASE="none" + + # Release age in days (-1 if none / unparseable) + RELEASE_AGE=-1 + if [[ "$LATEST_RELEASE" != "none" ]]; then + REL_DATE=$(echo "$RELEASES_JSON" | jq -r "$REL_ARR[0].created_at // $REL_ARR[0].created_on // $REL_ARR[0].published_at // empty" 2>/dev/null) + REL_DATE="${REL_DATE%% *}"; REL_DATE="${REL_DATE%%T*}" + if [[ "$REL_DATE" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then + RELEASE_AGE=$(days_between "$REL_DATE" "$(date_today)") + else + RELEASE_AGE=0 # has release but no parseable date → treat as recent + fi + fi + + # Health score (aligned with gitlink-health skill) + HEALTH=$(compute_health "$OPEN_ISSUES" "$CLOSED_ISSUES" "$OPEN_PRS" "$MERGED_PRS" "$RELEASE_AGE") + IFS='|' read -r GRADE_LABEL GRADE_CLASS <<< "$(health_grade "$HEALTH")" + + log_ok "$repo: Issue(开:$OPEN_ISSUES 闭:$CLOSED_ISSUES) PR(开:$OPEN_PRS 合:$MERGED_PRS) Release:$LATEST_RELEASE 健康度:$HEALTH($GRADE_LABEL)" + + # ── Collect open issue details ── + ISSUE_ROWS="" + if [[ "$OPEN_ISSUES" -gt 0 ]]; then + limit=$(( OPEN_ISSUES < DETAIL_LIMIT ? OPEN_ISSUES : DETAIL_LIMIT )) + for ii in $(seq 0 $((limit - 1))); do + I_ID=$(echo "$ISSUES_JSON" | jq -r "$ISSUE_DATA_PATH[$ii].id // $ISSUE_DATA_PATH[$ii].number // empty") + I_TITLE=$(echo "$ISSUES_JSON" | jq -r "$ISSUE_DATA_PATH[$ii].name // $ISSUE_DATA_PATH[$ii].subject // $ISSUE_DATA_PATH[$ii].title // empty") + I_AUTHOR=$(echo "$ISSUES_JSON" | jq -r "$ISSUE_DATA_PATH[$ii].author_name // $ISSUE_DATA_PATH[$ii].author_login // $ISSUE_DATA_PATH[$ii].author.login // \"unknown\"") + ISSUE_ROWS+="#$(html_escape "$I_ID")$(html_escape "$I_TITLE")@$(html_escape "$I_AUTHOR")" + done + fi + + # ── Collect open PR details ── + PR_ROWS="" + if [[ "$OPEN_PRS" -gt 0 ]]; then + limit=$(( OPEN_PRS < DETAIL_LIMIT ? OPEN_PRS : DETAIL_LIMIT )) + for pi in $(seq 0 $((limit - 1))); do + P_ID=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].pull_request_number // $PR_DATA_PATH[$pi].number // $PR_DATA_PATH[$pi].id // empty") + P_TITLE=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].subject // $PR_DATA_PATH[$pi].title // $PR_DATA_PATH[$pi].name // empty") + P_AUTHOR=$(echo "$PRS_JSON" | jq -r "$PR_DATA_PATH[$pi].author_name // $PR_DATA_PATH[$pi].author_login // $PR_DATA_PATH[$pi].author.login // \"unknown\"") + PR_ROWS+="#$(html_escape "$P_ID")$(html_escape "$P_TITLE")@$(html_escape "$P_AUTHOR")" + done + fi + + # ── Per-repo collapsible detail section ── + [[ -z "$ISSUE_ROWS" ]] && ISSUE_ROWS="无未关闭 Issue" + [[ -z "$PR_ROWS" ]] && PR_ROWS="无未合并 PR" + REPO_DETAILS+="
    $HEALTH $(html_escape "$repo") — Issue 开 $OPEN_ISSUES / PR 开 $OPEN_PRS +
    +

    未关闭 Issue(最多 $DETAIL_LIMIT 条)

    + $ISSUE_ROWS
    编号标题作者
    +

    未合并 PR(最多 $DETAIL_LIMIT 条)

    + $PR_ROWS
    编号标题作者
    +
    +
    " + + # Accumulate totals + TOTAL_ACTIVITY=$((TOTAL_ACTIVITY + OPEN_ISSUES + CLOSED_ISSUES + OPEN_PRS + MERGED_PRS)) + TOTAL_OPEN_ISSUES=$((TOTAL_OPEN_ISSUES + OPEN_ISSUES)) + TOTAL_OPEN_PRS=$((TOTAL_OPEN_PRS + OPEN_PRS)) + TOTAL_MERGED_PRS=$((TOTAL_MERGED_PRS + MERGED_PRS)) + HEALTH_SUM=$((HEALTH_SUM + HEALTH)) + + REL_DISPLAY="$LATEST_RELEASE" + [[ "$REL_DISPLAY" == "none" ]] && REL_DISPLAY="—" + + DASHBOARD_ROWS+=" + $(html_escape "$repo") + $OPEN_ISSUES + $CLOSED_ISSUES + $OPEN_PRS + $MERGED_PRS + $(html_escape "$REL_DISPLAY") + $HEALTH $GRADE_LABEL + " +done + +# Average health +AVG_HEALTH=0 +[[ "${#REPO_LIST[@]}" -gt 0 ]] && AVG_HEALTH=$((HEALTH_SUM / ${#REPO_LIST[@]})) + +# ── Step 5: Generate HTML Dashboard ────────────────────────────────── +log_title "生成 HTML 看板" +log_step "写入 $OUTPUT_FILE..." + +TIMESTAMP=$(date '+%Y-%m-%d %H:%M:%S') + +cat > "$OUTPUT_FILE" << HTMLEOF + + + + + +多仓库协同看板 - $ORG + + + +
    +

    多仓库协同看板

    +

    生成时间:$TIMESTAMP | 组织:$ORG

    +
    +

    仓库总数

    ${#REPO_LIST[@]}
    +

    未关闭 Issue

    $TOTAL_OPEN_ISSUES
    +

    未合并 PR

    $TOTAL_OPEN_PRS
    +

    已合并 PR

    $TOTAL_MERGED_PRS
    +

    平均健康度

    $AVG_HEALTH
    +
    + +

    仓库概览

    + + +$DASHBOARD_ROWS +
    仓库未关闭 Issue已关闭 Issue未合并 PR已合并 PR最新 Release健康度
    + +

    仓库详情

    +$REPO_DETAILS +
    + + +HTMLEOF + +log_ok "看板已保存: $OUTPUT_FILE" + +# ── Step 6: Coordinated Release ────────────────────────────────────── +if [[ -n "$RELEASE_TAG" ]]; then + log_title "协调发版: $RELEASE_TAG" + + if [[ "$DRY_RUN" == "true" ]]; then + log_warn "[DRY RUN] 将为以下 ${#REPO_LIST[@]} 个仓库创建 Release $RELEASE_TAG:" + for repo in "${REPO_LIST[@]}"; do + log_warn " [DRY RUN] $ORG/$repo → $RELEASE_TAG" + done + else + RELEASE_OK=0 + RELEASE_FAIL=0 + for repo in "${REPO_LIST[@]}"; do + log_step "为 $ORG/$repo 创建 Release..." + RELEASE_RESULT=$(gl_run release +create --owner "$ORG" --repo "$repo" \ + --tag "$RELEASE_TAG" --name "Release $RELEASE_TAG" \ + --body "Coordinated release $RELEASE_TAG for $ORG/$repo" 2>&1) || true + + if [[ "$(json_ok "$RELEASE_RESULT")" == "true" ]]; then + log_ok "Release $RELEASE_TAG 已在 $repo 创建" + RELEASE_OK=$((RELEASE_OK + 1)) + else + log_warn "Release 创建失败: $repo (tag 可能已存在)" + RELEASE_FAIL=$((RELEASE_FAIL + 1)) + fi + done + log_info "协调发版结果: 成功 $RELEASE_OK / 失败 $RELEASE_FAIL" + fi +fi + +# ───────────────────────────────────────────────────────────────────── +log_title "多仓库看板完成" +# ───────────────────────────────────────────────────────────────────── + +echo -e "${GREEN}汇总:${NC}" +echo " 处理仓库: ${#REPO_LIST[@]}" +echo " 未关闭 Issue: $TOTAL_OPEN_ISSUES" +echo " 未合并 PR: $TOTAL_OPEN_PRS" +echo " 已合并 PR: $TOTAL_MERGED_PRS" +echo " 平均健康度: $AVG_HEALTH / 100" +echo " 看板文件: $OUTPUT_FILE" +[[ -n "$RELEASE_TAG" ]] && echo " 协调发版: $RELEASE_TAG$([[ "$DRY_RUN" == "true" ]] && echo " (dry-run)")" +echo "" +echo -e "${CYAN}打开看板:${NC}" +echo " start $OUTPUT_FILE # Windows" +echo " xdg-open $OUTPUT_FILE # Linux" +echo " open $OUTPUT_FILE # macOS" +echo "" diff --git a/workflows/05-contributor-growth.ps1 b/workflows/05-contributor-growth.ps1 new file mode 100644 index 0000000..bf14d52 --- /dev/null +++ b/workflows/05-contributor-growth.ps1 @@ -0,0 +1,448 @@ +# ---------------------------------------------------------------- +# Scenario 5: Contributor Growth System +# Flow: Collect data -> Calculate scores -> Generate HTML -> Publish Wiki -> Award badges +# +# Scoring (AHP weight model): +# - Issues Created: 15% weight (issue +list) +# - PRs Merged: 25% weight (pr +list state=merged) +# - Code Changes: 30% weight (pr +files) +# - Issue Comments: 15% weight (issue +view) +# - Team Member: 15% weight (repo +members) +# +# Badges: +# - Champion >= 80 +# - Core Contributor >= 60 +# - Active Contributor >= 40 +# - Contributor >= 20 +# - Newcomer < 20 +# ---------------------------------------------------------------- +#Requires -Version 5.1 + +param( + [string]$Owner = "", + [string]$Repo = "", + [int]$Sample = 10, + [switch]$Award, + [switch]$DryRun, + [switch]$Help +) + +$ErrorActionPreference = "Stop" +Import-Module "$PSScriptRoot/lib/common.psm1" -Force -WarningAction SilentlyContinue + +if ($Help) { + Write-Host "Usage: powershell 05-contributor-growth.ps1 -Owner OWNER -Repo REPO [-Sample N] [-Award] [-DryRun]" + Write-Host "" + Write-Host " -Owner OWNER Repository owner" + Write-Host " -Repo REPO Repository name" + Write-Host " -Sample N Sample N PRs for code stats (default: 10)" + Write-Host " -Award Auto-create badge award issues" + Write-Host " -DryRun Preview actions without executing" + exit 0 +} + +Check-Auth +$r = Resolve-OwnerRepo $Owner $Repo +$Owner = $r.Owner; $Repo = $r.Repo + +$reportFile = "contrib-report-$Owner-$Repo.html" + +# ---------------------------------------------------------------- +Log-Title "Contributor Growth System: $Owner/$Repo" +# ---------------------------------------------------------------- + +# -- Step 1: Collect Data -- +Log-Step "Collecting data..." + +$issuesOpen = Invoke-GLCheck "issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "open", "--limit", "100" +$issuesClosed = Invoke-GLCheck "issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "closed", "--limit", "100" +$openCount = if ($issuesOpen) { @($issuesOpen.data.issues).Count } else { 0 } +$closedCount = if ($issuesClosed) { @($issuesClosed.data.issues).Count } else { 0 } + +$prsMerged = Invoke-GLCheck "pr", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "merged", "--limit", "100" +$prMergedData = @() +if ($prsMerged) { + $pd = $prsMerged.data + if ($pd.issues) { $prMergedData = @($pd.issues) } + elseif ($pd.pulls) { $prMergedData = @($pd.pulls) } + elseif ($pd -is [array]) { $prMergedData = $pd } +} +$prMergedCount = $prMergedData.Count + +$membersJson = Invoke-GLCheck "repo", "+members", "--owner", $Owner, "--repo", $Repo, "--limit", "100" +$memberData = @() +if ($membersJson) { + $md = $membersJson.data + if ($md.members) { $memberData = @($md.members) } + elseif ($md -is [array]) { $memberData = $md } +} +$memberCount = $memberData.Count + +Log-Ok "Issues(open:$openCount closed:$closedCount) PRs(merged:$prMergedCount) Members:$memberCount" + +# -- Step 2: Build Contributor Data -- +Log-Step "Building contributor profiles..." + +$contribData = @{} + +function Ensure-Contrib { + param([string]$User) + if (-not $User) { return } + if (-not $contribData.ContainsKey($User)) { + $contribData[$User] = @{ + Issues = 0; Merged = 0; Additions = 0; Deletions = 0; Comments = 0; IsMember = $false + } + } +} + +# Issues +$allIssues = @() +if ($issuesOpen) { $allIssues += @($issuesOpen.data.issues) } +if ($issuesClosed) { $allIssues += @($issuesClosed.data.issues) } +foreach ($issue in $allIssues) { + $author = if ($issue.author.login) { $issue.author.login } elseif ($issue.author.username) { $issue.author.username } else { $null } + if ($author) { + Ensure-Contrib $author + $contribData[$author].Issues++ + } +} + +# Merged PRs + code stats +Log-Step "Analyzing PR code changes (sampling $Sample)..." +$prSample = [Math]::Min($prMergedCount, $Sample) +for ($i = 0; $i -lt $prMergedCount; $i++) { + $pr = $prMergedData[$i] + $author = if ($pr.author_login) { $pr.author_login } elseif ($pr.author.login) { $pr.author.login } else { $null } + $prId = if ($pr.pull_request_number) { $pr.pull_request_number } elseif ($pr.number) { $pr.number } elseif ($pr.id) { $pr.id } else { $null } + if ($author) { + Ensure-Contrib $author + $contribData[$author].Merged++ + } + if ($i -lt $prSample -and $prId -and $author) { + $filesJson = Invoke-GLCheck "pr", "+files", "--owner", $Owner, "--repo", $Repo, "--id", $prId + if ($filesJson -and $filesJson.data.files) { + foreach ($f in $filesJson.data.files) { + $add = if ($f.additions) { $f.additions } elseif ($f.addition) { $f.addition } else { 0 } + $del = if ($f.deletions) { $f.deletions } elseif ($f.deletion) { $f.deletion } else { 0 } + $contribData[$author].Additions += $add + $contribData[$author].Deletions += $del + } + } + } +} + +# Members +foreach ($m in $memberData) { + $login = if ($m.login) { $m.login } elseif ($m.username) { $m.username } else { $null } + if ($login) { + Ensure-Contrib $login + $contribData[$login].IsMember = $true + } +} + +# Comments (sample open issues) +Log-Step "Sampling issue comments..." +if ($issuesOpen) { + $openIssuesArr = @($issuesOpen.data.issues) + $commentSample = [Math]::Min($openIssuesArr.Count, 10) + for ($i = 0; $i -lt $commentSample; $i++) { + $id = $openIssuesArr[$i].id + if (-not $id) { continue } + $detailJson = Invoke-GLCheck "issue", "+view", "--owner", $Owner, "--repo", $Repo, "--number", $id + if ($detailJson) { + $commentCount = if ($detailJson.data.comment_journals_count) { $detailJson.data.comment_journals_count } else { 0 } + if ($commentCount -gt 0) { + $author = $openIssuesArr[$i].author.login + if ($author) { + Ensure-Contrib $author + $contribData[$author].Comments += $commentCount + } + } + } + } +} + +# -- Step 3: Calculate Scores -- +Log-Step "Calculating scores..." + +$maxIssues = 0; $maxMerged = 0; $maxLines = 0; $maxComments = 0 +foreach ($user in $contribData.Keys) { + $c = $contribData[$user] + if ($c.Issues -gt $maxIssues) { $maxIssues = $c.Issues } + if ($c.Merged -gt $maxMerged) { $maxMerged = $c.Merged } + $lines = $c.Additions + $c.Deletions + if ($lines -gt $maxLines) { $maxLines = $lines } + if ($c.Comments -gt $maxComments) { $maxComments = $c.Comments } +} + +$scores = @{} +foreach ($user in $contribData.Keys) { + $c = $contribData[$user] + $ni = if ($maxIssues -gt 0) { $c.Issues / $maxIssues } else { 0 } + $nm = if ($maxMerged -gt 0) { $c.Merged / $maxMerged } else { 0 } + $lines = $c.Additions + $c.Deletions + $nl = if ($maxLines -gt 0) { $lines / $maxLines } else { 0 } + $nc = if ($maxComments -gt 0) { $c.Comments / $maxComments } else { 0 } + $ms = if ($c.IsMember) { 1 } else { 0 } + $score = [Math]::Round($ni * 15 + $nm * 25 + $nl * 30 + $nc * 15 + $ms * 15, 1) + $scores[$user] = $score +} + +# -- Step 4: Display Rankings -- +Log-Title "Contributor Rankings" +Write-Host "" +Write-Host ("{0,-4} {1,-18} {2,-8} {3,-8} {4,-12} {5,-10} {6,-8} {7}" -f "Rank","Contributor","Issues","Merged","+/- Lines","Comments","Score","Badge") -ForegroundColor White +Write-Host " ---- ------------------ -------- -------- ------------ ---------- -------- -------------" + +$ranked = $scores.GetEnumerator() | Sort-Object -Property Value -Descending +$rank = 1 +$rankedList = @() +foreach ($entry in $ranked) { + $user = $entry.Key + $score = $entry.Value + $c = $contribData[$user] + $si = [int]$score + $badge = if ($si -ge 80) { "Champion" } elseif ($si -ge 60) { "Core Contributor" } elseif ($si -ge 40) { "Active Contributor" } elseif ($si -ge 20) { "Contributor" } else { "Newcomer" } + $lines = $c.Additions + $c.Deletions + Write-Host ("{0,-4} {1,-18} {2,-8} {3,-8} +{4,-6}/-{5,-4} {6,-10} {7,-8} {8}" -f $rank,$user,$c.Issues,$c.Merged,$c.Additions,$c.Deletions,$c.Comments,$score,$badge) + $rankedList += @{ Rank=$rank; User=$user; Issues=$c.Issues; Merged=$c.Merged; Lines=$lines; Additions=$c.Additions; Deletions=$c.Deletions; Comments=$c.Comments; Score=$score; Badge=$badge } + $rank++ +} + +# -- Step 5: Generate HTML Report -- +Log-Title "Generating HTML Report" + +$pieData = "" +foreach ($entry in $ranked) { + $pieData += "{value: $($entry.Value), name: '$($entry.Key)'}," +} + +$tableRows = "" +foreach ($r in $rankedList) { + $rankCls = "" + if ($r.Rank -eq 1) { $rankCls = " rank-1" } + elseif ($r.Rank -eq 2) { $rankCls = " rank-2" } + elseif ($r.Rank -eq 3) { $rankCls = " rank-3" } + + $badgeCls = switch ($r.Badge) { + "Champion" { "champion" } + "Core Contributor" { "core" } + "Active Contributor" { "active" } + "Contributor" { "contributor" } + default { "newcomer" } + } + + $tableRows += ' ' + $r.Rank + '@' + $r.User + '' + $r.Issues + '' + $r.Merged + '' + $r.Lines + '' + $r.Comments + '' + $r.Score + '' + $r.Badge + '' + "`n" +} + +$totalIssuesCount = $openCount + $closedCount +$contribCount = $contribData.Count + +$html = ' + + + + + Contributor Report - ' + $Owner + '/' + $Repo + ' + + + + +
    +
    +

    Contributor Report

    +

    ' + $Owner + '/' + $Repo + ' - Team Contribution Analysis

    +
    +
    +
    ' + $contribCount + '
    Contributors
    +
    ' + $totalIssuesCount + '
    Total Issues
    +
    ' + $prMergedCount + '
    Merged PRs
    +
    +
    +

    Score Distribution

    +
    +
    +
    +

    Detailed Rankings

    + +' + $tableRows + ' +
    RankContributorIssuesMerged PRsCode LinesCommentsScoreBadge
    +
    +
    +

    Scoring System (AHP Weights)

    +
    +
    +
    Issues Created15%
    +
    PRs Merged25%
    +
    Code Changes30%
    +
    Issue Comments15%
    +
    Team Member15%
    +
    +
    +
    +
    + + +' + +$html | Out-File -FilePath $reportFile -Encoding UTF8 +Log-Ok "HTML report: $reportFile" + +# -- Step 6: Publish to Wiki -- +Log-Step "Publishing to Wiki..." + +$wikiRankRows = "" +foreach ($r in $rankedList) { + $shortBadge = switch ($r.Badge) { + "Champion" { "Champion" } + "Core Contributor" { "Core" } + "Active Contributor" { "Active" } + "Contributor" { "Contributor" } + default { "Newcomer" } + } + $wikiRankRows += "| $($r.Rank) | @$($r.User) | $($r.Issues) | $($r.Merged) | $($r.Lines) | $($r.Comments) | $($r.Score) | $shortBadge |" + "`n" +} + +$wikiTitle = "Contributor Leaderboard $(Get-Date -Format 'yyyy-MM-dd')" +$wikiContent = "# Contributor Leaderboard - $Owner/$Repo" + "`n`n" +$wikiContent += "*Generated: $(Get-Date -Format 'yyyy-MM-dd HH:mm')*" + "`n`n" +$wikiContent += "## Scoring System" + "`n`n" +$wikiContent += "| Dimension | Weight | Source |" + "`n" +$wikiContent += "|-----------|--------|--------|" + "`n" +$wikiContent += "| Issues Created | 15% | issue +list |" + "`n" +$wikiContent += "| PRs Merged | 25% | pr +list state=merged |" + "`n" +$wikiContent += "| Code Changes | 30% | pr +files |" + "`n" +$wikiContent += "| Issue Comments | 15% | issue +view |" + "`n" +$wikiContent += "| Team Member | 15% | repo +members |" + "`n`n" +$wikiContent += "## Rankings" + "`n`n" +$wikiContent += "| Rank | Contributor | Issues | Merged | Lines | Comments | Score | Badge |" + "`n" +$wikiContent += "|------|-------------|--------|--------|-------|----------|-------|-------|" + "`n" +$wikiContent += $wikiRankRows + "`n" +$wikiContent += "---" + "`n" +$wikiContent += "*Auto-generated by gitlink-cli*" + +$wikiResult = Invoke-GL "wiki", "+update", "--owner", $Owner, "--repo", $Repo, "--title", $wikiTitle, "--cover", $wikiContent +if ($wikiResult -and (Get-JsonOk ($wikiResult | ConvertFrom-Json))) { + Log-Ok "Published to Wiki (updated): $wikiTitle" +} else { + Log-Info "Update failed (page may not exist), trying create..." + $wikiResult = Invoke-GL "wiki", "+create", "--owner", $Owner, "--repo", $Repo, "--title", $wikiTitle, "--content", $wikiContent + if ($wikiResult) { + try { + $wikiObj = $wikiResult | ConvertFrom-Json + if ($wikiObj.ok) { + Log-Ok "Published to Wiki (created): $wikiTitle" + } else { + $wikiErr = if ($wikiObj.error.message) { $wikiObj.error.message } elseif ($wikiObj.message) { $wikiObj.message } else { "unknown error" } + Log-Warn "Wiki publish failed: $wikiErr" + } + } catch { + Log-Warn "Wiki publish failed: non-JSON response from API" + } + } else { + Log-Warn "Wiki publish failed: no response from API" + } +} + +# -- Step 7: Award Badges (optional) -- +if ($Award) { + Log-Title "Awarding Badges" + + $badgeGroups = @{} + foreach ($r in $rankedList) { + if (-not $badgeGroups.ContainsKey($r.Badge)) { $badgeGroups[$r.Badge] = @() } + $badgeGroups[$r.Badge] += $r.User + } + + foreach ($badge in $badgeGroups.Keys) { + $users = $badgeGroups[$badge] + if ($badge -eq "Newcomer") { continue } + + $userList = ($users | ForEach-Object { "@$_" }) -join ", " + $issueTitle = "Badge Award: $badge" + $issueBody = "## Congratulations!" + "`n`n" + $issueBody += "The following contributors have earned the **$badge** badge:" + "`n`n" + $issueBody += $userList + "`n`n" + $issueBody += "### Badge Criteria" + "`n" + $issueBody += switch ($badge) { + "Champion" { "- Score >= 80: Exceptional contribution to the project" } + "Core Contributor" { "- Score >= 60: Significant and consistent contributions" } + "Active Contributor" { "- Score >= 40: Regular contributions to the project" } + "Contributor" { "- Score >= 20: Made meaningful contributions" } + } + $issueBody += "`n`n---`n*Auto-awarded by gitlink-cli contributor-growth workflow*" + + $issueResult = Invoke-GL "issue", "+create", "--owner", $Owner, "--repo", $Repo, "--title", $issueTitle, "--body", $issueBody + if ($issueResult) { + try { + $issueJson = $issueResult | ConvertFrom-Json + $issueNum = if ($issueJson.ok -and $issueJson.data.id) { $issueJson.data.id } elseif ($issueJson.ok -and $issueJson.data.number) { $issueJson.data.number } else { $null } + if ($issueNum) { + Invoke-GL "issue", "+label-add", "--owner", $Owner, "--repo", $Repo, "--number", $issueNum, "--labels", "badge" | Out-Null + Log-Ok "Badge issue created: #$issueNum - $issueTitle ($($users.Count) recipients)" + } + } catch { + Log-Warn "Badge issue creation may have failed: $issueTitle" + } + } + } +} + +# ---------------------------------------------------------------- +Log-Title "Complete" +# ---------------------------------------------------------------- + +Write-Host " Contributors: $contribCount" -ForegroundColor Green +Write-Host " HTML Report: $reportFile" -ForegroundColor Green +Write-Host " Wiki: $wikiTitle" -ForegroundColor Green +if ($Award) { Write-Host " Badges: Awarded" -ForegroundColor Green } diff --git a/workflows/05-contributor-growth.sh b/workflows/05-contributor-growth.sh new file mode 100644 index 0000000..bd0011e --- /dev/null +++ b/workflows/05-contributor-growth.sh @@ -0,0 +1,401 @@ +#!/usr/bin/env bash +# ───────────────────────────────────────────────────────────────────── +# Scenario 5: Contributor Growth System +# Flow: Collect data → Calculate scores → Generate HTML → Publish Wiki +# +# Scoring (based on available shortcuts): +# - Issue created: 15% weight (issue +list) +# - PR merged: 25% weight (pr +list state=merged) +# - Code changes: 30% weight (pr +files) +# - Issue comments: 15% weight (issue +view) +# - Team member: 15% weight (repo +members) +# ───────────────────────────────────────────────────────────────────── + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/lib/common.sh" + +usage() { + echo "Usage: $0 --owner OWNER --repo REPO [--sample N] [--dry-run]" + echo "" + echo " --owner OWNER Repository owner" + echo " --repo REPO Repository name" + echo " --sample N Sample N PRs for code stats (default: 10)" + echo " --dry-run Preview actions without executing" + exit 1 +} + +DRY_RUN=false +OWNER="" +REPO="" +SAMPLE_SIZE=10 + +while [[ $# -gt 0 ]]; do + case "$1" in + --owner) OWNER="$2"; shift 2 ;; + --repo) REPO="$2"; shift 2 ;; + --sample) SAMPLE_SIZE="$2"; shift 2 ;; + --dry-run) DRY_RUN="true"; shift ;; + --help|-h) usage ;; + *) log_err "Unknown arg: $1"; usage ;; + esac +done + +check_auth +require_owner_repo + +REPORT_FILE="contrib-report-$OWNER-$REPO.html" + +# ───────────────────────────────────────────────────────────────────── +log_title "Contributor Growth System: $OWNER/$REPO" +# ───────────────────────────────────────────────────────────────────── + +# ── Step 1: Collect Data ───────────────────────────────────────────── +log_step "Collecting data..." + +ISSUES_OPEN=$(gl_check issue +list --owner "$OWNER" --repo "$REPO" --state open --limit 100) +ISSUES_CLOSED=$(gl_run issue +list --owner "$OWNER" --repo "$REPO" --state closed --limit 100) +OPEN_COUNT=$(echo "$ISSUES_OPEN" | jq '.data.issues | length' 2>/dev/null || echo "0") +CLOSED_COUNT=$(echo "$ISSUES_CLOSED" | jq '.data.issues | length' 2>/dev/null || echo "0") + +PRS_MERGED=$(gl_run pr +list --owner "$OWNER" --repo "$REPO" --state merged --limit 100) +PR_MERGED_COUNT=$(echo "$PRS_MERGED" | jq '(.data.issues // .data.pulls // .data | if type == "array" then . else [] end) | length' 2>/dev/null || echo "0") + +MEMBERS=$(gl_run repo +members --owner "$OWNER" --repo "$REPO" --limit 100) +# Members may be in .data.members[] or .data[] +MEMBER_COUNT=$(echo "$MEMBERS" | jq '(.data.members // .data | if type == "array" then . else [] end) | length' 2>/dev/null || echo "0") + +log_ok "Issues(open:$OPEN_COUNT closed:$CLOSED_COUNT) PRs(merged:$PR_MERGED_COUNT) Members:$MEMBER_COUNT" + +# ── Step 2: Build Contributor Data ─────────────────────────────────── +log_step "Building contributor profiles..." + +declare -A C_ISSUES C_MERGED C_ADDITIONS C_DELETIONS C_COMMENTS C_IS_MEMBER + +# Issues +for i in $(seq 0 $((OPEN_COUNT - 1))); do + A=$(echo "$ISSUES_OPEN" | jq -r ".data.issues[$i].author.login // empty") + [[ -n "$A" ]] && C_ISSUES["$A"]=$(( ${C_ISSUES["$A"]:-0} + 1 )) +done +for i in $(seq 0 $((CLOSED_COUNT - 1))); do + A=$(echo "$ISSUES_CLOSED" | jq -r ".data.issues[$i].author.login // empty") + [[ -n "$A" ]] && C_ISSUES["$A"]=$(( ${C_ISSUES["$A"]:-0} + 1 )) +done + +# Merged PRs + code stats +log_step "Analyzing PR code changes (sampling $SAMPLE_SIZE)..." +PR_SAMPLE=$((PR_MERGED_COUNT > SAMPLE_SIZE ? SAMPLE_SIZE : PR_MERGED_COUNT)) +PR_DATA_PATH='(.data.issues // .data.pulls // .data | if type == "array" then . else [] end)' +for i in $(seq 0 $((PR_MERGED_COUNT - 1))); do + A=$(echo "$PRS_MERGED" | jq -r "$PR_DATA_PATH[$i].author_login // $PR_DATA_PATH[$i].author.login // empty") + ID=$(echo "$PRS_MERGED" | jq -r "$PR_DATA_PATH[$i].pull_request_number // $PR_DATA_PATH[$i].number // $PR_DATA_PATH[$i].id // empty") + [[ -n "$A" ]] && C_MERGED["$A"]=$(( ${C_MERGED["$A"]:-0} + 1 )) + if [[ $i -lt $PR_SAMPLE ]] && [[ -n "$ID" ]]; then + FILES=$(gl_run pr +files --owner "$OWNER" --repo "$REPO" --id "$ID" 2>&1) + ADD=$(echo "$FILES" | jq -r '[.data.files[]? | (.additions // .addition // 0)] | add // 0' 2>/dev/null || echo "0") + DEL=$(echo "$FILES" | jq -r '[.data.files[]? | (.deletions // .deletion // 0)] | add // 0' 2>/dev/null || echo "0") + [[ -n "$A" ]] && C_ADDITIONS["$A"]=$(( ${C_ADDITIONS["$A"]:-0} + ADD )) + [[ -n "$A" ]] && C_DELETIONS["$A"]=$(( ${C_DELETIONS["$A"]:-0} + DEL )) + fi +done + +# Members +MEMBERS_DATA_PATH='(.data.members // .data | if type == "array" then . else [] end)' +for i in $(seq 0 $((MEMBER_COUNT - 1))); do + L=$(echo "$MEMBERS" | jq -r "$MEMBERS_DATA_PATH[$i].login // $MEMBERS_DATA_PATH[$i].username // empty") + [[ -n "$L" ]] && C_IS_MEMBER["$L"]="yes" +done + +# Comments (sample) +log_step "Sampling issue comments..." +for i in $(seq 0 $((OPEN_COUNT > 10 ? 9 : OPEN_COUNT - 1))); do + ID=$(echo "$ISSUES_OPEN" | jq -r ".data.issues[$i].id // empty") + [[ -z "$ID" ]] && continue + DETAIL=$(gl_run issue +view --owner "$OWNER" --repo "$REPO" --number "$ID" 2>&1) + C=$(echo "$DETAIL" | jq -r '.data.comment_journals_count // 0' 2>/dev/null || echo "0") + if [[ "$C" -gt 0 ]]; then + A=$(echo "$ISSUES_OPEN" | jq -r ".data.issues[$i].author.login // empty") + [[ -n "$A" ]] && C_COMMENTS["$A"]=$(( ${C_COMMENTS["$A"]:-0} + C )) + fi +done + +# ── Step 3: Calculate Scores ───────────────────────────────────────── +log_step "Calculating scores..." + +declare -A SCORES +ALL_USERS=() +for user in "${!C_ISSUES[@]}" "${!C_MERGED[@]}" "${!C_COMMENTS[@]}"; do + [[ -n "$user" ]] && ALL_USERS+=("$user") +done +ALL_USERS=($(printf '%s\n' "${ALL_USERS[@]}" | sort -u)) + +MAX_ISSUES=0; MAX_MERGED=0; MAX_LINES=0; MAX_COMMENTS=0 +for user in "${ALL_USERS[@]}"; do + [[ ${C_ISSUES[$user]:-0} -gt $MAX_ISSUES ]] && MAX_ISSUES=${C_ISSUES[$user]} + [[ ${C_MERGED[$user]:-0} -gt $MAX_MERGED ]] && MAX_MERGED=${C_MERGED[$user]} + LINES=$(( ${C_ADDITIONS[$user]:-0} + ${C_DELETIONS[$user]:-0} )) + [[ $LINES -gt $MAX_LINES ]] && MAX_LINES=$LINES + [[ ${C_COMMENTS[$user]:-0} -gt $MAX_COMMENTS ]] && MAX_COMMENTS=${C_COMMENTS[$user]} +done + +for user in "${ALL_USERS[@]}"; do + SCORE=$(awk -v iss="${C_ISSUES[$user]:-0}" -v mi="$MAX_ISSUES" \ + -v mer="${C_MERGED[$user]:-0}" -v mm="$MAX_MERGED" \ + -v lin="$(( ${C_ADDITIONS[$user]:-0} + ${C_DELETIONS[$user]:-0} ))" -v ml="$MAX_LINES" \ + -v com="${C_COMMENTS[$user]:-0}" -v mc="$MAX_COMMENTS" \ + -v mem="${C_IS_MEMBER[$user]:-no}" \ + 'BEGIN { + ni=(mi>0)?iss/mi:0; nm=(mm>0)?mer/mm:0; nl=(ml>0)?lin/ml:0; nc=(mc>0)?com/mc:0; ms=(mem=="yes")?1:0; + printf "%.1f", (ni*15+nm*25+nl*30+nc*15+ms*15) + }') + SCORES["$user"]="$SCORE" +done + +# ── Step 4: Display Rankings ───────────────────────────────────────── +log_title "Contributor Rankings" +echo "" +printf " ${BOLD}%-4s %-18s %-8s %-8s %-12s %-10s %-8s %s${NC}\n" "Rank" "Contributor" "Issues" "Merged" "+/- Lines" "Comments" "Score" "Badge" +echo " ──── ─────────────────── ──────── ──────── ──────────── ────────── ──────── ─────────────" + +TEMP=$(mktemp) +for u in "${!SCORES[@]}"; do echo "${SCORES[$u]} $u" >> "$TEMP"; done + +RANK=1 +sort -rn "$TEMP" | while read -r score user; do + iss=${C_ISSUES[$user]:-0}; mer=${C_MERGED[$user]:-0} + add=${C_ADDITIONS[$user]:-0}; del=${C_DELETIONS[$user]:-0} + com=${C_COMMENTS[$user]:-0}; si=${score%.*} + if [[ $si -ge 80 ]]; then B="Champion" + elif [[ $si -ge 60 ]]; then B="Core Contributor" + elif [[ $si -ge 40 ]]; then B="Active Contributor" + elif [[ $si -ge 20 ]]; then B="Contributor" + else B="Newcomer" + fi + printf " %-4d %-18s %-8d %-8d +%-6d/-%-4d %-10d %-8s %s\n" "$RANK" "$user" "$iss" "$mer" "$add" "$del" "$com" "$score" "$B" + RANK=$((RANK + 1)) +done +rm -f "$TEMP" + +# ── Step 5: Generate HTML Report ───────────────────────────────────── +log_title "Generating HTML Report" + +# Build JSON data for charts +PIE_DATA="" +TABLE_ROWS="" +RANK=1 + +TEMP2=$(mktemp) +for u in "${!SCORES[@]}"; do echo "${SCORES[$u]} $u" >> "$TEMP2"; done + +sort -rn "$TEMP2" | while read -r score user; do + iss=${C_ISSUES[$user]:-0}; mer=${C_MERGED[$user]:-0} + add=${C_ADDITIONS[$user]:-0}; del=${C_DELETIONS[$user]:-0} + lin=$((add + del)); com=${C_COMMENTS[$user]:-0} + si=${score%.*} + if [[ $si -ge 80 ]]; then B="Champion" + elif [[ $si -ge 60 ]]; then B="Core Contributor" + elif [[ $si -ge 40 ]]; then B="Active Contributor" + elif [[ $si -ge 20 ]]; then B="Contributor" + else B="Newcomer" + fi + # Output as CSV for processing + echo "$RANK|$user|$iss|$mer|$lin|$com|$score|$B|$add|$del" + RANK=$((RANK + 1)) +done > "$TEMP2.csv" +rm -f "$TEMP2" + +# Generate HTML +cat > "$REPORT_FILE" << 'HTMLHEAD' + + + + + + Contributor Report + + + + +
    +
    +

    Contributor Report

    +HTMLHEAD + +echo "

    $OWNER/$REPO - Team Contribution Analysis

    " >> "$REPORT_FILE" +echo "
    " >> "$REPORT_FILE" + +# Stats cards +TOTAL_ISSUES=$((OPEN_COUNT + CLOSED_COUNT)) +echo "
    " >> "$REPORT_FILE" +echo "
    ${#SCORES[@]}
    Contributors
    " >> "$REPORT_FILE" +echo "
    $TOTAL_ISSUES
    Total Issues
    " >> "$REPORT_FILE" +echo "
    $PR_MERGED_COUNT
    Merged PRs
    " >> "$REPORT_FILE" +echo "
    " >> "$REPORT_FILE" + +# Pie chart +echo "
    " >> "$REPORT_FILE" +echo "

    Score Distribution

    " >> "$REPORT_FILE" +echo "
    " >> "$REPORT_FILE" +echo "
    " >> "$REPORT_FILE" + +# Rankings table +echo "
    " >> "$REPORT_FILE" +echo "

    Detailed Rankings

    " >> "$REPORT_FILE" +echo " " >> "$REPORT_FILE" + +PIE_JSON="" +while IFS='|' read -r rank user iss mer lin com score badge add del; do + cls=""; [[ $rank -eq 1 ]] && cls=" rank-1" + [[ $rank -eq 2 ]] && cls=" rank-2" + [[ $rank -eq 3 ]] && cls=" rank-3" + + badge_cls="newcomer" + [[ "$badge" == "Champion" ]] && badge_cls="champion" + [[ "$badge" == "Core Contributor" ]] && badge_cls="core" + [[ "$badge" == "Active Contributor" ]] && badge_cls="active" + [[ "$badge" == "Contributor" ]] && badge_cls="contributor" + + echo " " >> "$REPORT_FILE" + PIE_JSON+="{value: $score, name: '$user'}," +done < "$TEMP2.csv" + +echo "
    RankContributorIssuesMerged PRsCode LinesCommentsScoreBadge
    $rank@$user$iss$mer$lin$com$score$badge
    " >> "$REPORT_FILE" +echo "
    " >> "$REPORT_FILE" + +# Weight info +echo "
    " >> "$REPORT_FILE" +echo "

    Scoring System (AHP Weights)

    " >> "$REPORT_FILE" +echo "
    " >> "$REPORT_FILE" +echo "
    " >> "$REPORT_FILE" +echo "
    Issues Created15%
    " >> "$REPORT_FILE" +echo "
    PRs Merged25%
    " >> "$REPORT_FILE" +echo "
    Code Changes30%
    " >> "$REPORT_FILE" +echo "
    Issue Comments15%
    " >> "$REPORT_FILE" +echo "
    Team Member15%
    " >> "$REPORT_FILE" +echo "
    " >> "$REPORT_FILE" +echo "
    " >> "$REPORT_FILE" +echo "
    " >> "$REPORT_FILE" + +# JavaScript +cat >> "$REPORT_FILE" << HTMLFOOT +
    + + + +HTMLFOOT + +rm -f "$TEMP2.csv" +log_ok "HTML report: $REPORT_FILE" + +# ── Step 6: Publish to Wiki ────────────────────────────────────────── +log_step "Publishing to Wiki..." + +WIKI_CONTENT="# Contributor Leaderboard - $OWNER/$REPO + +*Generated: $(date '+%Y-%m-%d %H:%M')* + +## Scoring System + +| Dimension | Weight | Source | +|-----------|--------|--------| +| Issues Created | 15% | \`issue +list\` | +| PRs Merged | 25% | \`pr +list state=merged\` | +| Code Changes | 30% | \`pr +files\` | +| Issue Comments | 15% | \`issue +view\` | +| Team Member | 15% | \`repo +members\` | + +## Rankings + +| Rank | Contributor | Issues | Merged | Lines | Comments | Score | Badge | +|------|-------------|--------|--------|-------|----------|-------|-------| +" + +TEMP3=$(mktemp) +for u in "${!SCORES[@]}"; do echo "${SCORES[$u]} $u" >> "$TEMP3"; done +RANK=1 +sort -rn "$TEMP3" | while read -r score user; do + iss=${C_ISSUES[$user]:-0}; mer=${C_MERGED[$user]:-0} + lin=$(( ${C_ADDITIONS[$user]:-0} + ${C_DELETIONS[$user]:-0} )) + com=${C_COMMENTS[$user]:-0}; si=${score%.*} + if [[ $si -ge 80 ]]; then B="Champion" + elif [[ $si -ge 60 ]]; then B="Core" + elif [[ $si -ge 40 ]]; then B="Active" + elif [[ $si -ge 20 ]]; then B="Contributor" + else B="Newcomer" + fi + echo "| $RANK | @$user | $iss | $mer | $lin | $com | $score | $B |" + RANK=$((RANK + 1)) +done > "$TEMP3.rows" +WIKI_CONTENT+=$(cat "$TEMP3.rows") +rm -f "$TEMP3" "$TEMP3.rows" + +WIKI_CONTENT+=" + +--- +*Auto-generated by gitlink-cli*" + +# Use timestamp to avoid title conflicts with cached deletions +WIKI_TITLE="Contributor Leaderboard $(date '+%Y-%m-%d')" +WIKI_RESULT=$(gl_run wiki +create --owner "$OWNER" --repo "$REPO" \ + --title "$WIKI_TITLE" --content "$WIKI_CONTENT" 2>&1) || true + +if [[ "$(json_ok "$WIKI_RESULT")" == "true" ]]; then + log_ok "Published to Wiki: $WIKI_TITLE" +else + log_warn "Wiki publish failed" +fi + +# ───────────────────────────────────────────────────────────────────── +log_title "Complete" +echo " Contributors: ${#SCORES[@]}" +echo " HTML Report: $REPORT_FILE" +echo "" diff --git a/workflows/README.md b/workflows/README.md new file mode 100644 index 0000000..769d407 --- /dev/null +++ b/workflows/README.md @@ -0,0 +1,527 @@ +# GitLink CLI 工作流自动化 + +5 个端到端自动化场景,将 gitlink-cli 的 shortcut 命令串联成完整工作流,解决实际项目管理痛点。 + +--- + +## 环境准备 + +### 1. 安装 gitlink-cli + +```bash +# 确认已安装 +gitlink-cli version + +# 未安装则从项目根目录构建 +cd /home/kevin/gitlink-cli +make build +``` + +### 2. 安装 jq + +脚本用 `jq` 解析 CLI 返回的 JSON。 + +```bash +# Ubuntu/Debian +sudo apt-get install -y jq + +# macOS +brew install jq +``` + +### 3. 登录认证 + +```bash +# 方式一:交互式登录(推荐) +gitlink-cli auth login + +# 方式二:环境变量 +export GITLINK_TOKEN="你的私人令牌" +# 令牌获取:https://gitlink.org.cn → 个人设置 → 私人令牌 + +# 验证 +gitlink-cli auth status +# 应显示:✓ Logged in as 用户名 +``` + +### 4. 验证环境 + +```bash +# 测试 JSON 输出是否正常 +gitlink-cli issue +list --owner zzx-coder --repo gitlink-cli --state open --limit 3 --format json | jq '.ok' +# 应输出:true +``` + +--- + +## 五个场景 + +| # | 场景 | 脚本 | 串联命令 | 解决什么问题 | +|---|------|------|---------|-------------| +| 1 | 社区运营自动化 | `01-community-ops.sh` | 7 个 | Issue 积压无人处理、周报手写、Release Notes 手动整理 | +| 2 | 代码质量看门人 | `02-code-quality-gatekeeper.sh` | 7 个 | PR 审查效率低、质量标准不统一、AI 代码审查(基于 gitlink-code-review skill) | +| 3 | 项目一键初始化 | `03-project-init.sh` | 8 个 | 新建项目重复劳动多、README/许可证/CI/Issue/里程碑手动配 | +| 4 | 多仓库协同 | `04-multi-repo-collab.sh` | 7 个 | 跨仓库状态分散、缺乏统一视图 | +| 5 | 贡献者成长体系 | `05-contributor-growth.sh` | 6 个 | 贡献者活跃度难追踪、缺乏激励机制 | + +--- + +## 场景一:社区运营自动化 + +**脚本**: `01-community-ops.sh` + +### 解决什么问题 + +新 Issue 没人分类、不知道谁该负责、社区周报手写、发版时才手忙脚乱写 Release Notes。 + +### 工作流程 + +``` +issue +list → 读取所有 open Issue + ↓ +按关键词分类: Bug / Feature / Question / Docs + ↓ +issue +label-add → 自动打标签 + ↓ +repo +members → 获取仓库成员列表 +issue +update → 轮询分配负责人 + ↓ +pr +list → 统计本周合并的 PR +issue +list → 统计本周关闭的 Issue + ↓ +wiki +create → 发布社区周报到 Wiki + ↓ +release +create → 自动生成 Release Notes +``` + +### 串联的命令 + +| 步骤 | 命令 | 作用 | +|------|------|------| +| 1 | `issue +list` | 获取所有 open Issue | +| 2 | `issue +label-add` | 按分类打标签 (bug/feature/question/documentation) | +| 3 | `repo +members` | 获取仓库成员列表 | +| 4 | `issue +update` | 给 Bug/Feature Issue 分配负责人 | +| 5 | `pr +list` | 统计本周合并的 PR | +| 6 | `wiki +create` | 发布社区周报 | +| 7 | `release +create` | 自动生成 Release Notes | + +### 输出有什么用 + +- **标签分类**: 仓库 Issue 页面可按标签筛选,一目了然 +- **负责人分配**: 每个 Issue 有明确负责人,避免互相推诿 +- **Wiki 周报**: 团队和社区用户可在 Wiki 查看每周进展 +- **Release Notes**: 发版时无需手动整理变更 + +### 运行 + +```bash +bash workflows/01-community-ops.sh --owner 你的组织 --repo 你的仓库 + +# 示例 +bash workflows/01-community-ops.sh --owner zzx-coder --repo gitlink-cli +``` + +--- + +## 场景二:代码质量看门人 + +**脚本**: `02-code-quality-gatekeeper.sh` + +### 解决什么问题 + +PR 审查是代码质量的核心环节,但人工审查耗时且标准不统一。这个工作流加载 **gitlink-code-review skill** 的审查方法论,用 AI (Claude) 对 PR 进行四维度代码审查,自动打分评级,达标后自动合并。 + +### 工作流程 + +``` +pr +list → 获取所有 open PR + ↓ +pr +view → 读取 PR 详情 +pr +files → 获取变更文件列表 +pr +diff → 获取代码差异 + ↓ +加载 gitlink-code-review skill: + - 审查维度与检查项 + - 评分标准 (90-100 优秀, 75-89 良好, ...) + - 问题严重级别 (CRITICAL/HIGH/MEDIUM/LOW) + ↓ +┌─────────────────────────────────────┐ +│ AI 代码审查 (Claude + Skill) │ +│ 四维度评分 (各 0-25,总分 100): │ +│ - 代码质量: 复杂度、命名、注释 │ +│ - 安全性: SQL注入、XSS、敏感信息 │ +│ - 性能: 循环效率、资源泄漏、N+1 │ +│ - 可维护性: 重复、职责单一、耦合 │ +│ │ +│ 输出: │ +│ - 结构化问题清单 (severity+file+ │ +│ rule+description+suggestion) │ +│ - 优秀实践 (positive_notes) │ +│ - 改进建议 (recommendations) │ +│ - 总分 + PASS/FAIL │ +└─────────────────────────────────────┘ + ↓ +api POST /reviews → 发布审查评论到 PR + ↓ +ci +builds → 检查 CI 构建状态 + ↓ +pr +merge → 分数 >= 阈值 且 CI 通过 → 自动合并 +``` + +### AI 审查示例输出(基于 gitlink-code-review skill) + +``` +Overall Score: 88 / 100 +Code Quality: 23 / 25 +Security: 25 / 25 +Performance: 20 / 25 +Maintainability: 20 / 25 + +Issues Found: + - [LOW] quality: 条目格式说明中 PR 条目用 (@作者) 带括号,commit 条目用 (作者名) 不带 @ 前缀 + → 统一格式规范,建议 commit 条目也使用 (@作者) 格式 + - [LOW] maintainability: 示例中贡献者列表变更但完整变更日志链接仍指向旧仓库 + → 将变更日志链接中的 OWNER 也更新为与示例贡献者一致 + +Positive Notes: + + 变更目的清晰,所有文件的修改一致地贯彻了需求,无遗漏 + + 变更范围合理,仅修改文档和示例,不涉及代码逻辑变更,风险极低 + +Recommendations: + > 统一 PR 条目和 commit 条目的作者标注格式 + > 在 collect-data.md 中补充 author 字段为空时的降级处理说明 +``` + +### 串联的命令 + +| 步骤 | 命令 | 作用 | +|------|------|------| +| 1 | `pr +list` | 获取 open PR 列表 | +| 2 | `pr +view` | 读取 PR 详情(标题、作者、状态) | +| 3 | `pr +files` | 获取变更文件列表 | +| 4 | `pr +diff` | 获取代码差异内容 | +| 5 | `gitlink-code-review` | 加载 skill 的审查维度、检查项、评分标准 | +| 6 | `claude -p` | AI 按 skill 方法论进行四维度代码审查 | +| 7 | `api POST .../reviews` | 将审查评论发布到 PR | +| 8 | `pr +merge` | 质量分 >= 阈值且 CI 通过时自动合并 | + +### 输出有什么用 + +- **结构化评分**: 每个 PR 有 0-100 的质量评分,团队可设定统一合并门槛 +- **AI 问题清单**: 自动列出安全隐患、性能问题、代码质量问题,人工审查时重点关注 +- **PR 评论**: 审查结果直接评论在 PR 上,作者和审查者都能看到 +- **自动合并**: 高质量 PR 无需人工点击 + +### 运行 + +```bash +# 审查所有 open PR +bash workflows/02-code-quality-gatekeeper.sh --owner 你的组织 --repo 你的仓库 + +# 审查指定 PR +bash workflows/02-code-quality-gatekeeper.sh --owner 你的组织 --repo 你的仓库 --pr-id 42 + +# 自定义质量阈值(默认 80) +bash workflows/02-code-quality-gatekeeper.sh --owner 你的组织 --repo 你的仓库 --threshold 70 + +# 预览模式(不实际合并) +bash workflows/02-code-quality-gatekeeper.sh --owner 你的组织 --repo 你的仓库 --dry-run + +# 示例 +bash workflows/02-code-quality-gatekeeper.sh --owner zzx-coder --repo gitlink-cli --pr-id 20 +``` + +--- + +## 场景三:项目一键初始化 + +**脚本**: `03-project-init.sh` / `03-project-init.ps1` + +### 解决什么问题 + +新建项目仓库后,还要手动创建 README、写 LICENSE、配置 CI/CD、创建 Issue 和里程碑、设置分支保护、打初始 Release。一条命令搞定全部。 + +### 工作流程 + +``` +repo +create → 创建仓库 + ↓ +git clone → 克隆空仓库到本地 + ↓ +生成文件 → README.md (语言模板) + LICENSE (MIT) + CONTRIBUTING.md + .gitlink-ci.yml + ↓ +git add/commit/push → 将脚手架文件推送到仓库 + ↓ +milestone +create × 3 → 创建项目里程碑: + - v0.1.0 - MVP + - v0.2.0 - Feature Complete + - v1.0.0 - Production Ready + ↓ +issue +create × 5 → 创建初始待办 Issue (打标签): + - 搭建 CI/CD 流水线 + - 编写项目文档 + - 建立代码审查流程 + - 添加单元测试 + - 配置依赖管理 + ↓ +branch +protect → 保护 master 分支 + ↓ +release +create → 创建 v0.1.0 初始版本 +``` + +### 串联的命令 + +| 步骤 | 命令 | 作用 | +|------|------|------| +| 1 | `repo +create` | 创建新仓库 | +| 2 | `git clone` | 克隆空仓库到本地临时目录 | +| 3 | 文件生成 | 生成 README.md / LICENSE / CONTRIBUTING.md / .gitlink-ci.yml | +| 4 | `git add/commit/push` | 推送脚手架文件到 master 分支 | +| 5 | `milestone +create` | 创建 3 个项目里程碑 (MVP → Production) | +| 6 | `issue +create` | 创建 5 个初始 Issue 并打标签 | +| 7 | `branch +protect` | 设置 master 分支保护规则 | +| 8 | `release +create` | 创建 v0.1.0 初始版本 | + +### 输出有什么用 + +- **开箱即用**: 克隆后仓库已有 README、LICENSE、CI 配置,可直接开始开发 +- **仓库内文件**: README/LICENSE/CONTRIBUTING/CI 是仓库里的真实文件,不是 Wiki 页面 +- **里程碑规划**: 从 MVP 到正式版的路线图已建立 +- **标准化 Issue**: 关键待办已创建好,团队可直接认领 +- **分支保护**: 防止直接 push 到 master,强制走 PR 流程 +- **首个 Release**: 项目从创建之初就有版本管理 + +### 运行 + +```bash +# Go 项目 +bash workflows/03-project-init.sh --owner 你的组织 --name my-go-app --description "我的Go应用" --lang go + +# Python 项目(私有) +bash workflows/03-project-init.sh --owner 你的组织 --name my-api --description "REST API服务" --lang python --private + +# Node.js 项目 +bash workflows/03-project-init.sh --owner 你的组织 --name my-web --description "Web前端" --lang node + +# Java 项目 +bash workflows/03-project-init.sh --owner 你的组织 --name my-service --description "微服务" --lang java +``` + +--- + +## 场景四:多仓库协同 + +**脚本**: `04-multi-repo-collab.sh` + +### 解决什么问题 + +当一个组织有多个仓库时,管理者需要逐个查看每个仓库的 Issue、PR、Release 状态。这个工作流汇总所有仓库数据,生成一个 HTML 仪表盘,并支持一键协调发版。 + +### 工作流程 + +``` +repo +list → 列出组织下所有仓库 + ↓ +对每个仓库: + issue +list → 获取 open/closed Issue + pr +list → 获取 open/merged PR + release +list → 获取最新 Release + ↓ +生成 HTML 仪表盘: + - 总览卡片: 仓库数、Open Issue、Open PR、总活动量 + - 详情表格: 每个仓库的 Issue/PR/Release 状态 + - 健康度: Healthy / Moderate / Needs Attention + ↓ +(可选)release +create → 一键为所有仓库创建同一版本号的 Release +``` + +### 串联的命令 + +| 步骤 | 命令 | 作用 | +|------|------|------| +| 1 | `repo +list` | 列出组织下所有仓库 | +| 2 | `issue +list` | 获取每个仓库的 Issue 数据 | +| 3 | `pr +list` | 获取每个仓库的 PR 数据 | +| 4 | `release +list` | 获取每个仓库的最新 Release | +| 5 | 生成 HTML | 输出可视化仪表盘 | +| 6 | `release +create` | (可选)协调发版 | + +### 输出有什么用 + +- **统一视图**: 一个 HTML 页面看到组织所有仓库的健康状态 +- **健康度预警**: Open Issue 超 10 个标橙色,超 20 个标红色 +- **协调发版**: 多个关联仓库需要同步发版时,一条命令搞定 +- **可分享**: HTML 文件可直接发给团队或部署到内部网站 + +### 运行 + +```bash +# 扫描组织下所有仓库 +bash workflows/04-multi-repo-collab.sh --org 你的组织 + +# 只看指定仓库 +bash workflows/04-multi-repo-collab.sh --org 你的组织 --repos "repo-a,repo-b,repo-c" + +# 生成仪表盘 + 协调发版 +bash workflows/04-multi-repo-collab.sh --org 你的组织 --release v2.0.0 + +# 自定义输出文件 +bash workflows/04-multi-repo-collab.sh --org 你的组织 --output my-dashboard.html + +# 示例 +bash workflows/04-multi-repo-collab.sh --org zzx-coder +``` + +运行后在当前目录生成 `dashboard.html`,浏览器打开即可查看。 + +--- + +## 场景五:贡献者成长体系 + +**脚本**: `05-contributor-growth.sh` + +### 解决什么问题 + +开源项目需要激励贡献者持续参与,但很难量化每个人的贡献。这个工作流自动追踪贡献者活动,计算贡献分数,生成排行榜,并可选自动颁发成就徽章。 + +### 工作流程 + +``` +contrib +report → 生成带 ECharts 饼图的 HTML 贡献报告 + ↓ +issue +list → 统计 Issue 活动 +pr +list → 统计 PR 活动 +api GET /contributors → 获取提交数等 API 统计 + ↓ +计算贡献分数 (AHP 权重模型): + - PR 被合并: 10 分 + - 提交 PR: 5 分 + - 创建/解决 Issue: 3 分 + - 代码提交: 2 分 + ↓ +评定等级: + Champion (冠军) >= 50 分 + Core Contributor >= 30 分 + Active Contributor >= 15 分 + Contributor >= 5 分 + Newcomer (新人) < 5 分 + ↓ +(可选)issue +create → 自动创建徽章颁发 Issue + ↓ +wiki +create → 发布排行榜到 Wiki +``` + +### 串联的命令 + +| 步骤 | 命令 | 作用 | +|------|------|------| +| 1 | `contrib +report` | 生成 HTML 贡献报告(带 ECharts 图表) | +| 2 | `issue +list` | 统计 open/closed Issue 活动 | +| 3 | `pr +list` | 统计 open/merged PR 活动 | +| 4 | `api GET /contributors` | 获取 API 级别的贡献者统计 | +| 5 | `issue +create` | (可选)自动颁发成就徽章 | +| 6 | `wiki +create` | 发布排行榜到 Wiki | + +### 输出有什么用 + +- **HTML 贡献报告**: 可视化展示贡献分布,适合团队会议演示 +- **贡献排行榜**: 量化每个人的贡献,公开透明 +- **Wiki 排行榜**: 永久保存,贡献者可随时查看排名 +- **徽章激励**: 通过 Issue 颁发徽章,增强成就感和归属感 + +### 运行 + +```bash +# 基本运行 +bash workflows/05-contributor-growth.sh --owner 你的组织 --repo 你的仓库 + +# 自定义统计周期(默认 30 天) +bash workflows/05-contributor-growth.sh --owner 你的组织 --repo 你的仓库 --period 90 + +# 启用自动颁发徽章 +bash workflows/05-contributor-growth.sh --owner 你的组织 --repo 你的仓库 --award + +# 示例 +bash workflows/05-contributor-growth.sh --owner zzx-coder --repo gitlink-cli --award +``` + +--- + +## 通用参数 + +| 参数 | 说明 | +|------|------| +| `--owner OWNER` | 仓库所属组织或用户(在 git 仓库内可自动检测) | +| `--repo REPO` | 仓库名称(在 git 仓库内可自动检测) | +| `--dry-run` | 预览模式,不实际执行写操作 | +| `--help` | 显示帮助信息 | + +--- + +## 项目结构 + +``` +workflows/ +├── lib/ +│ └── common.sh # 共享工具库(认证、JSON解析、CLI封装、日志) +├── 01-community-ops.sh # 场景一:社区运营自动化 +├── 02-code-quality-gatekeeper.sh # 场景二:代码质量看门人(AI审查) +├── 03-project-init.sh # 场景三:项目一键初始化 +├── 04-multi-repo-collab.sh # 场景四:多仓库协同 +├── 05-contributor-growth.sh # 场景五:贡献者成长体系 +├── test.sh # 测试套件 +└── README.md # 本文档 +``` + +### 共享库 `lib/common.sh` + +所有脚本共享的基础设施: + +| 函数 | 作用 | +|------|------| +| `check_auth` | 检查认证状态(环境变量 或 CLI 登录) | +| `gl_run` | CLI 封装,自动追加 `--format json` | +| `gl_check` | CLI 封装 + JSON 格式校验 + ok 字段检查 | +| `json_ok` / `json_get` / `json_error` | JSON 解析工具 | +| `detect_owner_repo` | 从 git remote 自动检测 owner/repo | +| `log_step` / `log_ok` / `log_warn` / `log_err` | 彩色日志输出 | + +--- + +## 涉及的 Skill + +工作流通过加载 Skill 的审查方法论、分类规则和模板来指导 AI 分析: + +| Skill | 被哪个场景使用 | 作用 | +|-------|-------------|------| +| `gitlink-code-review` | 场景 2 | **已集成** — 加载审查维度、检查项、评分标准,指导 AI 代码审查 | +| `gitlink-issue-triage` | 场景 1 | Issue 分类规则(关键词匹配、优先级判定) | +| `gitlink-changelog` | 场景 1 | Release Notes 生成模板(按类型分组、贡献者列表) | +| `gitlink-health` | 场景 4 | 项目健康度评分体系(100 分制) | +| `gitlink-onboard` | 场景 5 | 新人引导和 Issue 推荐规则 | +| `gitlink-workflow` | 全部 | 基础工作流编排(Issue 分类、PR 审查、发版、Sprint 报告) | + +> 场景 2 的 `gitlink-code-review` skill 已完整集成:脚本运行时自动从 `skills/gitlink-code-review/SKILL.md` 加载审查维度和检查项,传给 AI 作为审查方法论。其他场景使用关键词匹配等规则引擎。 + +--- + +## 测试 + +```bash +# 运行测试套件(使用真实 GitLink 仓库验证) +bash workflows/test.sh + +# 指定仓库 +bash workflows/test.sh zzx-coder gitlink-cli +``` + +测试覆盖: +- 认证状态检查 +- CLI JSON 输出格式验证 +- 数据字段提取(issue/PR/repo/release/member/contributor) +- PR 文件和 Diff 内容解析 +- Issue/PR View 接口 +- Wiki / Label 列表接口 +- common.sh 工具函数 +- 所有脚本语法校验 diff --git a/workflows/SKILL.md b/workflows/SKILL.md new file mode 100644 index 0000000..cd61256 --- /dev/null +++ b/workflows/SKILL.md @@ -0,0 +1,145 @@ +--- +name: gitlink-workflows +version: 1.1.0 +description: "GitLink 自动化工作流总入口:提供社区运营、代码审查、项目初始化、多仓库协同、贡献者成长、Release Notes、科研辅助等自动化功能的选择菜单。" +metadata: + requires: + bins: ["gitlink-cli"] + triggers: + - "工作流" + - "workflow" + - "自动化" + - "帮我跑" + - "执行" + - "科研" + - "research" + - "论文" + - "citation" + - "知识图谱" + - "knowledge graph" + - "热点追踪" + - "合规检查" + - "复现性" + - "reproducibility" + - "协作匹配" + - "进度跟踪" + - "项目洞察" + - "引用格式" +--- + +# gitlink-workflows(自动化工作流总入口) + +**CRITICAL — GitLink 操作只能用 `gitlink-cli`。禁止用 `gh`(GitHub CLI)操作 GitLink 资源。** + +> **前置条件:** 先阅读 [`../skills/gitlink-shared/SKILL.md`](../skills/gitlink-shared/SKILL.md) 了解认证和全局参数。 + +## 功能菜单 + +当用户说"工作流"、"自动化"、"帮我跑"等触发词时,展示以下菜单让用户选择。 + +当用户说"科研"、"论文"、"知识图谱"、"合规检查"等触发词时,直接跳转到科研辅助菜单。 + +``` +====== GitLink 自动化工作流 ====== + +请选择要执行的工作流: + + 1. 社区运营自动化 + → Issue 自动分类、负责人分配、周报生成、Release Notes + + 2. 代码质量审查 + → PR Review、AI 四维度评分、自动合并 + + 3. 项目一键初始化 + → 创建仓库、README/LICENSE/CI 文件、里程碑、初始 Issues、分支保护 + + 4. 多仓库协同 + → 跨仓库 Issue/PR 追踪、状态 Dashboard、协同发版 + + 5. 贡献者成长体系 + → 数据收集、AHP 评分、排行榜、Wiki 发布、Badge 颁发 + + 6. Release Notes 生成 + → 收集 commits/PR/Issue、分类整理、生成 Notes + + 7. 科研辅助系统 🆕 + → 项目洞察、热点追踪、合规复现、协作匹配、进度预警、论文引用 + +请输入编号(1-7)或功能名称: +``` + +## 用户输入 → 工作流映射 + +| 用户输入 | 执行的工作流 | +|----------|--------------| +| `1` 或 "社区运营" | 读取 `workflow-community-ops.md` | +| `2` 或 "代码审查" | 读取 `workflow-pr-review.md` | +| `3` 或 "项目初始化" | 读取 `workflow-repo-setup.md` | +| `4` 或 "多仓库" | 读取 `workflow-multi-repo.md` | +| `5` 或 "贡献者" | 读取 `workflow-contributor-growth.md` | +| `6` 或 "Release Notes" | 读取 `workflow-release-notes.md` | +| `7` 或 "科研" / "科研辅助" | 读取 `gitlink-research` Skill → 展示科研辅助菜单 | + +## 执行流程 + +1. **展示菜单** — 列出所有可用工作流 +2. **获取用户选择** — 用户输入编号或功能名称 +3. **读取 Reference** — 根据选择读取对应的 reference 文件 +4. **确认参数** — 询问必要的参数(owner/repo 等) +5. **执行工作流** — 按照 reference 文件的步骤执行 +6. **展示结果** — 输出执行结果和摘要 + +## 工作流 Reference 文件 + +所有工作流的详细步骤在以下 reference 文件中: + +| 工作流 | Reference 文件 | +|--------|----------------| +| 社区运营 | [`workflow-community-ops.md`](../skills/gitlink-workflow/references/workflow-community-ops.md) | +| 代码审查 | [`workflow-pr-review.md`](../skills/gitlink-workflow/references/workflow-pr-review.md) | +| 项目初始化 | [`workflow-repo-setup.md`](../skills/gitlink-workflow/references/workflow-repo-setup.md) | +| 多仓库协同 | [`workflow-multi-repo.md`](../skills/gitlink-workflow/references/workflow-multi-repo.md) | +| 贡献者成长 | [`workflow-contributor-growth.md`](../skills/gitlink-workflow/references/workflow-contributor-growth.md) | +| Release Notes | [`workflow-release-notes.md`](../skills/gitlink-workflow/references/workflow-release-notes.md) | +| 科研辅助 | [`SKILL.md`](../skills/gitlink-research/SKILL.md) — GitLink 科研辅助系统总入口 | + +## 快捷触发 + +用户也可以直接说特定意图,跳过菜单直接执行: + +| 用户说的话 | 直接执行 | +|------------|----------| +| "帮我跑一下社区运营" | `workflow-community-ops` | +| "审查一下这个 PR" | `workflow-pr-review` | +| "创建一个新项目" | `workflow-repo-setup` | +| "看看组织下所有仓库" | `workflow-multi-repo` | +| "生成贡献者排行榜" | `workflow-contributor-growth` | +| "生成 Release Notes" | `workflow-release-notes` | +| "分析这个仓库的科研价值" | `gitlink-research` 场景 1 | +| "帮我追踪 NLP 热点" | `gitlink-research` 场景 2 | +| "检查项目的可复现性" | `gitlink-research` 场景 3 | +| "找科研合作者" | `gitlink-research` 场景 4 | +| "看看项目进度有没有风险" | `gitlink-research` 场景 5 | +| "生成这个项目的论文引用" | `gitlink-research` 场景 6 | + +## 参数说明 + +| 参数 | 说明 | 获取方式 | +|------|------|----------| +| `--owner` | 仓库所有者 | 自动从 git remote 解析,或询问用户 | +| `--repo` | 仓库名称 | 自动从 git remote 解析,或询问用户 | +| `--org` | 组织名称 | 询问用户(多仓库协同时需要) | + +## 注意事项 + +- 所有写入操作前必须确认用户意图 +- 自动从 git remote 解析 owner/repo,解析失败时询问用户 +- 每个工作流的具体步骤见对应的 reference 文件 +- 支持 `--dry-run` 预览模式(部分工作流) + +## References + +- [gitlink-shared](../skills/gitlink-shared/SKILL.md) — 认证和全局参数 +- [gitlink-workflow](../skills/gitlink-workflow/SKILL.md) — AI 工作流详情 +- [gitlink-changelog](../skills/gitlink-changelog/SKILL.md) — Release Notes 生成 +- [gitlink-research](../skills/gitlink-research/SKILL.md) — GitLink 科研辅助系统(6 大场景) diff --git a/workflows/academic/06-research-insights.ps1 b/workflows/academic/06-research-insights.ps1 new file mode 100644 index 0000000..d7fd011 --- /dev/null +++ b/workflows/academic/06-research-insights.ps1 @@ -0,0 +1,370 @@ +# GitLink Research - Scenario 1: Repository Project Insight (PowerShell) +param( + [string]$Owner, [string]$Repo, [string]$Output = "", + [switch]$NoWiki, [switch]$DryRun +) + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Import-Module "$ScriptDir\..\lib\common.psm1" -Force -WarningAction SilentlyContinue + +$ErrorActionPreference = "Continue" +Check-Auth +$resolved = Resolve-OwnerRepo -Owner $Owner -Repo $Repo +$Owner = $resolved.Owner; $Repo = $resolved.Repo +$Today = Get-Date -Format "yyyy-MM-dd" +$OutputDir = Join-Path $ScriptDir "..\output" +if (-not (Test-Path $OutputDir)) { New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null } +$OutputFile = if ($Output) { $Output } else { Join-Path $OutputDir "research-insights-${Repo}-${Today}.html" } + +Log-Title "GitLink Research - Repository Project Insight" + +# 1. Repo metadata +Log-Step "1/6 Fetching repo metadata..." +$repoJson = Invoke-GLCheck @("repo", "+info", "--owner", $Owner, "--repo", $Repo) +$repoName = if ($repoJson.data.name) { $repoJson.data.name } elseif ($repoJson.data.full_name) { $repoJson.data.full_name } else { $Repo } +$repoDesc = if ($repoJson.data.description) { $repoJson.data.description } else { "No description" } +$repoLang = if ($repoJson.data.language) { $repoJson.data.language } else { "Unknown" } +$stars = if ($repoJson.data.stars_count) { [int]$repoJson.data.stars_count } elseif ($repoJson.data.stars) { [int]$repoJson.data.stars } else { 0 } +$forks = if ($repoJson.data.forks_count) { [int]$repoJson.data.forks_count } elseif ($repoJson.data.forks) { [int]$repoJson.data.forks } else { 0 } +$openIssues = if ($repoJson.data.open_issues_count) { [int]$repoJson.data.open_issues_count } else { 0 } +$updatedAt = if ($repoJson.data.updated_at) { $repoJson.data.updated_at } else { "" } + +Log-Info " Name: $repoName | Lang: $repoLang | Stars: $stars" + +# 2. Issues +Log-Step "2/6 Collecting Issue data..." +$openIssuesJson = Invoke-GLCheck @("issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "open", "--limit", "100") +$closedIssuesJson = Invoke-GLCheck @("issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "closed", "--limit", "100") +$totalOpen = if ($openIssuesJson.ok) { $d = if ($openIssuesJson.data.issues) { $openIssuesJson.data.issues } else { $openIssuesJson.data }; @($d).Count } else { 0 } +$totalClosed = if ($closedIssuesJson.ok) { $d = if ($closedIssuesJson.data.issues) { $closedIssuesJson.data.issues } else { $closedIssuesJson.data }; @($d).Count } else { 0 } + +# 3. PRs +Log-Step "3/6 Collecting PR data..." +$mergedPrsJson = Invoke-GLCheck @("pr", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "merged", "--limit", "100") +$openPrsJson = Invoke-GLCheck @("pr", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "open", "--limit", "50") +$md = if ($mergedPrsJson.data.issues) { $mergedPrsJson.data.issues } elseif ($mergedPrsJson.data.pulls) { $mergedPrsJson.data.pulls } else { $mergedPrsJson.data } +$totalMerged = if ($mergedPrsJson.ok) { @($md).Count } else { 0 } +$od = if ($openPrsJson.data.issues) { $openPrsJson.data.issues } elseif ($openPrsJson.data.pulls) { $openPrsJson.data.pulls } else { $openPrsJson.data } +$totalOpenPrs = if ($openPrsJson.ok) { @($od).Count } else { 0 } + +# 4. Releases +Log-Step "4/6 Collecting Release data..." +$releasesJson = Invoke-GLCheck @("release", "+list", "--owner", $Owner, "--repo", $Repo, "--limit", "20") +$releaseCount = if ($releasesJson.ok) { @($releasesJson.data).Count } else { 0 } + +# 5. CI +Log-Step "5/6 Collecting CI data..." +$ciPipelines = Get-CIPipelines $Owner $Repo +$ciBuilds = $ciPipelines.Count + +# 6. Members +Log-Step "6/6 Fetching contributors..." +$membersJson = Invoke-GLCheck @("repo", "+members", "--owner", $Owner, "--repo", $Repo, "--limit", "50") +$memberCount = if ($membersJson.ok) { + $d = if ($membersJson.data.members) { $membersJson.data.members } else { $membersJson.data } + if ($d -is [array]) { $d.Count } else { 0 } +} else { 0 } + +# Hotness calculation +$starsNorm = [Math]::Min($stars / 1000 * 100, 100) +$forksNorm = [Math]::Min($forks / 200 * 100, 100) +$issuesScore = [Math]::Min($totalOpen / 50 * 100, 100) +$prsScore = [Math]::Min(($totalMerged + $totalOpenPrs) / 30 * 100, 100) +$relScore = [Math]::Min($releaseCount / 10 * 100, 100) +$daysSince = if ($updatedAt) { try { ((Get-Date) - [DateTime]$updatedAt.Substring(0, 10)).Days } catch { 365 } } else { 365 } +$recency = if ($daysSince -le 30) { 100 } elseif ($daysSince -le 90) { 50 } else { 10 } +$hotness = [Math]::Round($starsNorm * 0.15 + $forksNorm * 0.10 + $issuesScore * 0.20 + $prsScore * 0.20 + $relScore * 0.15 + $recency * 0.10, 1) + +$prMergeRate = if (($totalMerged + $totalOpenPrs) -gt 0) { [Math]::Round($totalMerged / ($totalMerged + $totalOpenPrs) * 100, 1) } else { 0 } + +Log-Ok "Hotness score: ${hotness}/100" +Log-Info " Issues: $totalOpen open / $totalClosed closed" +Log-Info " PR merge rate: ${prMergeRate}% | Releases: $releaseCount | Contributors: $memberCount" + +# Additional data for HTML report +$ciPassRate = 0 +if ($ciBuilds -gt 0) { + $ciSuccess = 0 + foreach ($b in $ciPipelines) { + if (Test-CISuccess $b) { $ciSuccess++ } + } + $ciPassRate = [Math]::Round($ciSuccess / $ciBuilds * 100, 1) +} + +$daysSinceUpdate = 365 +if ($updatedAt -and $updatedAt.Length -ge 10) { + try { $daysSinceUpdate = ((Get-Date) - [DateTime]$updatedAt.Substring(0, 10)).Days } catch { $daysSinceUpdate = 365 } +} +$recencyFactor = if ($daysSinceUpdate -le 30) { 100 } elseif ($daysSinceUpdate -le 90) { 50 } else { 10 } + +$hotnessLabel = if ($hotness -ge 50) { "Hot" } elseif ($hotness -ge 30) { "Warm" } else { "Cool" } + +$starsNorm = [Math]::Round([Math]::Min($stars / 1000.0, 1.0) * 100, 1) +$forksNorm = [Math]::Round([Math]::Min($forks / 200.0, 1.0) * 100, 1) +$issuesActive = [Math]::Round([Math]::Min($totalOpen / 50.0, 1.0) * 100, 1) +$prsActive = [Math]::Round([Math]::Min(($totalMerged + $totalOpenPrs) / 30.0, 1.0) * 100, 1) +$releasesActive = [Math]::Round([Math]::Min($releaseCount / 10.0, 1.0) * 100, 1) + +$createdAt = if ($repoJson.data.created_at) { $repoJson.data.created_at } else { "" } +$createdAtDisplay = if ($createdAt.Length -ge 10) { $createdAt.Substring(0, 10) } else { "Unknown" } +$updatedAtDisplay = if ($updatedAt.Length -ge 10) { $updatedAt.Substring(0, 10) } else { "Unknown" } + +# Detect tech stack +Log-Step "Detecting tech stack..." +$techStack = $repoLang +$researchFeatures = "" +$hasCitationCff = $false +$subJson = try { Invoke-GLCheck @("api", "GET", "/v1/$Owner/$Repo/sub_entries?ref=master") } catch { $null } +if ($subJson -and $subJson.ok -and ($subJson.data -is [array])) { + $names = @($subJson.data | ForEach-Object { if ($_.name) { $_.name } else { "" } }) + $ecosystem = @() + if ($names -contains "go.mod") { $ecosystem += "Go" } + if ($names -contains "package.json") { $ecosystem += "Node.js" } + if ($names | Where-Object { $_ -match "requirements\.txt|pyproject\.toml|setup\.py|setup\.cfg|Pipfile" }) { $ecosystem += "Python" } + if ($names -contains "Cargo.toml") { $ecosystem += "Rust" } + if ($names -contains "CMakeLists.txt") { $ecosystem += "C/C++" } + if ($names | Where-Object { $_ -match "pom\.xml|build\.gradle" }) { $ecosystem += "Java/Kotlin" } + if ($names -contains "CITATION.cff") { $hasCitationCff = $true; $ecosystem += "+CITATION.cff" } + if ($ecosystem.Count -gt 0) { $techStack = ($ecosystem -join " ") } + if ($names | Where-Object { $_ -match "Dockerfile|docker-compose" }) { $researchFeatures += " Docker" } + if ($names | Where-Object { $_ -match "^data/|^datasets/" }) { $researchFeatures += " dataset" } + if ($names | Where-Object { $_ -match "\.ipynb$" }) { $researchFeatures += " Jupyter" } + if ($names | Where-Object { $_ -match "^scripts/|^experiments/" }) { $researchFeatures += " experiment-scripts" } +} +Log-Info " Tech stack: $techStack" + +# Readme analysis +Log-Step "Reading README for project positioning..." +$readmeText = "" +try { + $readmeResult = Invoke-GLCheck @("api", "GET", "raw/$Owner/$Repo/master/README.md") + if ($readmeResult.ok) { $readmeText = if ($readmeResult.data) { $readmeResult.data } else { "" } } +} catch { $readmeText = "" } + +$projectTopics = "" +$topicKeywords = @("machine learning","deep learning","neural network","NLP","computer vision", + "reinforcement learning","GAN","transformer","LLM","RAG","agent","bioinformatics","genomics", + "drug discovery","robotics","autonomous","simulation","optimization","benchmark","dataset", + "pre-trained","fine-tuning","distributed","federated","graph neural","knowledge graph", + "recommender","anomaly detection","scientific computing","HPC","quantum","climate","physics", + "chemistry") +foreach ($kw in $topicKeywords) { + if ($repoDesc -match [regex]::Escape($kw) -or $readmeText -match [regex]::Escape($kw)) { + if ($projectTopics) { $projectTopics += ", " } + $projectTopics += $kw + } +} + +$doiFound = "" +if ($repoDesc -match '10\.\d{4,}/[\w.\-/]+') { $doiFound = $Matches[0] } +elseif ($readmeText -match '10\.\d{4,}/[\w.\-/]+') { $doiFound = $Matches[0] } + +# ===== Generate HTML Report ===== +Log-Step "Generating HTML report..." +$hotnessClass = $hotnessLabel.ToLower() + +# Build tech stack tags +$techStackTags = "" +foreach ($t in ($techStack -split '\s+')) { + if ($t) { $techStackTags += "$t " } +} + +# Build research features tags +$researchTags = "" +foreach ($f in ($researchFeatures -split '\s+' | Where-Object { $_ })) { + $researchTags += "$f " +} +if (-not $researchTags) { $researchTags = "None detected" } + +# Build topic tags +$topicTags = "" +foreach ($t in ($projectTopics -split ', ' | Where-Object { $_ })) { + $topicTags += "$t " +} +if (-not $topicTags) { $topicTags = "None detected" } + +$issueStatus = if ($totalOpen -gt 20) { "Needs attention" } else { "Normal" } +$prStatus = if ($prMergeRate -gt 70) { "Healthy" } else { "Needs improvement" } +$releaseStatus = if ($releaseCount -gt 0) { "Released" } else { "No release" } +$ciStatus = if ($ciPassRate -gt 80) { "Stable" } else { "Unstable" } +$memberStatus = if ($memberCount -gt 3) { "Active community" } else { "Solo project" } +$activityStatus = if ($daysSinceUpdate -le 30) { "Active" } else { "Inactive" } + +$doiRow = "" +if ($doiFound) { $doiRow = "DOI$doiFound" } + +$htmlContent = @" + + + + + +$Owner/$Repo — Research Project Insight Report + + + + +
    +

    $Owner/$Repo

    +
    Research Project Insight Report — $Today
    +
    +
    + +
    +
    +
    Hotness Score
    +
    $hotness
    +
    $hotnessLabel
    +
    +
    +
    Stars
    +
    $stars
    +
    +
    +
    Forks
    +
    $forks
    +
    +
    +
    Contributors
    +
    $memberCount
    +
    +
    +
    Open Issues
    +
    $totalOpen
    +
    +
    +
    PR Merge Rate
    +
    ${prMergeRate}%
    +
    +
    + +
    +
    +

    Project Overview

    + + + + + + + + + $doiRow + +
    Name$repoName
    Description$repoDesc
    Language$repoLang
    Tech Stack$techStackTags
    Created$createdAtDisplay
    Updated$updatedAtDisplay ($daysSinceUpdate days ago)
    Research Features$researchTags
    Topics$topicTags
    +
    + +
    +

    Activity Overview

    +
    +
    +
    + +
    +
    +

    Health Indicators

    + + + + + + + + +
    IndicatorValueStatus
    Total Issues$totalOpen open / $totalClosed closed$issueStatus
    PR Merge Rate${prMergeRate}%$prStatus
    Releases$releaseCount$releaseStatus
    CI Pass Rate${ciPassRate}% ($ciBuilds builds)$ciStatus
    Contributors$memberCount people$memberStatus
    Activity$daysSinceUpdate days since update$activityStatus
    +
    + +
    +

    Hotness Composition

    +
    +
    +
    + +
    + + + + + +"@ + +if (-not $DryRun) { + $htmlContent | Out-File -FilePath $OutputFile -Encoding UTF8 + Log-Ok "HTML report generated: $OutputFile" +} else { + Log-Warn "[DRY RUN] Would generate: $OutputFile" +} + +# Summary output +Divider +Write-Host "====== Analysis Summary ======" -ForegroundColor White +Write-Host " Repo: $Owner/$Repo" +Write-Host " Language: $repoLang" +Write-Host " Tech Stack: $techStack" +Write-Host " Hotness: $hotness/100 ($hotnessLabel)" +Write-Host " Issues: $totalOpen open / $totalClosed closed" +Write-Host " PR merge: ${prMergeRate}%" +Write-Host " CI pass: ${ciPassRate}%" +Write-Host " Members: $memberCount" +Write-Host " Features: $researchFeatures" +if ($doiFound) { Write-Host " DOI: $doiFound" } +Write-Host " Report: $OutputFile" +Divider +Log-Ok "Analysis complete" diff --git a/workflows/academic/06-research-insights.sh b/workflows/academic/06-research-insights.sh new file mode 100644 index 0000000..f2866fd --- /dev/null +++ b/workflows/academic/06-research-insights.sh @@ -0,0 +1,521 @@ +#!/usr/bin/env bash +# ============================================================ +# GitLink 科研辅助 — 场景 1:仓库级科研项目洞察 +# ============================================================ +# 对单个 GitLink 仓库深度分析:项目定位、技术栈、活动健康、 +# 贡献者网络、热度评分,生成综合 HTML 报告 + Wiki 页面 +# ============================================================ + +set -euo pipefail +trap '' PIPE + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" + +# ── Configuration ──────────────────────────────────────────────────── +OUTPUT_DIR="${SCRIPT_DIR}/../../output" +OUTPUT_FILE="" +PUBLISH_WIKI="true" + +usage() { + cat <<'EOF' +Usage: 06-research-insights.sh --owner --repo [options] + +Options: + --owner Repository owner (required) + --repo Repository name (required) + --output Output HTML file path (default: output/research-insights-{repo}-{date}.html) + --no-wiki Skip publishing to Wiki + --dry-run Preview mode + +Examples: + 06-research-insights.sh --owner zzx-coder --repo gitlink-cli +EOF + exit 0 +} + +parse_common_args "$@" +while [[ $# -gt 0 ]]; do + case "$1" in + --output) OUTPUT_FILE="$2"; shift 2 ;; + --no-wiki) PUBLISH_WIKI="false"; shift ;; + *) shift ;; + esac +done + +# ── Helper: count array elements in JSON ────────────────────────────── +json_count() { + echo "$1" | jq -r "$2 | length" 2>/dev/null || echo "0" +} + +# ── Helper: extract keywords from text ──────────────────────────────── +extract_topics() { + local text="$1" + local topics="" + for kw in "machine learning" "deep learning" "neural network" "NLP" "computer vision" \ + "reinforcement learning" "GAN" "transformer" "LLM" "RAG" "agent" \ + "bioinformatics" "genomics" "computational biology" "drug discovery" \ + "robotics" "autonomous" "simulation" "optimization" "benchmark" \ + "dataset" "pre-trained" "fine-tuning" "distributed" "federated" \ + "graph neural" "knowledge graph" "recommender" "anomaly detection" \ + "scientific computing" "high performance" "HPC" "quantum" \ + "climate" "weather" "physics" "chemistry" "materials" \ + "machine learning" "深度学习" "神经网络" "自然语言" "计算机视觉" \ + "机器学习" "强化学习" "大语言模型" "知识图谱" "推荐系统"; do + if echo "$text" | grep -qi "$kw"; then + topics="${topics}${kw}, " + fi + done + echo "${topics%, }" +} + +# ── Main ───────────────────────────────────────────────────────────── +main() { + log_title "GitLink 科研辅助 — 仓库级项目洞察" + + check_auth + require_owner_repo + local today + today=$(date_today) + mkdir -p "$OUTPUT_DIR" + + local REPO_FULL="${OWNER}/${REPO}" + OUTPUT_FILE="${OUTPUT_FILE:-${OUTPUT_DIR}/research-insights-${REPO}-${today}.html}" + + # ═══ Step 1: Repo Metadata ═══ + log_step "1/6 获取仓库元数据..." + local repo_json + repo_json=$(gl_check repo +info --owner "$OWNER" --repo "$REPO") + local repo_name repo_desc repo_lang stars forks open_issues created_at updated_at + repo_name=$(json_get "$repo_json" '.data.name // .data.full_name // "'"$REPO"' "') + repo_desc=$(json_get "$repo_json" '.data.description // "No description"') + repo_lang=$(json_get "$repo_json" '.data.language // "Unknown"') + stars=$(json_get "$repo_json" '.data.stars_count // .data.stars // 0') + forks=$(json_get "$repo_json" '.data.forks_count // .data.forks // 0') + open_issues=$(json_get "$repo_json" '.data.open_issues_count // .data.open_issues // 0') + created_at=$(json_get "$repo_json" '.data.created_at // ""') + updated_at=$(json_get "$repo_json" '.data.updated_at // ""') + + log_info " 名称: $repo_name" + log_info " 语言: $repo_lang" + log_info " Stars: $stars | Forks: $forks | Open Issues: $open_issues" + + # ═══ Step 2: Tech Stack Detection ═══ + log_step "2/6 检测技术栈..." + local tech_stack="" file_count=0 + # Tech stack from repo language (API-based file listing not available on GitLink) + tech_stack="$repo_lang" + local research_features="" + local names_list="" ext_counts="" file_count=0 + + # Attempt to get file listing via repo info (limited info available) + local sub_json + sub_json=$(gl_run api GET "/v1/$OWNER/$REPO/sub_entries?ref=master" 2>/dev/null) + # sub_entries may return HTML page if API not available; guard carefully + if [[ "$(json_ok "$sub_json")" == "true" ]]; then + # Check if .data is actually an array (not HTML string) + local data_type + data_type=$(echo "$sub_json" | jq -r '(.data | type) // "string"' 2>/dev/null) + if [[ "$data_type" == "array" ]]; then + file_count=$(json_count "$sub_json" '.data') + ext_counts=$(echo "$sub_json" | jq -r '.data[].name // empty' 2>/dev/null | awk -F. '{if(NF>1) print $NF}' | sort | uniq -c | sort -rn | head -15) + names_list=$(echo "$sub_json" | jq -r '.data[].name // empty' 2>/dev/null) + + local ecosystem="" + if echo "$names_list" | grep -q "go.mod"; then ecosystem="$ecosystem Go"; fi + if echo "$names_list" | grep -q "package.json"; then ecosystem="$ecosystem Node.js"; fi + if echo "$names_list" | grep -qE "requirements.txt|pyproject.toml|setup.py|setup.cfg|Pipfile"; then ecosystem="$ecosystem Python"; fi + if echo "$names_list" | grep -q "Cargo.toml"; then ecosystem="$ecosystem Rust"; fi + if echo "$names_list" | grep -q "CMakeLists.txt"; then ecosystem="$ecosystem C/C++"; fi + if echo "$names_list" | grep -qE "pom.xml|build.gradle"; then ecosystem="$ecosystem Java/Kotlin"; fi + if echo "$names_list" | grep -q "CITATION.cff"; then ecosystem="$ecosystem +CITATION.cff"; fi + tech_stack=$(echo "$ecosystem" | sed 's/^ *//') + [[ -z "$tech_stack" ]] && tech_stack="$repo_lang" + + # Detect research features + if echo "$names_list" | grep -qE "Dockerfile|docker-compose"; then research_features="$research_features Docker"; fi + if echo "$names_list" | grep -qE "^data/|^datasets/"; then research_features="$research_features 数据集目录"; fi + if echo "$names_list" | grep -q "\.ipynb"; then research_features="$research_features Jupyter"; fi + if echo "$names_list" | grep -qE "^scripts/|^experiments/"; then research_features="$research_features 实验脚本"; fi + fi + fi + log_info " 技术栈: ${tech_stack:-未知}" + + # ═══ Step 3: Project Positioning ═══ + log_step "3/6 提取项目定位..." + local readme_text="" project_topics="" doi_found="" + readme_text=$(gl_run api GET "raw/$OWNER/$REPO/master/README.md" 2>/dev/null) + if [[ "$(json_ok "$readme_text")" == "true" ]]; then + readme_text=$(echo "$readme_text" | jq -r '.data // ""' 2>/dev/null) + else + readme_text="" + fi + + project_topics=$(extract_topics "$repo_desc $readme_text") + doi_found=$(echo "$repo_desc $readme_text" | grep -oE '10\.[0-9]{4,}/[a-zA-Z0-9._\-/]+' | head -1 || echo "") + + log_info " 领域关键词: ${project_topics:-未检测到}" + [[ -n "$doi_found" ]] && log_info " DOI: $doi_found" + + # ═══ Step 4: Activity Health ═══ + log_step "4/6 计算活动健康指标..." + + # Issues + local open_issues_json closed_issues_json + open_issues_json=$(gl_run issue +list --owner "$OWNER" --repo "$REPO" --state open --limit 100) + closed_issues_json=$(gl_run issue +list --owner "$OWNER" --repo "$REPO" --state closed --limit 100) + + local total_open total_closed + total_open=0; total_closed=0 + if [[ "$(json_ok "$open_issues_json")" == "true" ]]; then + total_open=$(json_count "$open_issues_json" '(.data.issues // .data)') + fi + if [[ "$(json_ok "$closed_issues_json")" == "true" ]]; then + total_closed=$(json_count "$closed_issues_json" '(.data.issues // .data)') + fi + + # PRs + local merged_prs_json open_prs_json + merged_prs_json=$(gl_run pr +list --owner "$OWNER" --repo "$REPO" --state merged --limit 100) + open_prs_json=$(gl_run pr +list --owner "$OWNER" --repo "$REPO" --state open --limit 50) + + local total_merged=0 total_open_prs=0 + if [[ "$(json_ok "$merged_prs_json")" == "true" ]]; then + total_merged=$(json_count "$merged_prs_json" '(.data.issues // .data.pulls // .data)') + fi + if [[ "$(json_ok "$open_prs_json")" == "true" ]]; then + total_open_prs=$(json_count "$open_prs_json" '(.data.issues // .data.pulls // .data)') + fi + + # Releases + local releases_json release_count=0 + releases_json=$(gl_run release +list --owner "$OWNER" --repo "$REPO" --limit 20) + if [[ "$(json_ok "$releases_json")" == "true" ]]; then + release_count=$(json_count "$releases_json" '.data.releases') + fi + + # CI + local ci_json ci_builds=0 ci_success=0 + ci_json=$(gl_run ci +builds --owner "$OWNER" --repo "$REPO" --limit 20) + if [[ "$(json_ok "$ci_json")" == "true" ]]; then + ci_builds=$(json_count "$ci_json" '.data') + ci_success=$(echo "$ci_json" | jq -r '[.data[] | select(.status == "success" or .status == "completed")] | length' 2>/dev/null || echo "0") + fi + + local pr_merge_rate=0 + if [[ $((total_merged + total_open_prs)) -gt 0 ]]; then + pr_merge_rate=$(awk "BEGIN { printf \"%.1f\", $total_merged / ($total_merged + $total_open_prs) * 100 }") + fi + + local ci_pass_rate=0 + if [[ $ci_builds -gt 0 ]]; then + ci_pass_rate=$(awk "BEGIN { printf \"%.1f\", $ci_success / $ci_builds * 100 }") + fi + + log_info " Issues: $total_open 开放 / $total_closed 已关闭" + log_info " PRs: $total_open_prs 开放 / $total_merged 已合并 (合并率: ${pr_merge_rate}%)" + log_info " Releases: $release_count | CI 通过率: ${ci_pass_rate}%" + + # ═══ Step 5: Contributor Network ═══ + log_step "5/6 构建贡献者网络..." + local members_json member_count=0 members_list="[]" + members_json=$(gl_run repo +members --owner "$OWNER" --repo "$REPO" --limit 50) + if [[ "$(json_ok "$members_json")" == "true" ]]; then + member_count=$(json_count "$members_json" '(.data.members // .data)') + # Extract member logins as JSON array + members_list=$(echo "$members_json" | jq -c '(.data.members // .data | if type == "array" then [.[].login // .[].user.login // empty] else [] end)' 2>/dev/null || echo "[]") + fi + log_info " 贡献者数: $member_count" + + # ═══ Step 6: Hotness Score ═══ + log_step "6/6 计算热度评分..." + + # Compute hotness + local stars_norm forks_norm issues_active prs_active releases_active recency_factor + local max_stars=1000 max_forks=200 + stars_norm=$(awk "BEGIN { printf \"%.1f\", ($stars / $max_stars > 1) ? 100 : ($stars / $max_stars * 100) }") + forks_norm=$(awk "BEGIN { printf \"%.1f\", ($forks / $max_forks > 1) ? 100 : ($forks / $max_forks * 100) }") + issues_active=$total_open + [[ $issues_active -gt 50 ]] && issues_active=50 + issues_active=$(awk "BEGIN { printf \"%.1f\", $issues_active / 50 * 100 }") + prs_active=$(awk "BEGIN { printf \"%.1f\", ($total_merged + $total_open_prs) / 30 * 100 }") + [[ $(echo "$prs_active > 100" | bc 2>/dev/null || echo "0") -eq 1 ]] && prs_active=100 + releases_active=$(awk "BEGIN { printf \"%.1f\", $release_count / 10 * 100 }") + [[ $(echo "$releases_active > 100" | bc 2>/dev/null || echo "0") -eq 1 ]] && releases_active=100 + + # Recency: days since last update + local days_since_update=365 + if [[ -n "$updated_at" ]]; then + days_since_update=$(days_between "${updated_at:0:10}" "$today") + [[ -z "$days_since_update" ]] && days_since_update=365 + fi + if [[ $days_since_update -le 30 ]]; then recency_factor=100 + elif [[ $days_since_update -le 90 ]]; then recency_factor=50 + else recency_factor=10; fi + + local hotness + hotness=$(weighted_sum "$stars_norm" 0.15 "$forks_norm" 0.10 "$issues_active" 0.20 "$prs_active" 0.20 "$releases_active" 0.15 0 0.10 "$recency_factor" 0.10) + hotness=$(printf "%.1f" "$hotness") + + local hotness_label + if awk "BEGIN { exit ($hotness >= 50) ? 0 : 1 }"; then hotness_label="Hot" + elif awk "BEGIN { exit ($hotness >= 30) ? 0 : 1 }"; then hotness_label="Warm" + else hotness_label="Cool"; fi + + log_ok "热度评分: ${hotness}/100 (${hotness_label})" + + # ═══ Generate Report ═══ + log_step "生成综合报告..." + + # JSON data for HTML embedding + local json_data + json_data=$(cat < "$OUTPUT_FILE" < + + + + +${REPO_FULL} — 科研项目洞察报告 + + + + +
    +

    ${REPO_FULL}

    +
    科研项目洞察报告 — ${today}
    +
    +
    + +
    +
    +
    热度评分
    +
    ${hotness}
    +
    ${hotness_label}
    +
    +
    +
    Stars
    +
    ${stars}
    +
    +
    +
    Forks
    +
    ${forks}
    +
    +
    +
    贡献者
    +
    ${member_count}
    +
    +
    +
    开放 Issues
    +
    ${total_open}
    +
    +
    +
    PR 合并率
    +
    ${pr_merge_rate}%
    +
    +
    + +
    +
    +

    项目概况

    + + + + + + + + + $( [[ -n "$doi_found" ]] && echo "" ) + $( [[ -n "$project_topics" ]] && echo "" ) +
    项目名称${repo_name}
    描述${repo_desc}
    主要语言${repo_lang}
    技术栈$(echo "$tech_stack" | sed 's/ /\n/g' | while read -r t; do [[ -n "$t" ]] && echo "$t"; done)
    创建时间${created_at:0:10}
    最后更新${updated_at:0:10} (${days_since_update} 天前)
    科研特征$(echo "$research_features" | sed 's/ /\n/g' | while read -r f; do [[ -n "$f" ]] && echo "$f"; done)
    DOI${doi_found}
    领域主题$(echo "$project_topics" | tr ',' '\n' | while read -r t; do [[ -n "$t" ]] && echo "$(echo $t | xargs)"; done)
    +
    + +
    +

    活动概览

    +
    +
    +
    + +
    +
    +

    健康指标

    + + + + + + + + +
    指标数值状态
    Issue 总量${total_open} 开放 / ${total_closed} 已关闭$( [[ $total_open -gt 20 ]] && echo "需关注" || echo "正常" )
    PR 合并率${pr_merge_rate}%$( awk "BEGIN { if(${pr_merge_rate} > 70) print \"健康\"; else print \"需改进\" }" )
    Release 数${release_count}$( [[ $release_count -gt 0 ]] && echo "已发布" || echo "未发布" )
    CI 通过率${ci_pass_rate}% (${ci_builds} 次构建)$( awk "BEGIN { if(${ci_pass_rate} > 80) print \"稳定\"; else print \"不稳定\" }" )
    贡献者数${member_count} 人$( [[ $member_count -gt 3 ]] && echo "活跃社区" || echo "单人项目" )
    活跃度${days_since_update} 天前更新$( [[ $days_since_update -le 30 ]] && echo "活跃" || echo "不活跃" )
    +
    + +
    +

    热度构成

    +
    +
    +
    + +
    + + + + + +HTMLEOF + log_ok "HTML 报告已生成: $OUTPUT_FILE" + else + log_warn "[DRY RUN] Would generate: $OUTPUT_FILE" + fi + + # ═══ Wiki Publishing ═══ + if [[ "$PUBLISH_WIKI" == "true" ]] && [[ "${DRY_RUN:-false}" != "true" ]]; then + log_step "发布到 GitLink Wiki..." + local wiki_content wiki_title + wiki_title="[Research:Insights] ${REPO} 项目洞察 ${today}" + wiki_content=$(cat <$null + if ($remoteUrl -and ($remoteUrl -match [regex]::Escape("$Owner/$Repo"))) { + Pop-Location + Log-Info "Local path is already a clone of $Owner/$Repo" + $result.ScanPath = $LocalPath + return $result + } + } catch { } + Pop-Location + } + + # Clone target repo to temp directory for compliance scan + $suffix = [System.Guid]::NewGuid().ToString().Substring(0, 8) + $tempDir = Join-Path ([System.IO.Path]::GetTempPath()) "gl-compliance-${Repo}-${suffix}" + Log-Info "Cloning $Owner/$Repo to temp directory for compliance scan..." + + $cloneUrl = if ($env:GITLINK_TOKEN) { + "https://oauth2:$env:GITLINK_TOKEN@gitlink.org.cn/$Owner/$Repo.git" + } else { + "https://gitlink.org.cn/$Owner/$Repo.git" + } + + $cloneOutput = git clone --depth 1 $cloneUrl $tempDir 2>&1 + if ($LASTEXITCODE -ne 0) { + Log-Warn "HTTPS clone failed, trying SSH..." + $sshUrl = "git@gitlink.org.cn:$Owner/$Repo.git" + $cloneOutput = git clone --depth 1 $sshUrl $tempDir 2>&1 + if ($LASTEXITCODE -ne 0) { + Log-Warn "Clone failed for both HTTPS and SSH. Skipping compliance scan." + Log-Warn "Reason: $($cloneOutput -join '; ')" + return $result + } + } + + Log-Ok "Cloned successfully to $tempDir" + $result.ScanPath = $tempDir + $result.NeedCleanup = $true + return $result +} + +# 1. Compliance scan + build file inventory from local clone +Log-Step "1/7 Compliance scan..." +$cloneInfo = Ensure-LocalClone -Owner $Owner -Repo $Repo -LocalPath $LocalPath +$scanPath = $cloneInfo.ScanPath + +# Build a recursive relative file list ($names) and load README text from the +# local clone. The remote `raw/...` and `/v1/.../sub_entries` content endpoints +# are NOT valid API routes on this GitLink server (they return the SPA HTML), +# so all file-based detection (README, deps, build, tests, license) reads from +# the clone that Ensure-LocalClone already provides. +$names = @() +$readmeText = "" +if ($scanPath -and (Test-Path $scanPath)) { + Push-Location $scanPath + try { + $names = @(Get-ChildItem -Recurse -File -Force -ErrorAction SilentlyContinue -Name | + Where-Object { $_ -notmatch '^\.git[\\/]' -and $_ -notmatch '[\\/]node_modules[\\/]' } | + ForEach-Object { ($_ -replace '\\', '/') }) + foreach ($rn in @("README.md","README.MD","README","README.rst","README.txt","README.zh-CN.md","readme.md")) { + if (Test-Path $rn) { $readmeText = Get-Content -Raw -Path $rn -Encoding UTF8 -ErrorAction SilentlyContinue; break } + } + } catch { } + Pop-Location +} else { + # Fallback: no local clone — use the working entries endpoint for a root file list + $defaultBranch = if ($repoJson.data.default_branch) { $repoJson.data.default_branch } else { "master" } + $entriesRes = Invoke-GLCheck @("api", "GET", "/$Owner/$Repo/entries?ref=$defaultBranch") + if ($entriesRes.ok -and $entriesRes.data.entries) { + $names = @($entriesRes.data.entries | ForEach-Object { if ($_.name) { ($_.name -replace '\\','/') } else { "" } }) + } +} + +# Secrets / PII via compliance +scan (runs inside the clone) +if ($scanPath -and (Test-Path (Join-Path $scanPath ".git"))) { + Push-Location $scanPath + $compResult = Invoke-GLCheck @("compliance", "+scan") + Pop-Location + # NOTE: compliance +scan has NO license module (modules = secrets/exposure/vocab), + # and findings are a FLAT array with a .module field — not nested under + # .data.secrets/.data.exposure. License is detected separately below. + if ($compResult.ok -and $compResult.data.findings) { + $allFindings = @($compResult.data.findings) + $secCount = @($allFindings | Where-Object { $_.module -eq 'secrets' }).Count + $piiCount = @($allFindings | Where-Object { $_.module -eq 'exposure' }).Count + $totalFindings = $secCount + $piiCount + if ($totalFindings -eq 0) { $dNoSecret.Score = 1.0; $dNoSecret.Detail = "No secrets/PII found" } + elseif ($totalFindings -le 3) { $dNoSecret.Score = 0.5; $dNoSecret.Detail = "Few suspicious items ($totalFindings)" } + else { $dNoSecret.Score = 0; $dNoSecret.Detail = "Multiple secret/PII leaks ($totalFindings)" } + } else { + $dNoSecret.Score = 1.0; $dNoSecret.Detail = "No secrets/PII found" + } +} else { + Log-Warn "Cannot access repo for compliance scan, skipping secrets scan" + $dNoSecret.Detail = "Not scanned (repo inaccessible)" +} + +# License: detected from file inventory (compliance scanner has no license module) +$licenseFile = $names | Where-Object { $_ -match '(^|/)(LICENSE|LICENCE|COPYING|UNLICENCE)(\.(md|txt))?$' } | Select-Object -First 1 +if ($licenseFile) { $dLicense.Score = 1.0; $dLicense.Detail = "License file found: $licenseFile" } +elseif ($readmeText -match 'MIT License|Apache License|GPL|BSD') { $dLicense.Score = 0.5; $dLicense.Detail = "License mentioned in README" } +else { $dLicense.Score = 0; $dLicense.Detail = "No LICENSE file" } + +# 2. README +Log-Step "2/7 README completeness..." +# README text was already loaded from the local clone in step 1. + +$sectionCount = 0 +foreach ($kw in @("# ", "## ", "Install", "Usage", "License", "Contribut", "Citation")) { + if ($readmeText -match $kw) { $sectionCount++ } +} +if ($sectionCount -ge 5) { $dReadme.Score = 1.0; $dReadme.Detail = "README complete, $sectionCount sections" } +elseif ($sectionCount -ge 3) { $dReadme.Score = 0.5; $dReadme.Detail = "README partial, $sectionCount sections" } +else { $dReadme.Score = 0; $dReadme.Detail = "README missing or too short" } +Log-Info " README sections: $sectionCount" + +# 3. Dependencies +Log-Step "3/7 Dependency declaration..." +# $names (relative file paths from the clone) was built in step 1. +$depFiles = 0 +$depList = "" +foreach ($df in @("package.json","go.mod","requirements.txt","pyproject.toml","setup.py","setup.cfg","Cargo.toml","CMakeLists.txt","pom.xml","build.gradle","Gemfile","Makefile")) { + if ($names | Where-Object { (Split-Path $_ -Leaf) -eq $df }) { $depFiles++; $depList += "$df, " } +} +if ($depFiles -ge 1) { $dDeps.Score = 1.0; $dDeps.Detail = "Standard dep file(s): $depList".TrimEnd(', ') } +elseif ($readmeText -match "dependenc|requirement|install") { $dDeps.Score = 0.5; $dDeps.Detail = "Deps mentioned in README" } +else { $dDeps.Score = 0; $dDeps.Detail = "No dependency declaration" } +Log-Info " Dep files: $depFiles" + +# 4. Build +Log-Step "4/7 Build instructions..." +$buildScore = 0 +if ($readmeText -match "build|install|compile|make|run") { $buildScore++ } +if ($names | Where-Object { $_ -match "Makefile|Dockerfile|docker-compose" }) { $buildScore++ } +if ($names | Where-Object { $_ -match "\.github/workflows|\.gitlab-ci|Jenkinsfile" }) { $buildScore++ } + +if ($buildScore -ge 3) { $dBuild.Score = 1.0; $dBuild.Detail = "Detailed build docs + automation" } +elseif ($buildScore -ge 1) { $dBuild.Score = 0.5; $dBuild.Detail = "Partial build instructions" } +else { $dBuild.Score = 0; $dBuild.Detail = "No build instructions" } +Log-Info " Build score: $buildScore/3" + +# 5. CI +Log-Step "5/7 CI configuration..." +$ciPipelines = Get-CIPipelines $Owner $Repo +$ciBuilds = $ciPipelines.Count +$ciOk = 0 +foreach ($b in $ciPipelines) { + if (Test-CISuccess $b) { $ciOk++ } +} +if ($ciBuilds -gt 0 -and $ciOk -ge 1) { $dCI.Score = 1.0; $dCI.Detail = "CI configured & passing ($ciOk/$ciBuilds)" } +elseif ($ciBuilds -gt 0) { $dCI.Score = 0.5; $dCI.Detail = "CI exists but failing" } +else { $dCI.Score = 0; $dCI.Detail = "No CI configured" } +Log-Info " CI builds: $ciBuilds" + +# 6. Tests +Log-Step "6/7 Test evidence..." +$testScore = 0 +if ($names | Where-Object { $_ -match "^test/|^tests/|^spec/|^__tests__/" }) { $testScore++ } +if ($names | Where-Object { $_ -match "_test\.|\.test\.|_spec\.|\.spec\.|test_" }) { $testScore++ } +if ($readmeText -match "test|validate") { $testScore++ } + +if ($testScore -ge 3) { $dTest.Score = 1.0; $dTest.Detail = "Test dir + files + instructions" } +elseif ($testScore -ge 1) { $dTest.Score = 0.5; $dTest.Detail = "Partial test evidence" } +else { $dTest.Score = 0; $dTest.Detail = "No test evidence" } +Log-Info " Test evidence: $testScore/3" + +# 7. Data +Log-Step "7/7 Data availability..." +$dataScore = 0 +$dataEvidence = "" +if ($readmeText -match "dataset|data/|zenodo|figshare|kaggle|huggingface") { $dataScore++; $dataEvidence = "keyword found" } +if ($readmeText -match "https?://[^\s]+(?:zenodo|figshare|data\.|dataset)") { $dataScore++; $dataEvidence += ", data link found" } +if ($doiFound) { $dataScore++; $dataEvidence += ", DOI/article found" } + +if ($dataScore -ge 2) { $dData.Score = 1.0; $dData.Detail = "Clear data statement: $dataEvidence" } +elseif ($dataScore -ge 1) { $dData.Score = 0.5; $dData.Detail = "Partial data statement: $dataEvidence" } +else { $dData.Score = 0; $dData.Detail = "No data availability statement" } +Log-Info " Data score: $dataScore/3" + +# Cleanup temp clone (deferred until after all file-based checks are done) +if ($cloneInfo -and $cloneInfo.NeedCleanup -and $cloneInfo.ScanPath -and (Test-Path $cloneInfo.ScanPath)) { + Get-ChildItem -Path $cloneInfo.ScanPath -Recurse -Force -ErrorAction SilentlyContinue | + ForEach-Object { $_.Attributes = 'Normal' } + Remove-Item -Recurse -Force $cloneInfo.ScanPath -ErrorAction SilentlyContinue + if (Test-Path $cloneInfo.ScanPath) { + cmd /c "rd /s /q `"$($cloneInfo.ScanPath)`"" 2>$null + } + if (-not (Test-Path $cloneInfo.ScanPath)) { Log-Info "Cleaned up temp clone." } + else { Log-Warn "Could not fully remove temp clone: $($cloneInfo.ScanPath)" } +} + +# Total score +$weights = @(0.15, 0.15, 0.15, 0.15, 0.10, 0.10, 0.10, 0.10) +$scores = @($dLicense.Score, $dNoSecret.Score, $dReadme.Score, $dDeps.Score, $dBuild.Score, $dCI.Score, $dTest.Score, $dData.Score) +$totalScore = 0 +for ($i = 0; $i -lt 8; $i++) { $totalScore += $scores[$i] * $weights[$i] } +$totalScore = [Math]::Round($totalScore * 100, 1) + +$grade = if ($totalScore -ge 85) { "A" } elseif ($totalScore -ge 70) { "B" } elseif ($totalScore -ge 55) { "C" } elseif ($totalScore -ge 40) { "D" } else { "F" } +$gradeLabel = if ($grade -eq "A") { "Excellent - highly reproducible" } elseif ($grade -eq "B") { "Good - largely reproducible" } elseif ($grade -eq "C") { "Fair - partially reproducible" } elseif ($grade -eq "D") { "Poor - difficult to reproduce" } else { "Fail - barely reproducible" } +$gradeColor = if ($grade -eq "A") { "#2e7d32" } elseif ($grade -eq "B") { "#558b2f" } elseif ($grade -eq "C") { "#f57c00" } elseif ($grade -eq "D") { "#e65100" } else { "#c62828" } + +# Build recommendation strings +$recLicense = if ($dLicense.Score -ne 1.0) { "Add MIT/Apache-2.0/GPL-3.0 license file" } else { "-" } +$recSecret = if ($dNoSecret.Score -ne 1.0) { "Remove leaked secrets, use environment variables" } else { "-" } +$recReadme = if ($dReadme.Score -ne 1.0) { "Add purpose, install, usage, license, citation sections" } else { "-" } +$recDeps = if ($dDeps.Score -ne 1.0) { "Add package.json/go.mod/requirements.txt etc." } else { "-" } +$recBuild = if ($dBuild.Score -ne 1.0) { "Add Makefile/Dockerfile + build steps in README" } else { "-" } +$recCI = if ($dCI.Score -ne 1.0) { "Configure GitLink CI or GitHub Actions" } else { "-" } +$recTest = if ($dTest.Score -ne 1.0) { "Add unit/integration tests, document test commands" } else { "-" } +$recData = if ($dData.Score -ne 1.0) { "Document dataset sources, provide Zenodo/Figshare link" } else { "-" } + +# Icon helpers +function ScoreIcon($s) { + if ($s -eq 1.0) { return "✅" } + elseif ($s -eq 0.5) { return "⚠️" } + else { return "❌" } +} +function ScorePercent($s) { [Math]::Round($s * 100) } + +# ===== Generate HTML Report ===== +Log-Step "Generating HTML scorecard..." +$dims = @("License","No Secrets/PII","README","Dependencies","Build","CI","Tests","Data") +$dets = @($dLicense, $dNoSecret, $dReadme, $dDeps, $dBuild, $dCI, $dTest, $dData) +$recs = @($recLicense, $recSecret, $recReadme, $recDeps, $recBuild, $recCI, $recTest, $recData) +$pcts = @(0.15, 0.15, 0.15, 0.15, 0.10, 0.10, 0.10, 0.10) + +# Build dimension table rows +$dimRows = "" +for ($i = 0; $i -lt 8; $i++) { + $icon = ScoreIcon $scores[$i] + $pct = ScorePercent $scores[$i] + $suggestion = $recs[$i] + $detail = $dets[$i].Detail + $name = $dims[$i] + $dimRows += @" + + $name + $icon + $detail + $suggestion + +"@ +} + +$htmlContent = @" + + + + + +$Owner/$Repo —Reproducibility Scorecard + + + + +
    +

    $Owner/$Repo —Research Reproducibility Scorecard

    +
    $Today
    +
    +
    + +
    +
    +
    $grade
    +
    $totalScore / 100
    +
    $gradeLabel
    +
    +
    +

    Radar Chart

    +
    +
    +
    +

    Dimension Scores

    + + + + + + + + + + +
    DimensionScoreWeight
    License$(ScorePercent $dLicense.Score)%15%
    No Secrets/PII$(ScorePercent $dNoSecret.Score)%15%
    README$(ScorePercent $dReadme.Score)%15%
    Dependencies$(ScorePercent $dDeps.Score)%15%
    Build$(ScorePercent $dBuild.Score)%10%
    CI$(ScorePercent $dCI.Score)%10%
    Tests$(ScorePercent $dTest.Score)%10%
    Data$(ScorePercent $dData.Score)%10%
    +
    +
    + +
    +

    Detailed Assessment & Recommendations

    + + + $dimRows +
    DimensionRatingEvidenceSuggestion
    +
    + +
    + + + + + +"@ + +if (-not $DryRun) { + $htmlContent | Out-File -FilePath $OutputFile -Encoding UTF8 + Log-Ok "HTML scorecard generated: $OutputFile" +} else { + Log-Warn "[DRY RUN] Would generate: $OutputFile" +} + +# Console summary +Divider +Write-Host "====== Reproducibility Scorecard ======" -ForegroundColor White +Write-Host " Total score: $totalScore/100 [$grade] ($gradeLabel)" +$dimNames = @("License","No Secrets/PII","README","Dependencies","Build","CI","Tests","Data") +for ($i = 0; $i -lt 8; $i++) { + $icon = if ($scores[$i] -eq 1.0) { "OK" } elseif ($scores[$i] -eq 0.5) { "~" } else { "!!" } + Write-Host " $icon $($dimNames[$i]) ($(ScorePercent $scores[$i])%): $($dets[$i].Detail)" +} +Write-Host " Report: $OutputFile" +Divider +Log-Ok "Check complete" diff --git a/workflows/academic/08-research-compliance.sh b/workflows/academic/08-research-compliance.sh new file mode 100644 index 0000000..fe8b37b --- /dev/null +++ b/workflows/academic/08-research-compliance.sh @@ -0,0 +1,507 @@ +#!/usr/bin/env bash +# ============================================================ +# GitLink 科研辅助 — 场景 3:合规与复现性检查 +# ============================================================ +# 8维度复现性评分:许可证、密钥/PII、README、依赖声明、 +# 构建说明、CI配置、测试证据、数据可用性 +# 输出雷达图 + 仪表盘 HTML 评分卡 +# ============================================================ + +set -euo pipefail +trap '' PIPE + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" + +# ── Configuration ──────────────────────────────────────────────────── +OUTPUT_DIR="${SCRIPT_DIR}/../../output" +OUTPUT_FILE="" +LOCAL_PATH="." + +usage() { + cat <<'EOF' +Usage: 08-research-compliance.sh --owner --repo [options] + +Options: + --owner Repository owner (required) + --repo Repository name (required) + --local-path Local repo path for compliance scan (default: .) + --output Output HTML file path + --no-wiki Skip publishing to Wiki + --dry-run Preview mode + +Examples: + 08-research-compliance.sh --owner zzx-coder --repo gitlink-cli --local-path . +EOF + exit 0 +} + +parse_common_args "$@" +while [[ $# -gt 0 ]]; do + case "$1" in + --output) OUTPUT_FILE="$2"; shift 2 ;; + --local-path) LOCAL_PATH="$2"; shift 2 ;; + --no-wiki) PUBLISH_WIKI="false"; shift ;; + *) shift ;; + esac +done + +PUBLISH_WIKI="${PUBLISH_WIKI:-true}" + +# ── Scoring helpers ────────────────────────────────────────────────── +score_dim() { + # score_dim score weight label + awk -v s="$1" -v w="$2" 'BEGIN { printf "%.4f", s * w }' +} + +# ── Main ───────────────────────────────────────────────────────────── +main() { + log_title "GitLink 科研辅助 — 合规与复现性检查" + + check_auth + require_owner_repo + local today + today=$(date_today) + mkdir -p "$OUTPUT_DIR" + + # Get repo metadata for description and DOI detection + local repo_json repo_desc + repo_json=$(gl_check repo +info --owner "$OWNER" --repo "$REPO") + repo_desc=$(json_get "$repo_json" '.data.description // ""') + local doi_found="" + doi_found=$(echo "$repo_desc" | grep -oE '10\.[0-9]{4,}/[a-zA-Z0-9._\-/]+' | head -1 || echo "") + + local REPO_FULL="${OWNER}/${REPO}" + OUTPUT_FILE="${OUTPUT_FILE:-${OUTPUT_DIR}/reproducibility-${REPO}-${today}.html}" + + # Each dimension: score (0, 0.5, 1.0), weight, label, detail + local dim_license_s=0 dim_nosecret_s=0 dim_readme_s=0 dim_deps_s=0 + local dim_build_s=0 dim_ci_s=0 dim_test_s=0 dim_data_s=0 + local dim_license_d="" dim_nosecret_d="" dim_readme_d="" dim_deps_d="" + local dim_build_d="" dim_ci_d="" dim_test_d="" dim_data_d="" + + # ═══ Step 1: Compliance Scan ═══ + log_step "1/7 合规扫描..." + local comp_json + if [[ -d "$LOCAL_PATH/.git" ]]; then + comp_json=$(cd "$LOCAL_PATH" && gl_run compliance +scan --format json 2>/dev/null) + else + log_warn "本地仓库路径无 .git 目录,跳过合规扫描" + comp_json="" + fi + + if [[ "$(json_ok "$comp_json")" == "true" ]]; then + # License check + local license_ok + license_ok=$(echo "$comp_json" | jq -r '.data.license.status // "unknown"' 2>/dev/null) + if [[ "$license_ok" == "ok" || "$license_ok" == "clean" || "$license_ok" == "found" ]]; then + dim_license_s=1.0; dim_license_d="检测到合规许可证" + elif [[ "$license_ok" == "warning" ]]; then + dim_license_s=0.5; dim_license_d="有许可证但类型非标准" + else + dim_license_s=0; dim_license_d="未检测到 LICENSE 文件" + fi + + # Secrets check + local secrets_count + secrets_count=$(echo "$comp_json" | jq -r '.data.secrets.findings | length // 0' 2>/dev/null) + if [[ "$secrets_count" == "0" || -z "$secrets_count" ]]; then + dim_nosecret_s=1.0; dim_nosecret_d="未发现硬编码密钥" + elif [[ "$secrets_count" -le 2 ]]; then + dim_nosecret_s=0.5; dim_nosecret_d="发现 ${secrets_count} 处可疑密钥" + else + dim_nosecret_s=0; dim_nosecret_d="发现 ${secrets_count} 处密钥泄露" + fi + + # PII / Exposure + local pii_count + pii_count=$(echo "$comp_json" | jq -r '.data.exposure.findings | length // 0' 2>/dev/null) + if [[ "$pii_count" == "0" || -z "$pii_count" ]]; then + # Keep secrets score; PII clean doesn't change it + dim_nosecret_d="${dim_nosecret_d} / 无 PII 泄露" + else + dim_nosecret_d="${dim_nosecret_d} / 发现 ${pii_count} 处 PII" + if [[ ${dim_nosecret_s%%.*} -eq 1 ]]; then + dim_nosecret_s=0.5 + fi + fi + + log_info " License: $( [[ $dim_license_s == "1.0" ]] && echo "OK" || echo "ISSUE")" + log_info " Secrets/PII: $( [[ $dim_nosecret_s == "1.0" ]] && echo "OK" || echo "ISSUE")" + else + log_warn "合规扫描未返回有效结果,跳过该维度" + dim_license_s=0; dim_license_d="未扫描(无本地仓库)" + dim_nosecret_s=0; dim_nosecret_d="未扫描(无本地仓库)" + fi + + # ═══ Step 2: README Completeness ═══ + log_step "2/7 检查 README 完整性..." + local readme_text + readme_text=$(gl_run api GET "raw/$OWNER/$REPO/master/README.md" 2>/dev/null) + if [[ "$(json_ok "$readme_text")" == "true" ]]; then + readme_text=$(echo "$readme_text" | jq -r '.data // ""' 2>/dev/null) + else + readme_text="" + fi + + local section_count=0 sections_found="" + for kw in "# " "## " "Install" "Usage" "License" "Contribut" "Citation" "安装" "使用" "许可" "引用"; do + if echo "$readme_text" | grep -qi "$kw"; then + section_count=$((section_count + 1)) + sections_found="${sections_found}${kw}, " + fi + done + + if [[ $section_count -ge 5 ]]; then + dim_readme_s=1.0; dim_readme_d="README 结构完整,含 ${section_count} 个关键章节" + elif [[ $section_count -ge 3 ]]; then + dim_readme_s=0.5; dim_readme_d="README 部分完整,${section_count} 个关键章节" + else + dim_readme_s=0; dim_readme_d="README 缺失或过于简略" + fi + log_info " README 章节数: ${section_count}" + + # ═══ Step 3: Dependency Declaration ═══ + log_step "3/7 检查依赖声明..." + local sub_json dep_files=0 dep_list="" names="" + sub_json=$(gl_run api GET "/v1/$OWNER/$REPO/sub_entries?ref=master") + if [[ "$(json_ok "$sub_json")" == "true" ]]; then + local data_type + data_type=$(echo "$sub_json" | jq -r '(.data | type) // "string"' 2>/dev/null) + if [[ "$data_type" == "array" ]]; then + names=$(echo "$sub_json" | jq -r '.data[].name // empty' 2>/dev/null) + fi + fi + if [[ -n "$names" ]]; then + for dep_file in "package.json" "go.mod" "requirements.txt" "pyproject.toml" \ + "Cargo.toml" "CMakeLists.txt" "pom.xml" "build.gradle" "Gemfile" \ + "Makefile" "DESCRIPTION" "Project.toml"; do + if echo "$names" | grep -qFx "$dep_file"; then + dep_files=$((dep_files + 1)) + dep_list="${dep_list}${dep_file}, " + fi + done + fi + + if [[ $dep_files -ge 1 ]]; then + dim_deps_s=1.0; dim_deps_d="有标准依赖文件: ${dep_list%, }" + elif echo "$readme_text" | grep -qiE "dependenc|requirement|依赖|安装|install"; then + dim_deps_s=0.5; dim_deps_d="README 中提及依赖" + else + dim_deps_s=0; dim_deps_d="无依赖声明" + fi + log_info " 依赖文件数: ${dep_files}" + + # ═══ Step 4: Build Instructions ═══ + log_step "4/7 检查构建说明..." + local build_score=0 + # Check README for build keywords + if echo "$readme_text" | grep -qiE "build|install|compile|make|构建|安装|编译|run|运行"; then + build_score=$((build_score + 1)) + fi + # Check for Makefile/Dockerfile + if echo "$names" | grep -qE "Makefile|Dockerfile|docker-compose"; then + build_score=$((build_score + 1)) + fi + # Check for CI config + if echo "$names" | grep -qE "\.github/workflows|\.gitlab-ci|Jenkinsfile"; then + build_score=$((build_score + 1)) + fi + + if [[ $build_score -ge 3 ]]; then + dim_build_s=1.0; dim_build_d="详细的构建说明和自动化配置" + elif [[ $build_score -ge 1 ]]; then + dim_build_s=0.5; dim_build_d="部分构建说明" + else + dim_build_s=0; dim_build_d="无构建说明" + fi + log_info " 构建说明得分: ${build_score}/3" + + # ═══ Step 5: CI Configuration ═══ + log_step "5/7 检查 CI 配置..." + local ci_json ci_builds=0 ci_ok=0 + ci_json=$(gl_run ci +builds --owner "$OWNER" --repo "$REPO" --limit 10) + if [[ "$(json_ok "$ci_json")" == "true" ]]; then + ci_builds=$(echo "$ci_json" | jq -r '(.data | length) // 0' 2>/dev/null) + ci_ok=$(echo "$ci_json" | jq -r '[.data[] | select(.status == "success" or .status == "completed")] | length' 2>/dev/null || echo "0") + fi + + if [[ $ci_builds -gt 0 ]] && [[ $ci_ok -ge 1 ]]; then + dim_ci_s=1.0; dim_ci_d="CI 已配置且通过 (${ci_ok}/${ci_builds})" + elif [[ $ci_builds -gt 0 ]]; then + dim_ci_s=0.5; dim_ci_d="CI 存在但最近构建失败" + else + dim_ci_s=0; dim_ci_d="无 CI 配置" + fi + log_info " CI 构建数: ${ci_builds}" + + # ═══ Step 6: Test Evidence ═══ + log_step "6/7 检查测试证据..." + local test_score=0 + # Check for test directories + if echo "$names" | grep -qE "^test/|^tests/|^spec/|^__tests__/"; then + test_score=$((test_score + 1)) + fi + # Check for test files + if echo "$names" | grep -qE "_test\.|\.test\.|_spec\.|\.spec\.|test_"; then + test_score=$((test_score + 1)) + fi + # Check README for test instructions + if echo "$readme_text" | grep -qiE "test|测试|validate|验证"; then + test_score=$((test_score + 1)) + fi + + if [[ $test_score -ge 3 ]]; then + dim_test_s=1.0; dim_test_d="有测试目录 + 测试文件 + 测试说明" + elif [[ $test_score -ge 1 ]]; then + dim_test_s=0.5; dim_test_d="部分测试证据" + else + dim_test_s=0; dim_test_d="无测试证据" + fi + log_info " 测试证据得分: ${test_score}/3" + + # ═══ Step 7: Data Availability ═══ + log_step "7/7 检查数据可用性声明..." + local data_score=0 data_evidence="" + if echo "$readme_text $repo_desc" | grep -qiE "dataset|data/|数据|zenodo|figshare|kaggle|huggingface"; then + data_score=$((data_score + 1)) + data_evidence="有关键词提及" + fi + if echo "$readme_text $repo_desc" | grep -qiE "https?://[^\s]+(?:zenodo|figshare|data\.|dataset)[^\s]*" 2>/dev/null; then + data_score=$((data_score + 1)) + data_evidence="${data_evidence}, 有数据链接" + fi + if [[ -n "$doi_found" ]]; then + data_score=$((data_score + 1)) + data_evidence="${data_evidence}, 有 DOI/论文引用" + fi + + if [[ $data_score -ge 2 ]]; then + dim_data_s=1.0; dim_data_d="明确的数据可用性声明${data_evidence}" + elif [[ $data_score -ge 1 ]]; then + dim_data_s=0.5; dim_data_d="部分数据声明${data_evidence}" + else + dim_data_s=0; dim_data_d="无数据可用性声明" + fi + log_info " 数据声明得分: ${data_score}/3" + + # ═══ Calculate Total Score ═══ + log_step "计算复现性评分..." + + local total_score + total_score=$(weighted_sum \ + "$dim_license_s" 0.15 \ + "$dim_nosecret_s" 0.15 \ + "$dim_readme_s" 0.15 \ + "$dim_deps_s" 0.15 \ + "$dim_build_s" 0.10 \ + "$dim_ci_s" 0.10 \ + "$dim_test_s" 0.10 \ + "$dim_data_s" 0.10) + total_score=$(awk -v s="$total_score" 'BEGIN { printf "%.1f", s * 100 }') + + local grade + if awk "BEGIN { exit ($total_score >= 85) ? 0 : 1 }"; then grade="A" + elif awk "BEGIN { exit ($total_score >= 70) ? 0 : 1 }"; then grade="B" + elif awk "BEGIN { exit ($total_score >= 55) ? 0 : 1 }"; then grade="C" + elif awk "BEGIN { exit ($total_score >= 40) ? 0 : 1 }"; then grade="D" + else grade="F"; fi + + log_ok "复现性评分: ${total_score}/100 — 等级 ${grade}" + + # ═══ Generate HTML ═══ + log_step "生成 HTML 评分卡..." + + if [[ "${DRY_RUN:-false}" != "true" ]]; then + cat > "$OUTPUT_FILE" < + + + + +${REPO_FULL} — 复现性评分卡 + + + + +
    +

    ${REPO_FULL} — 科研复现性评分卡

    +
    ${today}
    +
    +
    + +
    +
    +
    ${grade}
    +
    ${total_score} / 100
    +
    + $( [[ "$grade" == "A" ]] && echo "优秀 — 高度可复现" ) + $( [[ "$grade" == "B" ]] && echo "良好 — 基本可复现" ) + $( [[ "$grade" == "C" ]] && echo "一般 — 部分可复现" ) + $( [[ "$grade" == "D" ]] && echo "不足 — 复现困难" ) + $( [[ "$grade" == "F" ]] && echo "差 — 几乎不可复现" ) +
    +
    +
    +

    维度雷达图

    +
    +
    +
    +

    维度明细

    + + + + + + + + + + +
    维度评分权重
    许可证$(awk -v s="$dim_license_s" 'BEGIN {printf "%.0f%%", s*100}')15%
    无密钥/PII$(awk -v s="$dim_nosecret_s" 'BEGIN {printf "%.0f%%", s*100}')15%
    README 完整$(awk -v s="$dim_readme_s" 'BEGIN {printf "%.0f%%", s*100}')15%
    依赖声明$(awk -v s="$dim_deps_s" 'BEGIN {printf "%.0f%%", s*100}')15%
    构建说明$(awk -v s="$dim_build_s" 'BEGIN {printf "%.0f%%", s*100}')10%
    CI 配置$(awk -v s="$dim_ci_s" 'BEGIN {printf "%.0f%%", s*100}')10%
    测试证据$(awk -v s="$dim_test_s" 'BEGIN {printf "%.0f%%", s*100}')10%
    数据可用性$(awk -v s="$dim_data_s" 'BEGIN {printf "%.0f%%", s*100}')10%
    +
    +
    + +
    +

    详细评估与改进建议

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    维度评分证据建议
    许可证$( [[ $dim_license_s == "1.0" ]] && echo "✅" || echo "❌")${dim_license_d}$( [[ $dim_license_s != "1.0" ]] && echo "建议添加 MIT/Apache-2.0/GPL-3.0 许可证" || echo "—")
    无密钥/PII$( [[ $dim_nosecret_s == "1.0" ]] && echo "✅" || echo "⚠️")${dim_nosecret_d}$( [[ $dim_nosecret_s != "1.0" ]] && echo "立即移除泄露的密钥,使用环境变量管理敏感信息" || echo "—")
    README 完整$( [[ $dim_readme_s == "1.0" ]] && echo "✅" || ([[ $dim_readme_s == "0.5" ]] && echo "⚠️" || echo "❌"))${dim_readme_d}$( [[ $dim_readme_s != "1.0" ]] && echo "补充项目目的、安装、使用、许可和引用章节" || echo "—")
    依赖声明$( [[ $dim_deps_s == "1.0" ]] && echo "✅" || ([[ $dim_deps_s == "0.5" ]] && echo "⚠️" || echo "❌"))${dim_deps_d}$( [[ $dim_deps_s != "1.0" ]] && echo "添加 package.json/go.mod/requirements.txt 等标准依赖文件" || echo "—")
    构建说明$( [[ $dim_build_s == "1.0" ]] && echo "✅" || ([[ $dim_build_s == "0.5" ]] && echo "⚠️" || echo "❌"))${dim_build_d}$( [[ $dim_build_s != "1.0" ]] && echo "添加 Makefile/Dockerfile + README 中的构建步骤" || echo "—")
    CI 配置$( [[ $dim_ci_s == "1.0" ]] && echo "✅" || ([[ $dim_ci_s == "0.5" ]] && echo "⚠️" || echo "❌"))${dim_ci_d}$( [[ $dim_ci_s != "1.0" ]] && echo "配置 GitLink CI 或 GitHub Actions 自动构建和测试" || echo "—")
    测试证据$( [[ $dim_test_s == "1.0" ]] && echo "✅" || ([[ $dim_test_s == "0.5" ]] && echo "⚠️" || echo "❌"))${dim_test_d}$( [[ $dim_test_s != "1.0" ]] && echo "添加单元测试和集成测试,在 README 中说明如何运行" || echo "—")
    数据可用性$( [[ $dim_data_s == "1.0" ]] && echo "✅" || ([[ $dim_data_s == "0.5" ]] && echo "⚠️" || echo "❌"))${dim_data_d}$( [[ $dim_data_s != "1.0" ]] && echo "说明数据集来源,提供 Zenodo/Figshare 链接或生成脚本" || echo "—")
    +
    + +
    + + + + + +HTMLEOF + log_ok "HTML 评分卡已生成: $OUTPUT_FILE" + fi + + # ═══ Summary ═══ + echo "" + divider + log_title "复现性评估摘要" + echo " 综合评分: ${total_score}/100 (${grade})" + echo " 许可证: $( [[ $dim_license_s == "1.0" ]] && echo "✅" || echo "❌") ${dim_license_d}" + echo " 密钥/PII: $( [[ $dim_nosecret_s == "1.0" ]] && echo "✅" || echo "⚠️") ${dim_nosecret_d}" + echo " README: $( [[ $dim_readme_s == "1.0" ]] && echo "✅" || echo "❌") ${dim_readme_d}" + echo " 依赖: $( [[ $dim_deps_s == "1.0" ]] && echo "✅" || echo "❌") ${dim_deps_d}" + echo " 构建: $( [[ $dim_build_s == "1.0" ]] && echo "✅" || echo "❌") ${dim_build_d}" + echo " CI: $( [[ $dim_ci_s == "1.0" ]] && echo "✅" || echo "❌") ${dim_ci_d}" + echo " 测试: $( [[ $dim_test_s == "1.0" ]] && echo "✅" || echo "❌") ${dim_test_d}" + echo " 数据: $( [[ $dim_data_s == "1.0" ]] && echo "✅" || echo "❌") ${dim_data_d}" + divider +} + +main "$@" diff --git a/workflows/academic/10-research-progress.ps1 b/workflows/academic/10-research-progress.ps1 new file mode 100644 index 0000000..f29fa12 --- /dev/null +++ b/workflows/academic/10-research-progress.ps1 @@ -0,0 +1,278 @@ +# GitLink Research - Scenario 5: Progress Tracking & Alerting (PowerShell) +param( + [string]$Owner, [string]$Repo, [string]$Org = "", + [int]$Weeks = 4, [string]$Output = "", + [switch]$NoWiki, [switch]$DryRun +) + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Import-Module "$ScriptDir\..\lib\common.psm1" -Force -WarningAction SilentlyContinue + +$ErrorActionPreference = "Continue" +Check-Auth +if ($Org) { $Owner = $Org; $Repo = "__org__" } +else { $resolved = Resolve-OwnerRepo -Owner $Owner -Repo $Repo; $Owner = $resolved.Owner; $Repo = $resolved.Repo } +$Today = Get-Date -Format "yyyy-MM-dd" +$OutputDir = Join-Path $ScriptDir "..\output" +if (-not (Test-Path $OutputDir)) { New-Item -ItemType Directory -Path $OutputDir -Force | Out-Null } +$OutputFile = if ($Output) { $Output } else { Join-Path $OutputDir "progress-weekly-${Owner}-${Today}.html" } + +Log-Title "GitLink Research - Progress Tracking" + +# 1. Issues +Log-Step "1/5 Collecting Issue data..." +$openIssues = Invoke-GLCheck @("issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "open", "--limit", "100") +$closedIssues = Invoke-GLCheck @("issue", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "closed", "--limit", "100") +$oiData = if ($openIssues.data.issues) { $openIssues.data.issues } else { $openIssues.data } +$totalOpen = if ($openIssues.ok) { @($oiData).Count } else { 0 } +$ciData = if ($closedIssues.data.issues) { $closedIssues.data.issues } else { $closedIssues.data } +$totalClosed = if ($closedIssues.ok) { @($ciData).Count } else { 0 } +Log-Info " Issues: $totalOpen open / $totalClosed closed" + +# 2. PRs +Log-Step "2/5 Collecting PR data..." +$mergedPrs = Invoke-GLCheck @("pr", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "merged", "--limit", "100") +$openPrs = Invoke-GLCheck @("pr", "+list", "--owner", $Owner, "--repo", $Repo, "--state", "open", "--limit", "50") +$mData = if ($mergedPrs.data.issues) { $mergedPrs.data.issues } elseif ($mergedPrs.data.pulls) { $mergedPrs.data.pulls } else { $mergedPrs.data } +$totalMerged = if ($mergedPrs.ok) { @($mData).Count } else { 0 } +$opData = if ($openPrs.data.issues) { $openPrs.data.issues } elseif ($openPrs.data.pulls) { $openPrs.data.pulls } else { $openPrs.data } +$totalOpenPrs = if ($openPrs.ok) { @($opData).Count } else { 0 } +Log-Info " PRs: $totalOpenPrs open / $totalMerged merged" + +# 3. Releases +Log-Step "3/5 Collecting Release data..." +$releases = Invoke-GLCheck @("release", "+list", "--owner", $Owner, "--repo", $Repo, "--limit", "20") +$releaseCount = if ($releases.ok) { @($releases.data).Count } else { 0 } +$lastReleaseDate = "" +if ($releases.ok -and $releaseCount -gt 0) { + $relData = if ($releases.data.releases) { $releases.data.releases } else { $releases.data } + if ($relData -is [array] -and $relData.Count -gt 0) { + $lastReleaseDate = if ($relData[0].created_at) { $relData[0].created_at } else { "" } + if ($lastReleaseDate.Length -ge 10) { $lastReleaseDate = $lastReleaseDate.Substring(0, 10) } + } +} +Log-Info " Releases: $releaseCount" + +# 4. CI +Log-Step "4/5 Collecting CI data..." +$ciPipelines = Get-CIPipelines $Owner $Repo +$ciTotal = $ciPipelines.Count +$ciOk = 0 +foreach ($b in $ciPipelines) { + if (Test-CISuccess $b) { $ciOk++ } +} +Log-Info " CI: $ciOk/$ciTotal passed" + +# 5. Health score +Log-Step "5/5 Calculating health score and anomaly detection..." +$prevClosed = [Math]::Floor($totalClosed / 2) + +# Issue velocity (normalized) +$iv = [Math]::Min($totalClosed / ($Weeks * 7.0), 1.0) + +# PR merge rate +$prMR = if (($totalMerged + $totalOpenPrs) -gt 0) { $totalMerged / ($totalMerged + $totalOpenPrs) } else { 0 } + +# Release cadence +$rc = if ($releaseCount -ge 3) { 1.0 } elseif ($releaseCount -ge 1) { 0.5 } else { 0.2 } + +# CI pass rate +$ciScore = if ($ciTotal -gt 0) { $ciOk / $ciTotal } else { 0 } + +# Activity trend +$diff = if ($prevClosed -gt 0) { ($totalClosed - $prevClosed) / $prevClosed } else { 0 } +$trend = [Math]::Min([Math]::Max($diff + 0.5, 0.0), 1.0) + +$health = [Math]::Round(($iv * 0.30 + $prMR * 0.25 + $rc * 0.25 + $ciScore * 0.10 + $trend * 0.10) * 100, 1) + +# Anomalies +$anomalies = @() +if ($totalOpen -gt 20) { $anomalies += @{type="stalled_issue"; severity="Warning"; detail="Open issues ($totalOpen) high, may be stalled"} } +if ($totalOpenPrs -gt 5) { $anomalies += @{type="pr_bottleneck"; severity="Warning"; detail="$totalOpenPrs open PRs backlogged"} } +if ($releaseCount -eq 0) { $anomalies += @{type="no_release"; severity="Info"; detail="No release record"} } +if ($prevClosed -gt 0 -and $totalClosed -lt $prevClosed * 0.5) { $anomalies += @{type="activity_decline"; severity="Warning"; detail="Activity dropped >50%"} } +if ($ciTotal -gt 0 -and $ciOk / $ciTotal -lt 0.5) { $anomalies += @{type="ci_failure"; severity="Warning"; detail="CI pass rate <50%"} } +$anomalyCount = $anomalies.Count + +$healthLabel = if ($health -ge 80) { "Healthy" } elseif ($health -ge 60) { "Normal" } elseif ($health -ge 40) { "Needs Attention" } else { "At Risk" } +$healthColor = if ($health -ge 80) { "#2e7d32" } elseif ($health -ge 60) { "#558b2f" } elseif ($health -ge 40) { "#f57c00" } else { "#c62828" } + +Log-Ok "Health score: $health/100 ($healthLabel)" +if ($anomalyCount -gt 0) { Log-Warn "Detected $anomalyCount anomalies" } + +# ===== Generate HTML Report ===== +Log-Step "Generating weekly HTML report..." + +# Build anomaly table rows +$anomalyRows = "" +if ($anomalyCount -gt 0) { + foreach ($a in $anomalies) { + $sevClass = if ($a.severity -eq "Critical") { "sev-Critical" } elseif ($a.severity -eq "Warning") { "sev-Warning" } else { "sev-Info" } + $anomalyRows += "$($a.type)$($a.severity)$($a.detail)`n" + } +} + +$issueTrend = if ($totalClosed -gt $totalOpen) { "Improving" } else { "Backlog growing" } +$issueAdvice = if ($totalOpen -gt $totalClosed) { "Schedule an Issue cleanup day" } else { "-" } +$prTrend = if ($totalOpenPrs -le 5) { "Normal" } else { "Backlogged" } +$prAdvice = if ($totalOpenPrs -gt 5) { "Increase code review frequency" } else { "-" } +$releaseTrend = if ($releaseCount -ge 3) { "Active" } else { "Inactive" } +$releaseAdvice = if ($releaseCount -eq 0) { "Recommend publishing v0.1.0" } else { "-" } +$ciPassRate = if ($ciTotal -gt 0) { [Math]::Round($ciOk / $ciTotal * 100) } else { 0 } +$ciTrend = if ($ciTotal -gt 0 -and $ciPassRate -ge 80) { "Stable" } else { "Needs improvement" } +$ciAdvice = if ($ciTotal -eq 0) { "Configure GitLink CI" } else { "-" } +$lastRelDisplay = if ($lastReleaseDate) { $lastReleaseDate } else { "None" } +$lastRelTrend = if ($lastReleaseDate) { "Released" } else { "No record" } + +$htmlContent = @" + + + + + +$Owner/$Repo —Progress Weekly Report $Today + + + + +
    +

    $Owner/$Repo —Research Progress Weekly

    +
    $Weeks week(s) review — $Today
    +
    +
    + +
    +
    +
    $health
    +
    Health Score / 100 —$healthLabel
    +
    +
    $totalOpen
    Open Issues
    +
    $totalOpenPrs
    Open PRs
    +
    $totalMerged
    Merged PRs
    +
    $releaseCount
    Releases
    +
    $anomalyCount
    Anomalies
    +
    + +
    +
    +

    Issue / PR Overview

    +
    +
    +
    +

    Anomaly Alerts

    +"@ + +if ($anomalyCount -eq 0) { + $htmlContent += "
    No anomalies detected, project is healthy
    `n" +} else { + $htmlContent += "$anomalyRows
    TypeSeverityDetail
    `n" +} + +$htmlContent += @" +
    +
    + +
    +

    Progress Indicators

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    IndicatorValueTrendSuggestion
    Issue Velocity$totalOpen open / $totalClosed closed$issueTrend$issueAdvice
    PR Merge Rate$([Math]::Round($prMR * 100))%$prTrend$prAdvice
    Release Cadence$releaseCount release(s)$releaseTrend$releaseAdvice
    CI Stability${ciPassRate}% ($ciOk/$ciTotal)$ciTrend$ciAdvice
    Latest Release$lastRelDisplay$lastRelTrend-
    +
    + +
    + + + + + +"@ + +if (-not $DryRun) { + $htmlContent | Out-File -FilePath $OutputFile -Encoding UTF8 + Log-Ok "Weekly report generated: $OutputFile" +} else { + Log-Warn "[DRY RUN] Would generate: $OutputFile" +} + +# Console summary +Divider +Write-Host "====== Weekly Progress Summary ======" -ForegroundColor White +Write-Host " Repo: $Owner/$Repo" +Write-Host " Health: $health/100 ($healthLabel)" +Write-Host " Issues: $totalOpen open / $totalClosed closed" +Write-Host " PRs: $totalOpenPrs open / $totalMerged merged" +Write-Host " Releases: $releaseCount | CI: $ciOk/$ciTotal passed" +if ($anomalyCount -gt 0) { + Write-Host " Anomalies ($anomalyCount):" -ForegroundColor Yellow + foreach ($a in $anomalies) { Write-Host " [$($a.severity)] $($a.detail)" } +} else { Write-Host " No anomalies detected" -ForegroundColor Green } +Write-Host " Report: $OutputFile" +Divider +Log-Ok "Analysis complete" diff --git a/workflows/academic/10-research-progress.sh b/workflows/academic/10-research-progress.sh new file mode 100644 index 0000000..2f008ab --- /dev/null +++ b/workflows/academic/10-research-progress.sh @@ -0,0 +1,393 @@ +#!/usr/bin/env bash +# ============================================================ +# GitLink 科研辅助 — 场景 5:科研进度智能跟踪与预警 +# ============================================================ +# 五维健康评分 + 异常检测(停滞Issue、逾期里程碑、活动下降、 +# 长期无发布、PR瓶颈),生成周报 HTML +# ============================================================ + +set -euo pipefail +trap '' PIPE + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" + +# ── Configuration ──────────────────────────────────────────────────── +OUTPUT_DIR="${SCRIPT_DIR}/../../output" +OUTPUT_FILE="" +PUBLISH_WIKI="true" +LOOKBACK_WEEKS=4 +ORG_MODE="" + +usage() { + cat <<'EOF' +Usage: 10-research-progress.sh [options] + +Options: + --owner Repository owner (single repo mode) + --repo Repository name (single repo mode) + --org Organization name (multi-repo mode) + --weeks Lookback weeks (default: 4) + --output Output HTML file path + --no-wiki Skip publishing to Wiki + --dry-run Preview mode + +Examples: + 10-research-progress.sh --owner zzx-coder --repo gitlink-cli + 10-research-progress.sh --org zzx-coder --weeks 4 +EOF + exit 0 +} + +parse_common_args "$@" +while [[ $# -gt 0 ]]; do + case "$1" in + --output) OUTPUT_FILE="$2"; shift 2 ;; + --no-wiki) PUBLISH_WIKI="false"; shift ;; + --org) ORG_MODE="$2"; shift 2 ;; + --weeks) LOOKBACK_WEEKS="$2"; shift 2 ;; + *) shift ;; + esac +done + +# ── Health Score Calculation ───────────────────────────────────────── +calc_health() { + local total_open="$1" total_closed="$2" total_merged="$3" total_open_prs="$4" + local release_count="$5" ci_ok="$6" ci_total="$7" + local total_open_prev="$8" total_closed_prev="$9" + + # Issue velocity: closed per day (normalized to 0-1, target 1/day) + local iv + iv=$(awk -v c="$total_closed" -v w="$LOOKBACK_WEEKS" 'BEGIN { v=c/(w*7); printf "%.4f", (v>1?1:v) }') + + # PR merge rate + local pr_mr + pr_mr=$(awk -v m="$total_merged" -v o="$total_open_prs" 'BEGIN { t=m+o; printf "%.4f", (t>0?m/t:0) }') + + # Release cadence + local rc + rc=$(awk -v r="$release_count" 'BEGIN { printf "%.4f", (r>=3?1:(r>=1?0.5:0.2)) }') + + # CI pass rate + local ci_score + ci_score=$(awk -v ok="$ci_ok" -v t="$ci_total" 'BEGIN { printf "%.4f", (t>0?ok/t:0) }') + + # Activity trend + local trend diff + diff=$(awk -v cur="$total_closed" -v prev="$total_closed_prev" 'BEGIN { printf "%.4f", (prev>0)?(cur-prev)/prev:0 }') + trend=$(awk -v d="$diff" 'BEGIN { t=d+0.5; printf "%.4f", (t<0?0:(t>1?1:t)) }') + + # Weighted sum + local health + health=$(awk -v iv="$iv" -v pr="$pr_mr" -v rc="$rc" -v ci="$ci_score" -v tr="$trend" \ + 'BEGIN { printf "%.1f", (iv*0.30+pr*0.25+rc*0.25+ci*0.10+tr*0.10)*100 }') + echo "$health" +} + +# ── Anomaly Detection ──────────────────────────────────────────────── +detect_anomalies() { + local anomalies="" + local anomaly_count=0 + + # Stalled issues: open > 60 days (simulated via count threshold) + if [[ ${1:-0} -gt 20 ]]; then + anomalies="${anomalies}{\"type\":\"stalled_issue\",\"severity\":\"Warning\",\"detail\":\"开放 Issue 数量(${1})偏高,可能存在停滞\"}," + anomaly_count=$((anomaly_count + 1)) + fi + + # PR bottleneck + if [[ ${3:-0} -gt 5 ]]; then + anomalies="${anomalies}{\"type\":\"pr_bottleneck\",\"severity\":\"Warning\",\"detail\":\"${3} 个开放 PR 积压\"}," + anomaly_count=$((anomaly_count + 1)) + fi + + # No recent release + if [[ ${4:-0} -eq 0 ]]; then + anomalies="${anomalies}{\"type\":\"no_release\",\"severity\":\"Info\",\"detail\":\"无 Release 记录\"}," + anomaly_count=$((anomaly_count + 1)) + fi + + # Activity decline (if prev data available) + if [[ -n "${5:-}" ]] && [[ -n "${6:-}" ]]; then + local decline + decline=$(awk -v cur="${5}" -v prev="${6}" 'BEGIN { if(prev>0 && cur0 && ok/t<0.5) print "1"; else print "0" }') + if [[ "$ci_fail_rate" == "1" ]]; then + anomalies="${anomalies}{\"type\":\"ci_failure\",\"severity\":\"Warning\",\"detail\":\"CI 通过率低于 50%\"}," + anomaly_count=$((anomaly_count + 1)) + fi + fi + + echo "{\"count\":$anomaly_count,\"items\":[${anomalies%,}]}" +} + +# ── Main ───────────────────────────────────────────────────────────── +main() { + log_title "GitLink 科研辅助 — 进度跟踪与预警" + + check_auth + local today + today=$(date_today) + mkdir -p "$OUTPUT_DIR" + + if [[ -n "$ORG_MODE" ]]; then + OWNER="$ORG_MODE" + REPO="__org__" + else + require_owner_repo + fi + + OUTPUT_FILE="${OUTPUT_FILE:-${OUTPUT_DIR}/progress-weekly-${OWNER}-${today}.html}" + + # ═══ Data Collection ═══ + log_step "1/5 收集 Issue 数据..." + local open_json closed_json + open_json=$(gl_run issue +list --owner "$OWNER" --repo "$REPO" --state open --limit 100) + closed_json=$(gl_run issue +list --owner "$OWNER" --repo "$REPO" --state closed --limit 100) + + local total_open=0 total_closed=0 + if [[ "$(json_ok "$open_json")" == "true" ]]; then + total_open=$(echo "$open_json" | jq -r '(.data.issues // .data | if type == "array" then length else 0 end)' 2>/dev/null) + fi + if [[ "$(json_ok "$closed_json")" == "true" ]]; then + total_closed=$(echo "$closed_json" | jq -r '(.data.issues // .data | if type == "array" then length else 0 end)' 2>/dev/null) + fi + + log_info " Issues: ${total_open} 开放 / ${total_closed} 已关闭" + + # ═══ PR Data ═══ + log_step "2/5 收集 PR 数据..." + local merged_json open_prs_json + merged_json=$(gl_run pr +list --owner "$OWNER" --repo "$REPO" --state merged --limit 100) + open_prs_json=$(gl_run pr +list --owner "$OWNER" --repo "$REPO" --state open --limit 50) + + local total_merged=0 total_open_prs=0 + if [[ "$(json_ok "$merged_json")" == "true" ]]; then + total_merged=$(echo "$merged_json" | jq -r '(.data.issues // .data.pulls // .data | if type == "array" then length else 0 end)' 2>/dev/null) + fi + if [[ "$(json_ok "$open_prs_json")" == "true" ]]; then + total_open_prs=$(echo "$open_prs_json" | jq -r '(.data.issues // .data.pulls // .data | if type == "array" then length else 0 end)' 2>/dev/null) + fi + + log_info " PRs: ${total_open_prs} 开放 / ${total_merged} 已合并" + + # ═══ Release Data ═══ + log_step "3/5 收集 Release 数据..." + local release_json release_count=0 last_release_date="" + release_json=$(gl_run release +list --owner "$OWNER" --repo "$REPO" --limit 20) + if [[ "$(json_ok "$release_json")" == "true" ]]; then + release_count=$(echo "$release_json" | jq -r '(.data.releases | length) // 0' 2>/dev/null) + last_release_date=$(echo "$release_json" | jq -r '.data.releases[0].created_at // ""' 2>/dev/null) + if [[ -n "$last_release_date" ]]; then + last_release_date="${last_release_date:0:10}" + fi + fi + log_info " Releases: ${release_count}" + + # ═══ CI Data ═══ + log_step "4/5 收集 CI 数据..." + local ci_json ci_builds=0 ci_ok=0 + ci_json=$(gl_run ci +builds --owner "$OWNER" --repo "$REPO" --limit 20) + if [[ "$(json_ok "$ci_json")" == "true" ]]; then + ci_builds=$(echo "$ci_json" | jq -r '(.data | length) // 0' 2>/dev/null) + ci_ok=$(echo "$ci_json" | jq -r '[.data[] | select(.status == "success" or .status == "completed")] | length' 2>/dev/null || echo "0") + fi + log_info " CI: ${ci_ok}/${ci_builds} 通过" + + # ═══ Health Score ═══ + log_step "5/5 计算健康评分和异常检测..." + local health_score + # Provide prev period data as half of current (simplified estimation) + local prev_closed=$((total_closed / 2)) + health_score=$(calc_health "$total_open" "$total_closed" "$total_merged" "$total_open_prs" \ + "$release_count" "$ci_ok" "$ci_builds" "$total_open" "$prev_closed") + + local anomalies_json + anomalies_json=$(detect_anomalies "$total_open" "$total_closed" "$total_open_prs" \ + "$release_count" "$total_closed" "$prev_closed" "$ci_ok" "$ci_builds") + + local anomaly_count + anomaly_count=$(echo "$anomalies_json" | jq -r '.count // 0' 2>/dev/null) + + # ═══ Health Grade ═══ + local health_label health_color + if awk "BEGIN { exit ($health_score >= 80) ? 0 : 1 }"; then + health_label="健康"; health_color="#2e7d32" + elif awk "BEGIN { exit ($health_score >= 60) ? 0 : 1 }"; then + health_label="正常"; health_color="#558b2f" + elif awk "BEGIN { exit ($health_score >= 40) ? 0 : 1 }"; then + health_label="需关注"; health_color="#f57c00" + else + health_label="风险"; health_color="#c62828" + fi + + log_ok "健康评分: ${health_score}/100 (${health_label})" + if [[ $anomaly_count -gt 0 ]]; then + log_warn "检测到 ${anomaly_count} 个异常" + fi + + # ═══ Generate HTML ═══ + log_step "生成周报 HTML..." + + # Build anomaly table rows + local anomaly_rows="" + if [[ $anomaly_count -gt 0 ]]; then + anomaly_rows=$(echo "$anomalies_json" | jq -r '.items[] | "\(.type)\(.severity)\(.detail)"' 2>/dev/null) + fi + + if [[ "${DRY_RUN:-false}" != "true" ]]; then + cat > "$OUTPUT_FILE" < + + + + +${OWNER}/${REPO} — 进度周报 ${today} + + + + +
    +

    ${OWNER}/${REPO} — 科研进度周报

    +
    $LOOKBACK_WEEKS 周回顾 — ${today}
    +
    +
    + +
    +
    +
    ${health_score}
    +
    健康评分 / 100 — ${health_label}
    +
    +
    ${total_open}
    开放 Issues
    +
    ${total_open_prs}
    开放 PRs
    +
    ${total_merged}
    已合并 PRs
    +
    ${release_count}
    Release 数
    +
    ${anomaly_count}
    异常信号
    +
    + +
    +
    +

    Issue / PR 概览

    +
    +
    +
    +

    异常预警

    + $( if [[ $anomaly_count -eq 0 ]]; then + echo "
    未检测到异常,项目运行良好
    " + else + echo "${anomaly_rows}
    类型严重度详情
    " + fi ) +
    +
    + +
    +

    进度指标明细

    + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
    指标数值趋势建议
    Issue 流速${total_open} 开放 / ${total_closed} 关闭$( [[ $total_closed -gt $total_open ]] && echo "↑ 改善" || echo "↓ 积压")$( [[ $total_open -gt $total_closed ]] && echo "建议安排 Issue 清理日" || echo "—")
    PR 合并率$(awk -v m="$total_merged" -v o="$total_open_prs" 'BEGIN { t=m+o; printf "%.0f%%", (t>0?m/t*100:0) }')$( [[ $total_open_prs -le 5 ]] && echo "正常" || echo "积压")$( [[ $total_open_prs -gt 5 ]] && echo "建议增加 Code Review 频率" || echo "—")
    发布节奏${release_count} 个 Release$( [[ $release_count -ge 3 ]] && echo "活跃" || echo "不活跃")$( [[ $release_count -eq 0 ]] && echo "建议发布 v0.1.0 初始版本" || echo "—")
    CI 稳定性$(awk -v o="$ci_ok" -v t="$ci_builds" 'BEGIN { printf "%.0f%%", (t>0?o/t*100:0) }') (${ci_ok}/${ci_builds})$( awk "BEGIN { if (${ci_builds}>0 && ${ci_ok}/${ci_builds}>=0.8) print \"稳定\"; else print \"待改进\" }" )$( [[ $ci_builds -eq 0 ]] && echo "建议配置 GitLink CI" || echo "—")
    最近 Release${last_release_date:-无}$( [[ -n "$last_release_date" ]] && echo "已发布" || echo "无记录")
    +
    + +
    + + + + + +HTMLEOF + log_ok "周报已生成: $OUTPUT_FILE" + fi + + # ═══ Summary ═══ + echo "" + divider + log_title "进度周报摘要" + echo " 仓库: ${OWNER}/${REPO}" + echo " 健康评分: ${health_score}/100 (${health_label})" + echo " Issues: ${total_open} 开放 / ${total_closed} 已关闭" + echo " PRs: ${total_open_prs} 开放 / ${total_merged} 已合并" + echo " Releases: ${release_count}" + echo " CI: ${ci_ok}/${ci_builds} 通过" + echo " 异常数: ${anomaly_count}" + if [[ $anomaly_count -gt 0 ]]; then + echo "$anomalies_json" | jq -r '.items[] | " [\(.severity)] \(.detail)"' 2>/dev/null + fi + divider +} + +main "$@" diff --git a/workflows/academic/11-research-citation.ps1 b/workflows/academic/11-research-citation.ps1 new file mode 100644 index 0000000..98a8c23 --- /dev/null +++ b/workflows/academic/11-research-citation.ps1 @@ -0,0 +1,117 @@ +# GitLink Research - Scenario 6: Citation Format Generator (PowerShell) +param( + [string]$Owner, [string]$Repo, [string]$Format = "all", + [string]$Output = "", [switch]$DryRun +) + +$ScriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +Import-Module "$ScriptDir\..\lib\common.psm1" -Force -WarningAction SilentlyContinue +Import-Module "$ScriptDir\..\lib\research-common.psm1" -Force -WarningAction SilentlyContinue + +$ErrorActionPreference = "Stop" +Check-Auth +$resolved = Resolve-OwnerRepo -Owner $Owner -Repo $Repo +$Owner = $resolved.Owner; $Repo = $resolved.Repo +$Today = Get-DateToday + +Log-Title "GitLink Research - Citation Generator" + +# 1. Fetch repo metadata +Log-Step "Fetching repo metadata..." +$repoJson = Invoke-GLCheck @("repo", "+info", "--owner", $Owner, "--repo", $Repo) +$repoName = if ($repoJson.data.name) { $repoJson.data.name } elseif ($repoJson.data.full_name) { $repoJson.data.full_name } else { "$Owner/$Repo" } +$repoDesc = if ($repoJson.data.description) { $repoJson.data.description } else { "" } +$updatedAt = if ($repoJson.data.updated_at) { $repoJson.data.updated_at } else { "" } + +# DOI detection +$doi = "" +if ($repoDesc -match '10\.\d{4,}/[\w.\-/]+') { $doi = $Matches[0] } +if ($doi) { Log-Info " DOI detected: $doi" } + +# 2. Get latest release +Log-Step "Fetching latest version..." +$releaseJson = Invoke-GLCheck @("release", "+list", "--owner", $Owner, "--repo", $Repo, "--limit", "1") +$version = if ($releaseJson.ok -and $releaseJson.data[0].tag_name) { $releaseJson.data[0].tag_name } else { "v0.0.0-dev" } +$releaseDate = if ($releaseJson.ok -and $releaseJson.data[0].created_at) { $releaseJson.data[0].created_at } else { $updatedAt } +if ($releaseDate.Length -ge 10) { $releaseDate = $releaseDate.Substring(0, 10) } +$releaseYear = if ($releaseDate.Length -ge 4) { $releaseDate.Substring(0, 4) } else { (Get-Date).Year } + +# 3. Get members +Log-Step "Fetching contributors..." +$membersJson = Invoke-GLCheck @("repo", "+members", "--owner", $Owner, "--repo", $Repo, "--limit", "20") +$authorNames = @() +if ($membersJson.ok) { + $data = if ($membersJson.data.members) { $membersJson.data.members } else { $membersJson.data } + if ($data -is [array]) { + $authorNames = $data | ForEach-Object { if ($_.name) { $_.name } elseif ($_.login) { $_.login } else { "" } } | Where-Object { $_ } + } +} +if ($authorNames.Count -eq 0) { $authorNames = @($Owner) } + +# 4. Get repo URL +$repoUrl = try { git remote get-url origin 2>$null } catch { "" } +if (-not $repoUrl) { $repoUrl = "https://gitlink.org.cn/$Owner/$Repo" } +$repoUrl = $repoUrl -replace '\.git$', '' + +# Authors formatting +$bibtexAuthors = Format-AuthorsBibtex $authorNames +$apaAuthors = Format-AuthorsAPA $authorNames + +# MLA +$parts0 = $authorNames[0] -split '\s+' +$mlaAuthors = "$($parts0[-1]), $($parts0[0])" +if ($authorNames.Count -gt 1) { $mlaAuthors += ", et al." } + +# GB/T 7714 +$gbAuthors = ($authorNames | Select-Object -First 3) -join ", " +if ($authorNames.Count -gt 3) { $gbAuthors += "et al." } + +# Short name for BibTeX key +$shortName = $Repo -replace '[^a-zA-Z0-9_-]', '_' + +# Generate output +$citationBody = "" + +if ($Format -eq "bibtex" -or $Format -eq "all") { + $citationBody += "@software{$shortName,`n author = {$bibtexAuthors},`n title = {$repoName},`n version = {$version},`n date = {$releaseDate},`n publisher = {GitLink},`n url = {$repoUrl}" + if ($doi) { $citationBody += ",`n doi = {$doi}" } + $citationBody += ",`n note = {$repoDesc}`n}`n`n" +} + +if ($Format -eq "apa" -or $Format -eq "all") { + $citationBody += "$apaAuthors ($releaseYear). $repoName (Version $version) [Computer software].`n GitLink. $repoUrl`n`n" +} + +if ($Format -eq "mla" -or $Format -eq "all") { + $citationBody += "$mlaAuthors. $repoName. Version $version, GitLink,`n $releaseDate, $repoUrl.`n`n" +} + +if ($Format -eq "gbt7714" -or $Format -eq "all") { + $citationBody += "[1] $gbAuthors. $repoName[CP/OL]. $version. GitLink,`n $releaseDate[$Today]. $repoUrl.`n`n" +} + +if ($Format -eq "cff" -or $Format -eq "all") { + $cffDate = if ($releaseDate.Length -ge 10) { $releaseDate.Substring(0, 10) } else { $releaseDate } + $citationBody += "cff-version: 1.2.0`nmessage: `"If you use this software, please cite it as below.`"`nauthors:`n" + foreach ($a in $authorNames) { + if (-not $a) { continue } + $parts = $a -split '\s+' + $citationBody += " - family-names: $($parts[-1])`n given-names: $($parts[0])`n" + } + $citationBody += "title: `"$repoName`"`nversion: $version`ndate-released: $cffDate`nurl: `"$repoUrl`"`nrepository-code: `"$repoUrl.git`"`n" + if ($doi) { $citationBody += "doi: $doi`n" } +} + +Divider +Write-Host $citationBody +Divider + +if ($Output) { + if (-not $DryRun) { $citationBody | Out-File -FilePath $Output -Encoding UTF8; Log-Ok "Written to: $Output" } +} + +Log-Info "Repo: $Owner/$Repo" +Log-Info "Version: $version | Released: $releaseDate | Contributors: $($authorNames.Count)" +if ($doi) { Log-Info "DOI: $doi" } +Log-Info "Format: $Format" +Log-Ok "Citation generation complete" diff --git a/workflows/academic/11-research-citation.sh b/workflows/academic/11-research-citation.sh new file mode 100644 index 0000000..8c2fd8b --- /dev/null +++ b/workflows/academic/11-research-citation.sh @@ -0,0 +1,273 @@ +#!/usr/bin/env bash +# ============================================================ +# GitLink 科研辅助 — 场景 6:一键生成论文引用格式 +# ============================================================ +# 从 GitLink 仓库提取元数据,生成 BibTeX / APA / MLA / +# GB/T 7714-2015 / CITATION.cff 格式的学术引用 +# ============================================================ + +set -euo pipefail +trap '' PIPE + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/../lib/common.sh" + +# ── Configuration ──────────────────────────────────────────────────── +OUTPUT_FILE="" +CITE_FORMAT="all" + +usage() { + cat <<'EOF' +Usage: 11-research-citation.sh --owner --repo [options] + +Options: + --owner Repository owner (required) + --repo Repository name (required) + --format Citation format: bibtex|apa|mla|gbt7714|cff|all (default: all) + --output Output file path (default: stdout) + --dry-run Preview mode (no file write) + +Examples: + 11-research-citation.sh --owner zzx-coder --repo gitlink-cli + 11-research-citation.sh --owner zzx-coder --repo gitlink-cli --format bibtex + 11-research-citation.sh --owner zzx-coder --repo gitlink-cli --output citation.bib +EOF + exit 0 +} + +# ── Parse Arguments ────────────────────────────────────────────────── +parse_common_args "$@" +while [[ $# -gt 0 ]]; do + case "$1" in + --format) CITE_FORMAT="$2"; shift 2 ;; + --output) OUTPUT_FILE="$2"; shift 2 ;; + *) shift ;; + esac +done + +# ── Main ───────────────────────────────────────────────────────────── +main() { + log_title "GitLink 科研辅助 — 论文引用格式生成" + + check_auth + require_owner_repo + local today + today=$(date_today) + + # Step 1: Fetch repo metadata + log_step "获取仓库元数据..." + local repo_json + repo_json=$(gl_check repo +info --owner "$OWNER" --repo "$REPO") + local repo_name repo_desc created_at updated_at + repo_name=$(json_get "$repo_json" '.data.name // .data.full_name // ""') + repo_desc=$(json_get "$repo_json" '.data.description // ""') + created_at=$(json_get "$repo_json" '.data.created_at // ""') + updated_at=$(json_get "$repo_json" '.data.updated_at // ""') + + # Step 2: Get latest release + log_step "获取最新版本..." + local release_json version release_date + release_json=$(gl_run release +list --owner "$OWNER" --repo "$REPO" --limit 1) + if [[ "$(json_ok "$release_json")" == "true" ]]; then + version=$(json_get "$release_json" '.data.releases[0].tag_name // .data.releases[0].name // ""') + release_date=$(json_get "$release_json" '.data.releases[0].created_at // ""') + fi + # Fallback to "dev" if no release + version="${version:-v0.0.0-dev}" + release_date="${release_date:-$updated_at}" + local release_year="${release_date:0:4}" + local release_year_only="${release_year:-$(date +%Y)}" + + # Step 3: Get members (authors) + log_step "获取贡献者列表..." + local members_json authors_login authors_name author_list_bibtex author_list_apa author_list_mla author_list_gb + members_json=$(gl_run repo +members --owner "$OWNER" --repo "$REPO" --limit 20) + if [[ "$(json_ok "$members_json")" == "true" ]]; then + # Extract logins from members + authors_login=$(json_get "$members_json" '(.data.members // .data | if type == "array" then [.[].login // .[].user.login // empty] else [] end | join(", "))') + authors_name=$(json_get "$members_json" '(.data.members // .data | if type == "array" then [.[].name // .[].full_name // .[].login // empty] else [] end | join(", "))') + fi + authors_login="${authors_login:-$OWNER}" + authors_name="${authors_name:-$OWNER}" + + # Format authors for each citation style + # Parse comma-separated names + IFS=', ' read -r -a name_array <<< "$authors_name" + local author_count=${#name_array[@]} + + # BibTeX: Last1, First1 and Last2, First2 + author_list_bibtex="" + local i=0 + for name in "${name_array[@]}"; do + [[ -z "$name" ]] && continue + i=$((i + 1)) + [[ $i -gt 5 ]] && { author_list_bibtex="${author_list_bibtex} and others"; break; } + [[ $i -gt 1 ]] && author_list_bibtex="${author_list_bibtex} and " + # Simple: first word = first name, rest = last name + local first="${name%% *}" last="${name##* }" + [[ "$first" == "$last" ]] && author_list_bibtex="${author_list_bibtex}${last}" || author_list_bibtex="${author_list_bibtex}${last}, ${first}" + done + + # APA: Last, F., & Last, F. + author_list_apa="" + i=0 + for name in "${name_array[@]}"; do + [[ -z "$name" ]] && continue + i=$((i + 1)) + [[ $i -gt 5 ]] && { author_list_apa="${author_list_apa}, et al."; break; } + if [[ $i -eq 1 ]]; then + : + elif [[ $i -eq "$author_count" ]] || [[ $i -eq 5 ]]; then + author_list_apa="${author_list_apa}, & " + else + author_list_apa="${author_list_apa}, " + fi + local first="${name%% *}" last="${name##* }" + [[ "$first" == "$last" ]] && author_list_apa="${author_list_apa}${last}" || author_list_apa="${author_list_apa}${last}, ${first:0:1}." + done + + # MLA: Last, First, et al. (2+ authors → et al.) + author_list_mla="" + local first_name="${name_array[0]%% *}" last_name="${name_array[0]##* }" + [[ "$first_name" == "$last_name" ]] && author_list_mla="${last_name}" || author_list_mla="${last_name}, ${first_name}" + if [[ $author_count -gt 1 ]]; then + author_list_mla="${author_list_mla}, et al." + fi + + # GB/T 7714: 作者1, 作者2 + author_list_gb="" + i=0 + for name in "${name_array[@]}"; do + [[ -z "$name" ]] && continue + i=$((i + 1)) + [[ $i -gt 3 ]] && { author_list_gb="${author_list_gb}等"; break; } + [[ $i -gt 1 ]] && author_list_gb="${author_list_gb}, " + author_list_gb="${author_list_gb}${name}" + done + + # Step 4: Get repo URL + local repo_url + repo_url=$(git remote get-url origin 2>/dev/null || echo "") + if [[ -z "$repo_url" ]]; then + repo_url="https://gitlink.org.cn/${OWNER}/${REPO}" + fi + # Normalize .git suffix + repo_url="${repo_url%.git}" + + # Step 5: Try to detect DOI + log_step "检测 DOI..." + local doi="" + if echo "$repo_desc" | grep -qoP '10\.\d{4,}/[\w.\-/]+'; then + doi=$(echo "$repo_desc" | grep -oE '10\.[0-9]{4,}/[a-zA-Z0-9._\-/]+' | head -1) + fi + + # Short name for BibTeX key + local short_name + short_name=$(echo "$REPO" | sed 's/[^a-zA-Z0-9_-]/_/g' | head -c 32) + + # ── Generate Citations ──────────────────────────────────────────── + local output="" + log_step "生成引用格式..." + + # BibTeX + if [[ "$CITE_FORMAT" == "bibtex" || "$CITE_FORMAT" == "all" ]]; then + output+="@software{${short_name}, + author = {${author_list_bibtex}}, + title = {${repo_name}}, + version = {${version}}, + date = {${release_date}}, + publisher = {GitLink}, + url = {${repo_url}}" + if [[ -n "$doi" ]]; then + output+=", + doi = {${doi}}" + fi + output+=", + note = {${repo_desc}} +} +" + fi + + # APA 7th + if [[ "$CITE_FORMAT" == "apa" || "$CITE_FORMAT" == "all" ]]; then + output+=" +${author_list_apa} (${release_year_only}). ${repo_name} (Version ${version}) [Computer software]. + GitLink. ${repo_url} +" + fi + + # MLA 9th + if [[ "$CITE_FORMAT" == "mla" || "$CITE_FORMAT" == "all" ]]; then + output+=" +${author_list_mla}. ${repo_name}. Version ${version}, GitLink, + ${release_date}, ${repo_url}. +" + fi + + # GB/T 7714-2015 + if [[ "$CITE_FORMAT" == "gbt7714" || "$CITE_FORMAT" == "all" ]]; then + output+=" +[1] ${author_list_gb}. ${repo_name}[CP/OL]. ${version}. GitLink, + ${release_date}[${today}]. ${repo_url}. +" + fi + + # CITATION.cff + if [[ "$CITE_FORMAT" == "cff" || "$CITE_FORMAT" == "all" ]]; then + output+=" +cff-version: 1.2.0 +message: \"If you use this software, please cite it as below.\" +authors: +" + for name in "${name_array[@]}"; do + [[ -z "$name" ]] && continue + local first="${name%% *}" last="${name##* }" + output+=" - family-names: ${last} + given-names: ${first} +" + done + output+="title: \"${repo_name}\" +version: ${version} +date-released: ${release_date:0:10} +url: \"${repo_url}\" +repository-code: \"${repo_url}.git\" +" + if [[ -n "$doi" ]]; then + output+="doi: ${doi} +" + fi + fi + + # ── Output ──────────────────────────────────────────────────────── + log_ok "引用格式生成完成" + + if [[ -n "$OUTPUT_FILE" ]]; then + if [[ "${DRY_RUN:-false}" != "true" ]]; then + echo "$output" > "$OUTPUT_FILE" + log_ok "已写入: $OUTPUT_FILE" + else + log_warn "[DRY RUN] Would write to: $OUTPUT_FILE" + fi + fi + + # Always print to console + divider + echo "$output" + divider + + # ── Summary ─────────────────────────────────────────────────────── + log_info "仓库: ${OWNER}/${REPO}" + log_info "版本: ${version}" + log_info "发布日期: ${release_date}" + log_info "贡献者数: ${author_count}" + [[ -n "$doi" ]] && log_info "DOI: ${doi}" + log_info "格式: ${CITE_FORMAT}" + + # Offer to create CITATION.cff + if [[ "${DRY_RUN:-false}" != "true" ]]; then + echo "" + log_info "提示: 可在仓库中创建 CITATION.cff 文件以便他人引用。" + fi +} + +main "$@" diff --git a/workflows/deploy.sh b/workflows/deploy.sh new file mode 100644 index 0000000..1f09786 --- /dev/null +++ b/workflows/deploy.sh @@ -0,0 +1,245 @@ +#!/usr/bin/env bash +# ================================================================ +# GitLink Community Ops — 一键部署到 Linux 服务器 +# +# 用法: +# 在本地执行 (scp 上传 + 远程安装): +# bash deploy.sh --host 1.2.3.4 --port 8080 \ +# --secret "my-secret" --owner mengcheng --repo gitlink_help_center +# +# 或在服务器本地执行 (已经上传完文件后): +# sudo bash deploy.sh --local --port 8080 --secret "my-secret" \ +# --owner mengcheng --repo gitlink_help_center --webhook-url "https://1.2.3.4:8080/webhook" +# ================================================================ + +set -euo pipefail + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; NC='\033[0m' +log() { echo -e "${CYAN}[INFO]${NC} $*"; } +ok() { echo -e "${GREEN}[ OK]${NC} $*"; } +err() { echo -e "${RED}[ ERR]${NC} $*"; exit 1; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } + +# ── 参数 ──────────────────────────────────────────────────────── +HOST="" +PORT="8080" +SECRET="" +OWNER="" +REPO="" +WEBHOOK_URL="" +LOCAL=false +INSTALL_DIR="/opt/gitlink-webhook" +SYSTEMD_USER="gitlink" + +usage() { + echo "Usage: $0 --host IP --port PORT --secret SECRET --owner OWNER --repo REPO" + echo " 或 $0 --local --port PORT --secret SECRET --owner OWNER --repo REPO --webhook-url URL" + echo "" + echo " 远程部署 (本地执行):" + echo " --host IP 服务器公网 IP" + echo " --port PORT 监听端口 (默认 8080)" + echo " --secret SECRET HMAC 密钥" + echo " --owner OWNER GitLink 仓库所有者" + echo " --repo REPO GitLink 仓库名" + echo "" + echo " 本地安装 (服务器上执行):" + echo " --local 在当前机器安装" + echo " --webhook-url URL 完整 webhook 回调 URL" + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --host) HOST="$2"; shift 2 ;; + --port) PORT="$2"; shift 2 ;; + --secret) SECRET="$2"; shift 2 ;; + --owner) OWNER="$2"; shift 2 ;; + --repo) REPO="$2"; shift 2 ;; + --webhook-url) WEBHOOK_URL="$2"; shift 2 ;; + --local) LOCAL=true; shift ;; + --help|-h) usage ;; + *) err "Unknown arg: $1" ;; + esac +done + +# ── 校验 ──────────────────────────────────────────────────────── +if [[ "$LOCAL" == "true" ]]; then + [[ -z "$WEBHOOK_URL" ]] && err "--webhook-url is required in --local mode" + [[ -z "$SECRET" ]] && err "--secret is required" +else + [[ -z "$HOST" ]] && err "--host is required for remote deployment" + [[ -z "$SECRET" ]] && err "--secret is required" + WEBHOOK_URL="https://${HOST}:${PORT}/webhook" +fi + +WORKFLOW_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REQUIRED_FILES=( + "01a-webhook-listener.py" + "01a-issue-triage.sh" + "01a-webhook-setup.sh" + "01-community-ops.sh" + "lib/common.sh" + "gitlink-webhook.service" +) + +# ── 远程部署 ───────────────────────────────────────────────────── +if [[ "$LOCAL" != "true" ]]; then + log "Deploying to $HOST ..." + + # 检查文件 + for f in "${REQUIRED_FILES[@]}"; do + [[ -f "$WORKFLOW_DIR/$f" ]] || err "Missing: $WORKFLOW_DIR/$f" + done + + log "Uploading files to $HOST:$INSTALL_DIR ..." + ssh "root@$HOST" "mkdir -p $INSTALL_DIR/webhook-logs $INSTALL_DIR/workflows/lib" || err "SSH connection failed" + + scp "$WORKFLOW_DIR/01a-webhook-listener.py" "root@$HOST:$INSTALL_DIR/" + scp "$WORKFLOW_DIR/01a-issue-triage.sh" "root@$HOST:$INSTALL_DIR/workflows/" + scp "$WORKFLOW_DIR/01-community-ops.sh" "root@$HOST:$INSTALL_DIR/workflows/" + scp "$WORKFLOW_DIR/01a-webhook-setup.sh" "root@$HOST:$INSTALL_DIR/workflows/" + scp "$WORKFLOW_DIR/lib/common.sh" "root@$HOST:$INSTALL_DIR/workflows/lib/" + scp "$WORKFLOW_DIR/gitlink-webhook.service" "root@$HOST:$INSTALL_DIR/" + ok "Files uploaded" + + log "Running remote installation..." + ssh "root@$HOST" "bash -s" << REMOTE_SCRIPT +set -e + +INSTALL_DIR="$INSTALL_DIR" +PORT="$PORT" +SECRET="$SECRET" +OWNER="$OWNER" +REPO="$REPO" +WEBHOOK_URL="$WEBHOOK_URL" +SYSTEMD_USER="$SYSTEMD_USER" + +echo '=== Installing GitLink Webhook ===' + +# 1. 创建用户 +if ! id -u \$SYSTEMD_USER &>/dev/null; then + useradd -r -s /usr/sbin/nologin -d \$INSTALL_DIR \$SYSTEMD_USER + echo "[OK] User \$SYSTEMD_USER created" +else + echo "[OK] User \$SYSTEMD_USER exists" +fi + +# 2. 设置权限 +chown -R \$SYSTEMD_USER:\$SYSTEMD_USER \$INSTALL_DIR +chmod +x \$INSTALL_DIR/01a-webhook-listener.py +chmod +x \$INSTALL_DIR/workflows/*.sh +echo "[OK] Permissions set" + +# 3. 创建 .env +cat > \$INSTALL_DIR/.env << EOF +WEBHOOK_PORT=$PORT +WEBHOOK_SECRET=$SECRET +EOF +chmod 600 \$INSTALL_DIR/.env +chown \$SYSTEMD_USER:\$SYSTEMD_USER \$INSTALL_DIR/.env +echo "[OK] .env created" + +# 4. 开放防火墙 +if command -v ufw &>/dev/null && ufw status | grep -q "Status: active"; then + ufw allow \$PORT/tcp 2>/dev/null || true + echo "[OK] Firewall: port \$PORT opened" +elif command -v firewall-cmd &>/dev/null; then + firewall-cmd --permanent --add-port=\$PORT/tcp 2>/dev/null || true + firewall-cmd --reload 2>/dev/null || true + echo "[OK] Firewall: port \$PORT opened" +else + echo "[WARN] No firewall detected — ensure port \$PORT is open in security group" +fi + +# 5. 安装 systemd 服务 +cp \$INSTALL_DIR/gitlink-webhook.service /etc/systemd/system/ +systemctl daemon-reload +systemctl enable gitlink-webhook +systemctl restart gitlink-webhook +echo "[OK] Systemd service installed and started" + +# 6. 等待启动 +sleep 2 +systemctl status gitlink-webhook --no-pager | head -5 + +echo '' +echo '=== Installation complete ===' +echo "Health check: http://$HOST:$PORT/" +echo "Webhook URL: $WEBHOOK_URL" +REMOTE_SCRIPT + + ok "Remote installation complete" + + # 7. 注册 webhook + echo "" + log "Registering webhook on GitLink..." + ssh "root@$HOST" "cd \$INSTALL_DIR/workflows && bash 01a-webhook-setup.sh --webhook-url '$WEBHOOK_URL' --owner '$OWNER' --repo '$REPO' --secret '$SECRET'" || { + warn "Webhook registration failed — you can run manually:" + echo " ssh root@$HOST" + echo " cd $INSTALL_DIR/workflows" + echo " bash 01a-webhook-setup.sh --webhook-url '$WEBHOOK_URL' --owner '$OWNER' --repo '$REPO' --secret '$SECRET'" + } + + echo "" + echo -e "${GREEN}╔══════════════════════════════════════════════════════════╗${NC}" + echo -e "${GREEN}║ Deployment Complete! ║${NC}" + echo -e "${GREEN}║ ║${NC}" + echo -e "${GREEN}║ Health: http://$HOST:$PORT/ ║${NC}" + echo -e "${GREEN}║ Webhook: $WEBHOOK_URL ║${NC}" + echo -e "${GREEN}║ Logs: ssh root@$HOST journalctl -u gitlink-webhook -f ║${NC}" + echo -e "${GREEN}╚══════════════════════════════════════════════════════════╝${NC}" + +# ── 本地安装(在服务器上执行)───────────────────────────────────── +else + [[ "$EUID" -ne 0 ]] && err "Please run as root (sudo)" + + log "Installing locally to $INSTALL_DIR ..." + + # 创建用户 + if ! id -u "$SYSTEMD_USER" &>/dev/null; then + useradd -r -s /usr/sbin/nologin -d "$INSTALL_DIR" "$SYSTEMD_USER" + ok "User $SYSTEMD_USER created" + fi + + # 设置权限 + chown -R "$SYSTEMD_USER:$SYSTEMD_USER" "$INSTALL_DIR" + chmod +x "$INSTALL_DIR/01a-webhook-listener.py" + chmod +x "$INSTALL_DIR/workflows/"*.sh 2>/dev/null || true + ok "Permissions set" + + # .env + cat > "$INSTALL_DIR/.env" << EOF +WEBHOOK_PORT=$PORT +WEBHOOK_SECRET=$SECRET +EOF + chmod 600 "$INSTALL_DIR/.env" + chown "$SYSTEMD_USER:$SYSTEMD_USER" "$INSTALL_DIR/.env" + ok ".env created" + + # 防火墙 + if command -v ufw &>/dev/null && ufw status 2>/dev/null | grep -q "Status: active"; then + ufw allow "$PORT/tcp" 2>/dev/null || true + ok "UFW: port $PORT opened" + fi + + # systemd + cp "$INSTALL_DIR/gitlink-webhook.service" /etc/systemd/system/ + systemctl daemon-reload + systemctl enable gitlink-webhook + systemctl restart gitlink-webhook + ok "Systemd service installed" + + sleep 2 + systemctl status gitlink-webhook --no-pager | head -8 + + echo "" + echo -e "${GREEN}Local installation complete!${NC}" + echo " Health check: curl http://localhost:$PORT/" + echo " Status: systemctl status gitlink-webhook" + echo " Logs: journalctl -u gitlink-webhook -f" + echo "" + echo " Next: register webhook on GitLink:" + echo " bash $INSTALL_DIR/workflows/01a-webhook-setup.sh \\" + echo " --webhook-url '$WEBHOOK_URL' \\" + echo " --owner '$OWNER' --repo '$REPO' --secret '$SECRET'" +fi diff --git a/workflows/gitlink-webhook.service b/workflows/gitlink-webhook.service new file mode 100644 index 0000000..c63fd72 --- /dev/null +++ b/workflows/gitlink-webhook.service @@ -0,0 +1,41 @@ +# ================================================================ +# GitLink Community Ops — Webhook Listener Systemd Service +# +# 安装: +# sudo cp gitlink-webhook.service /etc/systemd/system/ +# sudo systemctl daemon-reload +# sudo systemctl enable --now gitlink-webhook +# +# 管理: +# sudo systemctl status gitlink-webhook # 查看状态 +# sudo systemctl restart gitlink-webhook # 重启 +# sudo journalctl -u gitlink-webhook -f # 查看日志 +# ================================================================ +[Unit] +Description=GitLink Community Ops Webhook Listener +Documentation=https://github.com/your-org/gitlink-cli +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=gitlink +Group=gitlink +WorkingDirectory=/opt/gitlink-webhook +EnvironmentFile=/opt/gitlink-webhook/.env +ExecStart=/usr/bin/python3 /opt/gitlink-webhook/01a-webhook-listener.py +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal + +# 安全加固 +NoNewPrivileges=yes +PrivateTmp=yes +ProtectSystem=strict +ProtectHome=yes +ReadWritePaths=/opt/gitlink-webhook/webhook-logs +ReadOnlyPaths=/opt/gitlink-webhook/workflows + +[Install] +WantedBy=multi-user.target diff --git a/workflows/lib/common.psm1 b/workflows/lib/common.psm1 new file mode 100644 index 0000000..3d7a719 --- /dev/null +++ b/workflows/lib/common.psm1 @@ -0,0 +1,153 @@ +# Common utilities for gitlink-cli workflow scripts (PowerShell 5.1+) + +# Force UTF-8 when capturing stdout from native commands. +# On Windows Chinese locales, PS 5.1 defaults to GBK and corrupts multi-byte +# JSON (e.g. 紧急/新增), which makes ConvertFrom-Json fail silently. +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 + +# CRITICAL: gitlink-cli outputs UTF-8 JSON, but PS 5.1 on Chinese Windows +# defaults to GBK (codepage 936) for decoding external program output. +# Without this, multi-byte UTF-8 chars get garbled and JSON parsing fails. +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 + +# Detect the gitlink-cli executable. +# Windows: prefer the repo-local build at ..\..\gitlink-cli.exe (two dirs up +# from lib/); bare 'gitlink-cli' is not on PATH in dev checkouts. +# Unix / global npm install: fall back to bare 'gitlink-cli' on PATH. +if ($env:OS -eq "Windows_NT") { + $localExe = Join-Path $PSScriptRoot "..\..\gitlink-cli.exe" + $Script:GL = if (Test-Path $localExe) { $localExe } else { "gitlink-cli" } +} else { + $Script:GL = "gitlink-cli" +} + +# -- Logging -- +function Log-Step { param([string]$Msg) Write-Host "[STEP] $Msg" -ForegroundColor Blue } +function Log-Ok { param([string]$Msg) Write-Host "[ OK] $Msg" -ForegroundColor Green } +function Log-Warn { param([string]$Msg) Write-Host "[WARN] $Msg" -ForegroundColor Yellow } +function Log-Err { param([string]$Msg) Write-Host "[ ERR] $Msg" -ForegroundColor Red } +function Log-Info { param([string]$Msg) Write-Host "[INFO] $Msg" -ForegroundColor Cyan } +function Log-Title { param([string]$Msg) Write-Host ""; Write-Host "====== $Msg ======" -ForegroundColor White; Write-Host "" } +function Divider { Write-Host "------------------------------------------------" -ForegroundColor Cyan } + +# -- Auth Check -- +function Check-Auth { + if ($env:GITLINK_TOKEN) { + Log-Ok "GITLINK_TOKEN is set" + return + } + $status = & $Script:GL auth status 2>&1 + $statusStr = $status -join " " + if ($statusStr -match "logged in") { + Log-Ok "Authenticated" + return + } + Log-Err "Not authenticated. Please login first:" + Log-Info " gitlink-cli auth login" + Log-Info ' $env:GITLINK_TOKEN = "your-private-token"' + exit 1 +} + +# -- CLI Wrapper -- +# Returns raw JSON string on success, "" on failure. +# Callers do their own ConvertFrom-Json so they control error handling. +function Invoke-GL { + param([string[]]$Arguments) + # Suppress stderr to keep JSON output clean (errors go to console via error stream) + $output = & $Script:GL @Arguments --format json 2>$null + if ($output) { return ($output -join "`n") } + return "" +} + +# Like Invoke-GL but logs error on failure and returns parsed JSON object ($null on failure). +function Invoke-GLCheck { + param([string[]]$Arguments) + $output = Invoke-GL $Arguments + if (-not $output) { + Log-Err "Command returned no output: $Script:GL $($Arguments -join ' ')" + return $null + } + try { + $json = $output | ConvertFrom-Json + if (-not $json.ok) { + $errMsg = if ($json.error.message) { $json.error.message } else { "unknown error" } + Log-Err "Command failed: $Script:GL $($Arguments -join ' ')" + Log-Err $errMsg + return $null + } + return $json + } catch { + Log-Err "Command failed (non-JSON response): $Script:GL $($Arguments -join ' ')" + Log-Err $output + return $null + } +} + +# -- JSON Helpers -- +function Get-JsonOk { + param($Json) + return ($Json.ok -eq $true) +} + +# -- CI / 流水线 -- +# Fetch a repo's CI/流水线 pipelines from GitLink. +# NOTE: the `ci +builds` shortcut targets GET /{owner}/{repo}/builds, which the +# live server rejects with {"status":-1,"message":"接口数据异常"} (the endpoint +# does not exist on the current platform). The real CI endpoint is +# GET /v1/{owner}/{repo}/pipelines. We call it directly via the `api` subcommand. +# Returns the pipelines array, or @() on any error / no data. Never logs an +# error, because "CI not configured" or "no access" is a normal, non-fatal case. +function Get-CIPipelines { + param([string]$Owner, [string]$Repo) + $raw = Invoke-GL @("api", "GET", "/v1/$Owner/$Repo/pipelines") + if (-not $raw) { return @() } + try { + $obj = $raw | ConvertFrom-Json + if ($obj.ok -and $obj.data.pipelines) { return @($obj.data.pipelines) } + } catch {} + return @() +} + +# Test whether a CI pipeline/build record represents a successful run. +# Checks the common status field names GitLink may use; tolerant because the +# pipeline object schema is not always observable. +function Test-CISuccess { + param($Item) + $s = if ($Item.status) { "$($Item.status)" } + elseif ($Item.event) { "$($Item.event)" } + elseif ($Item.state) { "$($Item.state)" } + elseif ($Item.build_status) { "$($Item.build_status)" } + else { "" } + return ($s -eq "success" -or $s -eq "completed" -or $s -eq "passed" -or $s -eq "succeeded") +} + +# -- Owner/Repo Detection -- +function Detect-OwnerRepo { + $remote = git remote get-url origin 2>$null + if (-not $remote) { + Log-Err "No git remote 'origin' found. Use -Owner and -Repo flags." + exit 1 + } + if ($remote -match "gitlink\.org\.cn[:/]([^/]+)/([^/.]+)") { + return @{ Owner = $Matches[1]; Repo = $Matches[2] } + } + Log-Err "Cannot parse owner/repo from remote: $remote" + exit 1 +} + +function Resolve-OwnerRepo { + param([string]$Owner, [string]$Repo) + if (-not $Owner -or -not $Repo) { + $detected = Detect-OwnerRepo + if (-not $Owner) { $Owner = $detected.Owner } + if (-not $Repo) { $Repo = $detected.Repo } + } + Log-Info "Using: $Owner/$Repo" + return @{ Owner = $Owner; Repo = $Repo } +} + +# -- Date Helpers -- +function Get-DateToday { return (Get-Date -Format "yyyy-MM-dd") } + +Export-ModuleMember -Function * diff --git a/workflows/lib/common.sh b/workflows/lib/common.sh new file mode 100644 index 0000000..e593fa3 --- /dev/null +++ b/workflows/lib/common.sh @@ -0,0 +1,305 @@ +#!/usr/bin/env bash +# Common utilities for gitlink-cli workflow scripts + +set -euo pipefail +# Trap SIGPIPE to prevent premature exit when piping through head/truncate +trap '' PIPE + +# Ensure jq is available (WinGet installs to non-default PATH on Windows) +if ! command -v jq &>/dev/null; then + for d in "$LOCALAPPDATA/Microsoft/WinGet/Links" "$HOME/AppData/Local/Microsoft/WinGet/Links"; do + [[ -d "$d" ]] && export PATH="$d:$PATH" + done +fi + +# Ensure CLAUDE_CODE_GIT_BASH_PATH is set for Windows (needed by claude CLI) +if [[ -z "${CLAUDE_CODE_GIT_BASH_PATH:-}" ]] && command -v cygpath &>/dev/null; then + export CLAUDE_CODE_GIT_BASH_PATH="$(cygpath -w "$(which bash)")" +fi + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +BOLD='\033[1m' +NC='\033[0m' + +# ── Logging ────────────────────────────────────────────────────────── +log_step() { echo -e "${BLUE}[STEP]${NC} $*"; } +log_ok() { echo -e "${GREEN}[ OK]${NC} $*"; } +log_warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +log_err() { echo -e "${RED}[ ERR]${NC} $*" >&2; } +log_info() { echo -e "${CYAN}[INFO]${NC} $*"; } +log_title(){ echo -e "\n${BOLD}══════ $* ══════${NC}\n"; } + +# ── Auth Check ─────────────────────────────────────────────────────── +check_auth() { + # Check env var first, then try CLI auth status + if [[ -n "${GITLINK_TOKEN:-}" ]]; then + log_ok "GITLINK_TOKEN is set" + return 0 + fi + local status + status=$(gitlink-cli auth status 2>&1) + if echo "$status" | grep -qi "logged in\|✓"; then + log_ok "Authenticated: $(echo "$status" | sed -n 's/.*as //p' | tr -d '[:space:]')" + return 0 + fi + log_err "Not authenticated. Please login first:" + log_info " gitlink-cli auth login" + log_info " export GITLINK_TOKEN=\"your-private-token\"" + exit 1 +} + +# ── JSON Helpers ───────────────────────────────────────────────────── +# Extract a field from CLI JSON output (Envelope: {ok, data, ...}) +json_ok() { + echo "$1" | jq -r '.ok // false' 2>/dev/null +} + +json_data() { + echo "$1" | jq -r '.data' 2>/dev/null +} + +json_get() { + echo "$1" | jq -r "$2" 2>/dev/null +} + +json_error() { + echo "$1" | jq -r '.error.message // "unknown error"' 2>/dev/null +} + +# ── CLI Wrapper ────────────────────────────────────────────────────── +GL="gitlink-cli" + +gl_run() { + local output + # Always use JSON format for scripting + output=$("$GL" "$@" --format json 2>&1) || true + echo "$output" +} + +gl_check() { + local output + output=$(gl_run "$@") + # Check if output is valid JSON (use here-string to avoid SIGPIPE) + if ! jq empty <<< "$output" 2>/dev/null; then + log_err "Command failed (non-JSON response): $GL $*" + log_err "$output" + return 1 + fi + if [[ "$(json_ok "$output")" != "true" ]]; then + log_err "Command failed: $GL $*" + log_err "$(json_error "$output")" + return 1 + fi + echo "$output" +} + +# ── Owner/Repo Detection ──────────────────────────────────────────── +detect_owner_repo() { + local remote_url + remote_url=$(git remote get-url origin 2>/dev/null || echo "") + if [[ -z "$remote_url" ]]; then + log_err "No git remote 'origin' found. Use --owner and --repo flags." + exit 1 + fi + # Parse gitlink URL patterns + # https://gitlink.org.cn/owner/repo.git or git@gitlink.org.cn:owner/repo.git + if [[ "$remote_url" =~ gitlink\.org\.cn[:/]([^/]+)/([^/.]+) ]]; then + DETECTED_OWNER="${BASH_REMATCH[1]}" + DETECTED_REPO="${BASH_REMATCH[2]}" + else + log_err "Cannot parse owner/repo from remote: $remote_url" + exit 1 + fi +} + +require_owner_repo() { + if [[ -z "${OWNER:-}" || -z "${REPO:-}" ]]; then + detect_owner_repo + OWNER="${OWNER:-$DETECTED_OWNER}" + REPO="${REPO:-$DETECTED_REPO}" + fi + log_info "Using: ${OWNER}/${REPO}" +} + +# ── Confirmation ───────────────────────────────────────────────────── +confirm() { + local msg="${1:-Proceed?}" + if [[ "${DRY_RUN:-false}" == "true" ]]; then + log_warn "[DRY RUN] Would execute: $msg" + return 1 + fi + read -rp "$(echo -e "${YELLOW}$msg [y/N]${NC} ")" answer + [[ "$answer" =~ ^[Yy] ]] +} + +# ── Date Helpers ───────────────────────────────────────────────────── +date_today() { + date +%Y-%m-%d +} + +date_week_ago() { + date -d "7 days ago" +%Y-%m-%d 2>/dev/null || date -v-7d +%Y-%m-%d 2>/dev/null +} + +date_month_ago() { + date -d "30 days ago" +%Y-%m-%d 2>/dev/null || date -v-30d +%Y-%m-%d 2>/dev/null +} + +# ── Parameter Parsing ──────────────────────────────────────────────── +parse_common_args() { + while [[ $# -gt 0 ]]; do + case "$1" in + --owner) OWNER="$2"; shift 2 ;; + --repo) REPO="$2"; shift 2 ;; + --dry-run) DRY_RUN="true"; shift ;; + --help|-h) usage; exit 0 ;; + *) break ;; + esac + done +} + +# ── Section Divider ────────────────────────────────────────────────── +divider() { + echo -e "${CYAN}────────────────────────────────────────────────${NC}" +} + +# ══════════════════════════════════════════════════════════════════════ +# Scientific Research Helpers +# ══════════════════════════════════════════════════════════════════════ + +# Normalize value against max (returns 0-1, 0 if max is 0) +normalize() { + awk -v val="$1" -v max="$2" 'BEGIN { printf "%.4f", (max > 0) ? val / max : 0 }' +} + +# Clamp value between lo and hi +clamp() { + awk -v val="$1" -v lo="$2" -v hi="$3" 'BEGIN { printf "%.4f", (val < lo) ? lo : ((val > hi) ? hi : val) }' +} + +# Weighted sum: pass pairs of "value weight" as arguments +weighted_sum() { + awk 'BEGIN { sum=0; for(i=1;i 0) ? inter / union : 0; + }' +} + +# Detect programming language from file extension +detect_lang_from_ext() { + local ext="${1##*.}" + case "$ext" in + go) echo "Go" ;; + py|pyx) echo "Python" ;; + js|ts|jsx|tsx|mjs|cjs) echo "JavaScript/TypeScript" ;; + rs) echo "Rust" ;; + java) echo "Java" ;; + kt|kts) echo "Kotlin" ;; + c|cpp|cxx|h|hpp|hxx) echo "C/C++" ;; + r|R) echo "R" ;; + jl) echo "Julia" ;; + m|mm) echo "MATLAB/Objective-C" ;; + swift) echo "Swift" ;; + rb) echo "Ruby" ;; + php) echo "PHP" ;; + scala) echo "Scala" ;; + dart) echo "Dart" ;; + lua) echo "Lua" ;; + ipynb) echo "Jupyter Notebook" ;; + sh|bash|zsh) echo "Shell" ;; + ps1|psm1|psd1) echo "PowerShell" ;; + *) echo "Other" ;; + esac +} + +# Cross-platform days between two dates (YYYY-MM-DD format) +days_between() { + local d1 d2 diff + d1=$(date -d "$1" +%s 2>/dev/null || date -jf "%Y-%m-%d" "$1" +%s 2>/dev/null || echo "0") + d2=$(date -d "$2" +%s 2>/dev/null || date -jf "%Y-%m-%d" "$2" +%s 2>/dev/null || echo "0") + diff=$(( (d2 - d1) / 86400 )) + echo "${diff#-}" +} + +# Get today, N days ago (cross-platform) +date_days_ago() { + local n="$1" + date -d "$n days ago" +%Y-%m-%d 2>/dev/null || date -v-"$n"d +%Y-%m-%d 2>/dev/null +} + +# Extract first N space-separated authors into BibTeX format +# Input: "First Last" "First2 Last2" ... +format_authors_bibtex() { + local names="$1" count=0 result="" + for name in $names; do + count=$((count + 1)) + if [[ $count -gt 10 ]]; then + result="${result} and others" + break + fi + local last="${name##* }" first="${name%% *}" + [[ $count -gt 1 ]] && result="${result} and " + result="${result}${last}, ${first}" + done + echo "$result" +} + +# Extract organization name from login (try git remote or repo +info) +org_from_owner() { + local owner="$1" + # check if it's an org or user by listing repos + local out + out=$(gl_run repo +list --user "$owner" --limit 1) + if [[ "$(json_ok "$out")" == "true" ]]; then + echo "$owner" + else + echo "" + fi +} + +# Min and max helpers for awk +min_val() { awk -v a="$1" -v b="$2" 'BEGIN { print (a < b) ? a : b }'; } +max_val() { awk -v a="$1" -v b="$2" 'BEGIN { print (a > b) ? a : b }'; } + +# ── Knowledge Graph Helpers ────────────────────────────────────────── + +# Generate a unique node ID +kg_node_id() { echo "${1}:${2}" | tr '/' '_' | tr ' ' '_'; } + +# URL-encode a string (basic) +url_encode() { + local str="$1" + echo "$str" | jq -sRr @uri 2>/dev/null || echo "$str" +} + +# Escape JSON string value +json_escape() { + echo "$1" | jq -Rsa . 2>/dev/null || echo "\"$1\"" +} + +# ── Color-coded Severity ────────────────────────────────────────────── + +severity_color() { + case "$1" in + Critical|critical|CRITICAL) echo -e "${RED}$1${NC}" ;; + Warning|warning|WARNING) echo -e "${YELLOW}$1${NC}" ;; + Info|info|INFO) echo -e "${CYAN}$1${NC}" ;; + OK|ok|CLEAN) echo -e "${GREEN}$1${NC}" ;; + *) echo "$1" ;; + esac +} diff --git a/workflows/lib/research-common.psm1 b/workflows/lib/research-common.psm1 new file mode 100644 index 0000000..7e9a89a --- /dev/null +++ b/workflows/lib/research-common.psm1 @@ -0,0 +1,69 @@ +# GitLink 科研辅助 — PowerShell 公共模块扩展 +# 在 common.psm1 基础上增加科研计算函数 + +# -- Math helpers -- +function Get-Normalized { + param([double]$Value, [double]$Max) + if ($Max -le 0) { return 0 } + return [Math]::Round($Value / $Max, 4) +} +function Get-Clamped { + param([double]$Value, [double]$Lo, [double]$Hi) + return [Math]::Max($Lo, [Math]::Min($Value, $Hi)) +} +function Get-WeightedSum { + param([double[]]$Pairs) + $sum = 0.0 + for ($i = 0; $i -lt $Pairs.Count; $i += 2) { + $sum += $Pairs[$i] * $Pairs[$i + 1] + } + return [Math]::Round($sum, 4) +} +function Get-Jaccard { + param([string[]]$ListA, [string[]]$ListB) + $setA = @{}; foreach ($a in $ListA) { $setA[$a.Trim()] = 1 } + $inter = 0; foreach ($b in $ListB) { if ($setA.ContainsKey($b.Trim())) { $inter++ } } + $union = ($setA.Keys + ($ListB | ForEach-Object { $_.Trim() }) | Select-Object -Unique).Count + if ($union -le 0) { return 0 } + return [Math]::Round($inter / $union, 4) +} +function Get-DaysBetween { + param([string]$Date1, [string]$Date2) + try { return ([DateTime]$Date2 - [DateTime]$Date1).Days } + catch { return 365 } +} +function Get-DateToday { return (Get-Date -Format "yyyy-MM-dd") } + +# -- Citation helpers -- +function Format-AuthorsBibtex { + param([string[]]$Authors) + $result = ""; $count = 0 + foreach ($a in $Authors) { + if (-not $a) { continue } + $count++ + if ($count -gt 5) { $result += " and others"; break } + if ($count -gt 1) { $result += " and " } + $parts = $a -split '\s+' + $last = $parts[-1]; $first = $parts[0] + $result += "$last, $first" + } + return $result +} +function Format-AuthorsAPA { + param([string[]]$Authors) + $result = ""; $count = 0; $total = ($Authors | Where-Object { $_ }).Count + foreach ($a in $Authors) { + if (-not $a) { continue } + $count++ + if ($count -gt 5) { $result += ", et al."; break } + if ($count -eq 1) { } + elseif ($count -eq $total -or $count -eq 5) { $result += ", & " } + else { $result += ", " } + $parts = $a -split '\s+' + $last = $parts[-1]; $first = $parts[0][0] + $result += "$last, $first." + } + return $result +} + +Export-ModuleMember -Function Get-Normalized, Get-Clamped, Get-WeightedSum, Get-Jaccard, Get-DaysBetween, Get-DateToday, Format-AuthorsBibtex, Format-AuthorsAPA diff --git a/workflows/schemas/knowledge-graph.schema.json b/workflows/schemas/knowledge-graph.schema.json new file mode 100644 index 0000000..3dadb2f --- /dev/null +++ b/workflows/schemas/knowledge-graph.schema.json @@ -0,0 +1,101 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "$id": "https://gitlink.org.cn/schemas/research-knowledge-graph.json", + "title": "Research Knowledge Graph", + "description": "Schema for the research knowledge graph generated from GitLink repository analysis", + "type": "object", + "required": ["metadata", "nodes", "edges"], + "properties": { + "metadata": { + "type": "object", + "required": ["generated_at", "search_keywords"], + "properties": { + "generated_at": { "type": "string", "format": "date-time" }, + "search_keywords": { + "type": "array", + "items": { "type": "string" }, + "minItems": 1 + }, + "total_repos_scanned": { "type": "integer", "minimum": 0 }, + "total_contributors_found": { "type": "integer", "minimum": 0 }, + "total_edges_inferred": { "type": "integer", "minimum": 0 }, + "api_version": { "type": "string" } + } + }, + "nodes": { + "type": "array", + "items": { + "type": "object", + "required": ["id", "type", "label"], + "properties": { + "id": { + "type": "string", + "description": "Unique node identifier, e.g. 'repo:owner/repo-name' or 'topic:NLP'", + "pattern": "^[a-z_]+:.+$" + }, + "type": { + "type": "string", + "description": "Node type", + "enum": ["repo", "contributor", "topic", "paper", "organization", "release"] + }, + "label": { + "type": "string", + "description": "Human-readable display name" + }, + "description": { + "type": "string", + "description": "Optional description for tooltip" + }, + "properties": { + "type": "object", + "description": "Type-specific attributes" + } + } + } + }, + "edges": { + "type": "array", + "items": { + "type": "object", + "required": ["source", "target", "type"], + "properties": { + "source": { + "type": "string", + "description": "Source node ID" + }, + "target": { + "type": "string", + "description": "Target node ID" + }, + "type": { + "type": "string", + "description": "Relationship type", + "enum": [ + "depends_on", + "cites", + "contributes_to", + "forks_from", + "has_topic", + "collaborates_with", + "releases", + "references_paper", + "implements_method", + "related_to" + ] + }, + "weight": { + "type": "number", + "minimum": 0, + "maximum": 1, + "default": 0.5, + "description": "Edge weight / confidence" + }, + "evidence": { + "type": "string", + "description": "How this edge was inferred" + } + } + } + } + } +} diff --git a/workflows/templates/knowledge-graph.html b/workflows/templates/knowledge-graph.html new file mode 100644 index 0000000..63f69b4 --- /dev/null +++ b/workflows/templates/knowledge-graph.html @@ -0,0 +1,110 @@ + + + + + +科研知识图谱 — {{REPORT_DATE}} + + + + +
    +

    科研知识图谱

    +
    + 关键词:{{KEYWORDS}} — + 仓库:{{TOTAL_REPOS}} 个 — + 贡献者:{{TOTAL_CONTRIBUTORS}} 人 — + {{REPORT_DATE}} +
    +
    +
    + +
    +
    {{TOTAL_REPOS}}
    仓库节点
    +
    {{TOTAL_CONTRIBUTORS}}
    贡献者节点
    +
    {{TOTAL_TOPICS}}
    主题节点
    +
    {{TOTAL_EDGES}}
    关系边
    +
    {{HOTTEST_REPO}}
    最热仓库
    +
    + +
    +

    知识图谱 — 力导向布局

    +
    +
    + +
    +

    热度排行榜

    + + + {{TABLE_ROWS}} +
    排名仓库热度语言Stars趋势
    +
    + +
    + + + + + diff --git a/workflows/test.py b/workflows/test.py new file mode 100644 index 0000000..48d4162 --- /dev/null +++ b/workflows/test.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python +"""gitlink-cli Integration Test Suite — validates CLI commands against real GitLink API.""" +import subprocess, json, sys, os + +OWNER = sys.argv[1] if len(sys.argv) > 1 else "zzx-coder" +REPO = sys.argv[2] if len(sys.argv) > 2 else "gitlink-cli" +GL = "gitlink-cli" + +PASS = FAIL = SKIP = 0 + +# ── Helpers ────────────────────────────────────────────────────────── +ENC = {'encoding': 'utf-8', 'errors': 'replace'} +C = {"G":"\033[0;32m","R":"\033[0;31m","Y":"\033[1;33m","B":"\033[1m","N":"\033[0m"} + +def p(msg): print(f" {C['G']}[PASS]{C['N']} {msg}") +def f(msg): print(f" {C['R']}[FAIL]{C['N']} {msg}") +def s(msg): print(f" {C['Y']}[SKIP]{C['N']} {msg}") +def section(t): print(f"\n{C['B']}══════ {t} ══════{C['N']}\n") + +def run(*args, timeout=10): + """Run a command, return CompletedProcess.""" + return subprocess.run(list(args), capture_output=True, text=True, timeout=timeout, **ENC) + +def gl(*args): + """Run gitlink-cli with --format json, return (parsed_json, raw_string).""" + cmd = [GL] + list(args) + ["--format", "json", "--owner", OWNER, "--repo", REPO] + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=30, **ENC) + out = r.stdout.strip() or r.stderr.strip() + return json.loads(out), out + except (subprocess.TimeoutExpired, json.JSONDecodeError): + return None, "" + +def assert_ok(desc, data): + global PASS, FAIL + if data and data.get("ok") is True: + p(f"{desc}"); PASS += 1 + else: + ok_val = data.get("ok") if data else None + err = data.get("error",{}).get("message","?") if data else "no JSON" + f(f"{desc} — ok={ok_val}, error={err}"); FAIL += 1 + +def assert_field(desc, data, path): + global PASS, FAIL + val = data + for key in path: + if isinstance(val, dict) and key in val: val = val[key] + elif isinstance(val, list) and isinstance(key, int) and key < len(val): val = val[key] + else: val = None; break + if val is not None: + display = str(val)[:60] + p(f"{desc} → {'.'.join(str(k) for k in path)} = {display}"); PASS += 1 + else: + f(f"{desc} → {'.'.join(str(k) for k in path)} is null/absent"); FAIL += 1 + +def assert_cmd(desc, *args, allow_missing=False): + global PASS, FAIL, SKIP + data, raw = gl(*args) + if data is not None: + if data.get("ok") is True: + p(f"{desc}"); PASS += 1 + elif allow_missing: + s(f"{desc} — item not found (may be merged/deleted)"); SKIP += 1 + else: + f(f"{desc} — ok={data.get('ok')}"); FAIL += 1 + else: + if allow_missing: + s(f"{desc} — item not found"); SKIP += 1 + else: + f(f"{desc} — non-JSON response"); FAIL += 1 + return data + +def bash_check(desc, filepath): + """Syntax-check a shell script (skipped on Windows due to perf).""" + global SKIP + s(f"syntax check — {desc} (skipped: bash -n slow on Windows)"); SKIP += 1 + +# ════════════════════════════════════════════════════════════════════ +print(f"{C['B']}══════ gitlink-cli Integration Test Suite ══════{C['N']}\n") +print(f" Target: {OWNER}/{REPO}") +try: + ver = run(GL, "version").stdout.strip() + print(f" CLI: {ver}") +except: print(" CLI: unknown") + +# ── 1. Authentication ─────────────────────────────────────────────── +section("1. Authentication") +try: + r = run(GL, "auth", "status") + if "logged in" in r.stdout.lower() or "✓" in r.stdout: + p("auth status — logged in"); PASS += 1 + else: + f("auth status — not logged in"); FAIL += 1 +except: + f("auth status — command failed"); FAIL += 1 + +# ── 2. JSON Envelope Format Validation ───────────────────────────── +section("2. JSON Envelope Format Validation") +data, _ = gl("issue", "+list", "--limit", "1") +assert_field("issue +list envelope has .ok", data, ["ok"]) +assert_field("issue +list envelope has .data", data, ["data"]) +# .error may be absent on success — that's expected +has_error = data and "error" in data +if has_error: + p("issue +list envelope has .error"); PASS += 1 +else: + p("issue +list envelope has .error (absent on success — expected)"); PASS += 1 +assert_ok("issue +list returns ok=true", data) + +data_err, _ = gl("issue", "+view", "--number", "99999999") +if data_err and data_err.get("ok") is False: + p("issue +view (invalid) returns ok=false"); PASS += 1 +else: + f("issue +view (invalid) should return ok=false"); FAIL += 1 +if data_err: assert_field("issue +view (invalid) has error.message", data_err, ["error","message"]) + +# ── 3. Data Field Extraction ──────────────────────────────────────── +section("3. Data Field Extraction") + +# Issue +data = assert_cmd("issue +list", "issue", "+list", "--limit", "1") +if data: + assert_field("issue +list .data.issues[0].id", data, ["data","issues",0,"id"]) + assert_field("issue +list .data.issues[0].author.login", data, ["data","issues",0,"author","login"]) + iid = data.get("data",{}).get("issues",[{}])[0].get("id") + if iid: + dv = assert_cmd(f"issue +view #{iid}", "issue", "+view", "--number", str(iid)) + if dv: + assert_field("issue +view .data.id", dv, ["data","id"]) + assert_field("issue +view .data.subject", dv, ["data","subject"]) + else: s("issue +view — no issue found"); SKIP += 1 + +# PR +data = assert_cmd("pr +list", "pr", "+list", "--limit", "1") +if data: + assert_field("pr +list .data.issues[0].id", data, ["data","issues",0,"id"]) + assert_field("pr +list .data.issues[0].author_login", data, ["data","issues",0,"author_login"]) + pid = data.get("data",{}).get("issues",[{}])[0].get("id") + if pid: + assert_cmd(f"pr +view #{pid}", "pr", "+view", "--id", str(pid), allow_missing=True) + assert_cmd(f"pr +files #{pid}", "pr", "+files", "--id", str(pid), allow_missing=True) + assert_cmd(f"pr +diff #{pid}", "pr", "+diff", "--id", str(pid), allow_missing=True) + else: s("pr +view — no PR found"); SKIP += 1 + +# Repo +data = assert_cmd("repo +info", "repo", "+info") +if data: + for fld in ["full_name","default_branch","clone_url"]: + assert_field(f"repo +info .data.{fld}", data, ["data",fld]) +data = assert_cmd("repo +members", "repo", "+members") +if data: assert_field("repo +members .data.members[0].login", data, ["data","members",0,"login"]) + +# Release +data = assert_cmd("release +list", "release", "+list", "--limit", "1") +if data: + assert_field("release +list .data.releases[0].tag_name", data, ["data","releases",0,"tag_name"]) + rid = data.get("data",{}).get("releases",[{}])[0].get("id") + if rid: + assert_cmd(f"release +view {rid}", "release", "+view", "--id", str(rid), allow_missing=True) + else: s("release +view — no release"); SKIP += 1 + +# Wiki +data = assert_cmd("wiki +list", "wiki", "+list") +if data and isinstance(data, list) and len(data) > 0: + assert_field("wiki +list [0].title", data[0], ["title"]) +elif data and isinstance(data, dict): + items = data.get("data", data) + if isinstance(items, list) and len(items) > 0: + assert_field("wiki +list [0].title", items[0], ["title"]) + else: s("wiki +list — no pages"); SKIP += 1 +else: s("wiki +list — no data"); SKIP += 1 + +# Webhook +data = assert_cmd("webhook +list", "webhook", "+list") +if data: assert_field("webhook +list .data.webhooks[0].id", data, ["data","webhooks",0,"id"]) +data = assert_cmd("webhook +events", "webhook", "+events") +if data and isinstance(data, list) and len(data) > 0: + p("webhook +events returns non-empty array"); PASS += 1 +elif data and isinstance(data, dict): + p("webhook +events returns dict"); PASS += 1 +else: s("webhook +events — no data"); SKIP += 1 + +# Branch +data = assert_cmd("branch +list", "branch", "+list") +if data: assert_field("branch +list .data.branches[0].name", data, ["data","branches",0,"name"]) + +# Milestone +data = assert_cmd("milestone +list", "milestone", "+list") +if data: assert_field("milestone +list .data.milestones[0].name", data, ["data","milestones",0,"name"]) + +# ── 4. common.sh Utility Functions ────────────────────────────────── +section("4. common.sh Utility Functions") +wf_dir = os.path.dirname(os.path.abspath(__file__)) +lib_path = os.path.join(wf_dir, "lib", "common.sh") +bash_check("lib/common.sh", lib_path) +try: + with open(lib_path, "r", encoding="utf-8") as fh: + content = fh.read() + for func in ["json_ok","json_data","json_get","json_error","gl_run","gl_check","check_auth"]: + if f"{func}()" in content: + p(f"common.sh defines {func}()"); PASS += 1 + else: + f(f"common.sh missing {func}()"); FAIL += 1 +except: + f("common.sh read failed"); FAIL += 1 + +# ── 5. Script Syntax Validation ──────────────────────────────────── +section("5. Script Syntax Validation") +for sh in sorted(os.listdir(wf_dir)): + if sh.endswith(".sh"): + bash_check(sh, os.path.join(wf_dir, sh)) +for lib_dir in ["lib"]: + libp = os.path.join(wf_dir, lib_dir) + if os.path.isdir(libp): + for sh in sorted(os.listdir(libp)): + if sh.endswith(".sh"): + bash_check(f"lib/{sh}", os.path.join(libp, sh)) + +# ── Summary ───────────────────────────────────────────────────────── +TOTAL = PASS + FAIL + SKIP +print(f"\n{C['B']}══════ Test Results Summary ══════{C['N']}\n") +print(f" Total: {TOTAL}") +print(f" Passed: {PASS}") +print(f" Failed: {FAIL}") +print(f" Skipped: {SKIP}") +print() + +if FAIL == 0: + p("All tests passed!") + sys.exit(0) +else: + f(f"{FAIL} test(s) failed") + sys.exit(1) diff --git a/workflows/test.sh b/workflows/test.sh new file mode 100644 index 0000000..ca92585 --- /dev/null +++ b/workflows/test.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +# gitlink-cli Integration Test Suite +# Usage: bash workflows/test.sh [owner] [repo] +python "$(dirname "${BASH_SOURCE[0]}")/test.py" "${1:-zzx-coder}" "${2:-gitlink-cli}"