commit 0321f4a598653b7ba00f22ff80879ef74dcb8cd5 Author: zlei9 Date: Sun Mar 29 13:02:39 2026 +0800 Initial commit with translated description diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..3ed6368 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,157 @@ +--- +name: linear +description: "查询和管理Linear问题、项目和团队工作流。" +homepage: https://linear.app +metadata: {"clawdis":{"emoji":"📊","requires":{"env":["LINEAR_API_KEY"]}}} +--- + +# Linear + +Manage issues, check project status, and stay on top of your team's work. + +## Setup + +```bash +export LINEAR_API_KEY="your-api-key" +# Optional: default team key used when a command needs a team +export LINEAR_DEFAULT_TEAM="TEAM" +``` + +Discover team keys: + +```bash +{baseDir}/scripts/linear.sh teams +``` + +If `LINEAR_DEFAULT_TEAM` is set, you can omit the team key in `team` and call: + +```bash +{baseDir}/scripts/linear.sh create "Title" ["Description"] +``` + +## Quick Commands + +```bash +# My stuff +{baseDir}/scripts/linear.sh my-issues # Your assigned issues +{baseDir}/scripts/linear.sh my-todos # Just your Todo items +{baseDir}/scripts/linear.sh urgent # Urgent/High priority across team + +# Browse +{baseDir}/scripts/linear.sh teams # List available teams +{baseDir}/scripts/linear.sh team # All issues for a team +{baseDir}/scripts/linear.sh project # Issues in a project +{baseDir}/scripts/linear.sh issue # Get issue details +{baseDir}/scripts/linear.sh branch # Get branch name for GitHub + +# Actions +{baseDir}/scripts/linear.sh create "Title" ["Description"] +{baseDir}/scripts/linear.sh comment "Comment text" +{baseDir}/scripts/linear.sh status +{baseDir}/scripts/linear.sh assign +{baseDir}/scripts/linear.sh priority + +# Overview +{baseDir}/scripts/linear.sh standup # Daily standup summary +{baseDir}/scripts/linear.sh projects # All projects with progress +``` + +## Common Workflows + +### Morning Standup +```bash +{baseDir}/scripts/linear.sh standup +``` +Shows: your todos, blocked items across team, recently completed, what's in review. + +### Quick Issue Creation (from chat) +```bash +{baseDir}/scripts/linear.sh create TEAM "Fix auth timeout bug" "Users getting logged out after 5 min" +``` + +### Triage Mode +```bash +{baseDir}/scripts/linear.sh urgent # See what needs attention +``` + +## Git Workflow (Linear ↔ GitHub Integration) + +**Always use Linear-derived branch names** to enable automatic issue status tracking. + +### Getting the Branch Name +```bash +{baseDir}/scripts/linear.sh branch TEAM-212 +# Returns: dev/team-212-fix-auth-timeout-bug +``` + +### Creating a Worktree for an Issue +```bash +# 1. Get the branch name from Linear +BRANCH=$({baseDir}/scripts/linear.sh branch TEAM-212) + +# 2. Pull fresh main first (main should ALWAYS match origin) +cd /path/to/repo +git checkout main && git pull origin main + +# 3. Create worktree with that branch (branching from fresh origin/main) +git worktree add .worktrees/team-212 -b "$BRANCH" origin/main +cd .worktrees/team-212 + +# 4. Do your work, commit, push +git push -u origin "$BRANCH" +``` + +**⚠️ Never modify files on main.** All changes happen in worktrees only. + +### Why This Matters +- Linear's GitHub integration tracks PRs by branch name pattern +- When you create a PR from a Linear branch, the issue **automatically moves to "In Review"** +- When the PR merges, the issue **automatically moves to "Done"** +- Manual branch names break this automation +- Keeping main clean = no accidental pushes, easy worktree cleanup + +### Quick Reference +```bash +# Full workflow example +ISSUE="TEAM-212" +BRANCH=$({baseDir}/scripts/linear.sh branch $ISSUE) + +# Always start from fresh main +cd ~/workspace/your-repo +git checkout main && git pull origin main + +# Create worktree (inside .worktrees/) +git worktree add .worktrees/${ISSUE,,} -b "$BRANCH" origin/main +cd .worktrees/${ISSUE,,} + +# ... make changes ... +git add -A && git commit -m "fix: implement $ISSUE" +git push -u origin "$BRANCH" +gh pr create --title "$ISSUE: " --body "Closes $ISSUE" +``` + +## Priority Levels + +| Level | Value | Use for | +|-------|-------|---------| +| urgent | 1 | Production issues, blockers | +| high | 2 | This week, important | +| medium | 3 | This sprint/cycle | +| low | 4 | Nice to have | +| none | 0 | Backlog, someday | + +## Teams (cached) + +Team keys and IDs are discovered via the API and cached locally after the first lookup. +Use `linear.sh teams` to refresh and list available teams. + +## Notes + +- Uses GraphQL API (api.linear.app/graphql) +- Requires `LINEAR_API_KEY` env var +- Issue identifiers are like `TEAM-123` + +## Attribution + +Inspired by [schpet/linear-cli](https://github.com/schpet/linear-cli) by Peter Schilling (ISC License). +This is an independent bash implementation for Clawdbot integration. diff --git a/_meta.json b/_meta.json new file mode 100644 index 0000000..4c98483 --- /dev/null +++ b/_meta.json @@ -0,0 +1,6 @@ +{ + "ownerId": "kn75gkrc5mkhng2krphfv76brn7ynt1t", + "slug": "linear", + "version": "1.0.0", + "publishedAt": 1767722342343 +} \ No newline at end of file diff --git a/scripts/linear.sh b/scripts/linear.sh new file mode 100644 index 0000000..dbfe13f --- /dev/null +++ b/scripts/linear.sh @@ -0,0 +1,309 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Linear CLI wrapper +# Requires: LINEAR_API_KEY, curl, jq + +API="https://api.linear.app/graphql" + +if [[ -z "${LINEAR_API_KEY:-}" ]]; then + echo "Error: LINEAR_API_KEY not set" >&2 + exit 1 +fi + +gql() { + local query="$1" + curl -s -X POST "$API" \ + -H "Content-Type: application/json" \ + -H "Authorization: $LINEAR_API_KEY" \ + -d "{\"query\": \"$query\"}" +} + +cache_key="$(printf '%s' "$LINEAR_API_KEY" | cksum | awk '{print $1}')" +TEAMS_CACHE="${LINEAR_TEAMS_CACHE:-/tmp/linear-teams-${cache_key}.json}" + +refresh_teams_cache() { + gql "{ teams { nodes { id key name } } }" > "$TEAMS_CACHE" +} + +load_teams() { + if [[ ! -f "$TEAMS_CACHE" ]]; then + refresh_teams_cache + fi + cat "$TEAMS_CACHE" +} + +resolve_team_id() { + local team_key="${1:-}" + local team_id="" + if [[ -z "$team_key" ]]; then + team_key="${LINEAR_DEFAULT_TEAM:-}" + fi + if [[ -z "$team_key" ]]; then + echo "Error: team key required. Run 'linear.sh teams' to list teams." >&2 + return 1 + fi + team_id=$(load_teams | jq -r --arg key "$team_key" '.data.teams.nodes[] | select(.key == $key) | .id' | head -n1) + if [[ -z "$team_id" || "$team_id" == "null" ]]; then + refresh_teams_cache + team_id=$(load_teams | jq -r --arg key "$team_key" '.data.teams.nodes[] | select(.key == $key) | .id' | head -n1) + fi + if [[ -z "$team_id" || "$team_id" == "null" ]]; then + local team_keys + team_keys=$(load_teams | jq -r '.data.teams.nodes[].key' | tr '\n' ' ') + echo "Unknown team: $team_key (available: $team_keys)" >&2 + return 1 + fi + echo "$team_id" +} + +# Status name to ID mapping (fetched dynamically when needed) +get_state_id() { + local team_key="$1" + local state_name="$2" + local team_id + team_id=$(resolve_team_id "$team_key") || return 1 + + # Map friendly names to actual state names + case "$state_name" in + todo) state_name="Todo" ;; + progress) state_name="In Progress" ;; + review) state_name="In Review" ;; + done) state_name="Done" ;; + blocked) state_name="Blocked" ;; + backlog) state_name="Backlog" ;; + esac + + gql "{ workflowStates(filter: { team: { id: { eq: \\\"$team_id\\\" } }, name: { eq: \\\"$state_name\\\" } }) { nodes { id } } }" | jq -r '.data.workflowStates.nodes[0].id' +} + +format_issues() { + jq -r '.data | .. | .nodes? // empty | .[] | select(.identifier) | + "[\(.priorityLabel // "—")] \(.identifier): \(.title) (\(.state.name)) \(if .assignee then "→ " + .assignee.name else "" end)"' 2>/dev/null || echo "No issues found" +} + +format_issue_detail() { + jq -r '.data.issue | " +\(.identifier): \(.title) +State: \(.state.name) | Priority: \(.priorityLabel // "None") | Assignee: \(.assignee.name // "Unassigned") +Project: \(.project.name // "None") | Team: \(.team.name) +Created: \(.createdAt | split("T")[0]) +\(if .dueDate then "Due: " + .dueDate else "" end) + +\(.description // "No description") +"' +} + +cmd="${1:-help}" +shift || true + +case "$cmd" in + my-issues) + gql "{ viewer { assignedIssues(first: 20, filter: { state: { type: { nin: [\\\"completed\\\", \\\"canceled\\\"] } } }) { nodes { identifier title state { name } priority priorityLabel } } } }" | format_issues + ;; + + my-todos) + gql "{ viewer { assignedIssues(first: 20, filter: { state: { type: { eq: \\\"unstarted\\\" } } }) { nodes { identifier title state { name } priority priorityLabel } } } }" | format_issues + ;; + + urgent) + gql "{ issues(filter: { priority: { lte: 2 }, state: { type: { nin: [\\\"completed\\\", \\\"canceled\\\"] } } }, first: 20) { nodes { identifier title state { name } priority priorityLabel assignee { name } } } }" | format_issues + ;; + + team) + team_key="${1:-}" + team_id=$(resolve_team_id "$team_key") || exit 1 + gql "{ team(id: \\\"$team_id\\\") { issues(first: 30, filter: { state: { type: { nin: [\\\"completed\\\", \\\"canceled\\\"] } } }) { nodes { identifier title state { name } priority priorityLabel assignee { name } } } } }" | format_issues + ;; + + project) + project_name="${1:-}" + if [[ -z "$project_name" ]]; then + echo "Usage: linear.sh project <name>" >&2 + exit 1 + fi + gql "{ projects(filter: { name: { containsIgnoreCase: \\\"$project_name\\\" } }, first: 1) { nodes { issues(first: 30, filter: { state: { type: { nin: [\\\"completed\\\", \\\"canceled\\\"] } } }) { nodes { identifier title state { name } priority priorityLabel assignee { name } } } } } }" | format_issues + ;; + + issue) + issue_id="${1:-}" + if [[ -z "$issue_id" ]]; then + echo "Usage: linear.sh issue <TEAM-123>" >&2 + exit 1 + fi + team_key="${issue_id%%-*}" + issue_num="${issue_id##*-}" + gql "{ issues(filter: { number: { eq: $issue_num }, team: { key: { eq: \\\"$team_key\\\" } } }) { nodes { identifier title description state { name } priority priorityLabel assignee { name } project { name } team { name } createdAt dueDate } } }" | jq -r '.data.issues.nodes[0] | " +\(.identifier): \(.title) +State: \(.state.name) | Priority: \(.priorityLabel // "None") | Assignee: \(.assignee.name // "Unassigned") +Project: \(.project.name // "None") | Team: \(.team.name) +Created: \(.createdAt | split("T")[0]) +\(if .dueDate then "Due: " + .dueDate else "" end) + +\(.description // "No description") +"' + ;; + + create) + team_key="${1:-}" + title="${2:-}" + description="${3:-}" + if [[ -z "$title" && -n "${LINEAR_DEFAULT_TEAM:-}" ]]; then + team_key="$LINEAR_DEFAULT_TEAM" + title="${1:-}" + description="${2:-}" + fi + if [[ -z "$team_key" || -z "$title" ]]; then + echo "Usage: linear.sh create <TEAM_KEY> \"Title\" [\"Description\"]" >&2 + exit 1 + fi + team_id=$(resolve_team_id "$team_key") || exit 1 + # Escape quotes in title and description + title="${title//\"/\\\"}" + description="${description//\"/\\\"}" + result=$(gql "mutation { issueCreate(input: { teamId: \\\"$team_id\\\", title: \\\"$title\\\", description: \\\"$description\\\" }) { success issue { identifier title url } } }") + echo "$result" | jq -r 'if .data.issueCreate.success then "Created: \(.data.issueCreate.issue.identifier) - \(.data.issueCreate.issue.title)\n\(.data.issueCreate.issue.url)" else "Error: " + (.errors[0].message // "Unknown error") end' + ;; + + comment) + issue_id="${1:-}" + body="${2:-}" + if [[ -z "$issue_id" || -z "$body" ]]; then + echo "Usage: linear.sh comment <TEAM-123> \"Comment text\"" >&2 + exit 1 + fi + body="${body//\"/\\\"}" + # First get the issue UUID from the identifier + issue_uuid=$(gql "{ issueVcsBranchSearch(branchName: \\\"$issue_id\\\") { id } }" | jq -r '.data.issueVcsBranchSearch.id // empty') + if [[ -z "$issue_uuid" ]]; then + # Try direct lookup by identifier + issue_uuid=$(gql "{ issues(filter: { number: { eq: ${issue_id##*-} } }) { nodes { id identifier } } }" | jq -r ".data.issues.nodes[] | select(.identifier == \"$issue_id\") | .id") + fi + if [[ -z "$issue_uuid" ]]; then + echo "Could not find issue: $issue_id" >&2 + exit 1 + fi + result=$(gql "mutation { commentCreate(input: { issueId: \\\"$issue_uuid\\\", body: \\\"$body\\\" }) { success comment { id } } }") + echo "$result" | jq -r 'if .data.commentCreate.success then "Comment added" else "Error: " + (.errors[0].message // "Unknown error") end' + ;; + + status) + issue_id="${1:-}" + new_status="${2:-}" + if [[ -z "$issue_id" || -z "$new_status" ]]; then + echo "Usage: linear.sh status <TEAM-123> <todo|progress|review|done|blocked>" >&2 + exit 1 + fi + team_key="${issue_id%%-*}" + state_id=$(get_state_id "$team_key" "$new_status") + if [[ -z "$state_id" || "$state_id" == "null" ]]; then + echo "Could not find state: $new_status for team $team_key" >&2 + exit 1 + fi + # Get issue UUID + issue_num="${issue_id##*-}" + issue_uuid=$(gql "{ issues(filter: { number: { eq: $issue_num }, team: { key: { eq: \\\"$team_key\\\" } } }) { nodes { id } } }" | jq -r '.data.issues.nodes[0].id') + result=$(gql "mutation { issueUpdate(id: \\\"$issue_uuid\\\", input: { stateId: \\\"$state_id\\\" }) { success issue { identifier state { name } } } }") + echo "$result" | jq -r 'if .data.issueUpdate.success then "Updated \(.data.issueUpdate.issue.identifier) → \(.data.issueUpdate.issue.state.name)" else "Error: " + (.errors[0].message // "Unknown error") end' + ;; + + priority) + issue_id="${1:-}" + priority="${2:-}" + if [[ -z "$issue_id" || -z "$priority" ]]; then + echo "Usage: linear.sh priority <TEAM-123> <urgent|high|medium|low|none>" >&2 + exit 1 + fi + case "$priority" in + urgent) pval=1 ;; + high) pval=2 ;; + medium) pval=3 ;; + low) pval=4 ;; + none) pval=0 ;; + *) echo "Unknown priority: $priority" >&2; exit 1 ;; + esac + team_key="${issue_id%%-*}" + issue_num="${issue_id##*-}" + issue_uuid=$(gql "{ issues(filter: { number: { eq: $issue_num }, team: { key: { eq: \\\"$team_key\\\" } } }) { nodes { id } } }" | jq -r '.data.issues.nodes[0].id') + result=$(gql "mutation { issueUpdate(id: \\\"$issue_uuid\\\", input: { priority: $pval }) { success issue { identifier priorityLabel } } }") + echo "$result" | jq -r 'if .data.issueUpdate.success then "Updated \(.data.issueUpdate.issue.identifier) → \(.data.issueUpdate.issue.priorityLabel)" else "Error: " + (.errors[0].message // "Unknown error") end' + ;; + + assign) + issue_id="${1:-}" + user_name="${2:-}" + if [[ -z "$issue_id" || -z "$user_name" ]]; then + echo "Usage: linear.sh assign <TEAM-123> <userName>" >&2 + exit 1 + fi + # Get user ID + user_id=$(gql "{ users(filter: { name: { containsIgnoreCase: \\\"$user_name\\\" } }) { nodes { id name } } }" | jq -r '.data.users.nodes[0].id') + if [[ -z "$user_id" || "$user_id" == "null" ]]; then + echo "Could not find user: $user_name" >&2 + exit 1 + fi + team_key="${issue_id%%-*}" + issue_num="${issue_id##*-}" + issue_uuid=$(gql "{ issues(filter: { number: { eq: $issue_num }, team: { key: { eq: \\\"$team_key\\\" } } }) { nodes { id } } }" | jq -r '.data.issues.nodes[0].id') + result=$(gql "mutation { issueUpdate(id: \\\"$issue_uuid\\\", input: { assigneeId: \\\"$user_id\\\" }) { success issue { identifier assignee { name } } } }") + echo "$result" | jq -r 'if .data.issueUpdate.success then "Assigned \(.data.issueUpdate.issue.identifier) → \(.data.issueUpdate.issue.assignee.name)" else "Error: " + (.errors[0].message // "Unknown error") end' + ;; + + projects) + gql "{ projects(first: 20) { nodes { name state progress startDate targetDate teams { nodes { name } } } } }" | jq -r '.data.projects.nodes[] | "\(.name) [\(.state)] - \((.progress * 100) | floor)% complete \(if .targetDate then "(due " + .targetDate + ")" else "" end)"' + ;; + + branch) + issue_id="${1:-}" + if [[ -z "$issue_id" ]]; then + echo "Usage: linear.sh branch <TEAM-123>" >&2 + exit 1 + fi + team_key="${issue_id%%-*}" + issue_num="${issue_id##*-}" + gql "{ issues(filter: { number: { eq: $issue_num }, team: { key: { eq: \\\"$team_key\\\" } } }) { nodes { branchName } } }" | jq -r '.data.issues.nodes[0].branchName' + ;; + + teams) + refresh_teams_cache + jq -r '.data.teams.nodes[] | "\(.key)\t\(.name)"' "$TEAMS_CACHE" + ;; + + standup) + echo "=== 📊 Daily Standup ===" + echo "" + echo "🎯 YOUR TODOS:" + gql "{ viewer { assignedIssues(first: 10, filter: { state: { type: { eq: \\\"unstarted\\\" } } }) { nodes { identifier title priorityLabel } } } }" | jq -r '.data.viewer.assignedIssues.nodes // [] | sort_by(.priorityLabel) | .[] | " [\(.priorityLabel // "—")] \(.identifier): \(.title)"' + echo "" + echo "🚧 IN PROGRESS (yours):" + gql "{ viewer { assignedIssues(first: 10, filter: { state: { type: { eq: \\\"started\\\" } } }) { nodes { identifier title state { name } } } } }" | jq -r '.data.viewer.assignedIssues.nodes // [] | .[] | " \(.identifier): \(.title) (\(.state.name))"' + echo "" + echo "🔴 BLOCKED (team-wide):" + gql "{ issues(filter: { state: { name: { in: [\\\"Blocked\\\", \\\"Paused\\\"] } } }, first: 10) { nodes { identifier title assignee { name } } } }" | jq -r '.data.issues.nodes // [] | .[] | " \(.identifier): \(.title) → \(.assignee.name // "unassigned")"' + echo "" + echo "✅ RECENTLY DONE (last 7 days):" + week_ago=$(date -d '7 days ago' +%Y-%m-%d 2>/dev/null || date -v-7d +%Y-%m-%d) + gql "{ issues(filter: { state: { type: { eq: \\\"completed\\\" } }, completedAt: { gte: \\\"$week_ago\\\" } }, first: 10) { nodes { identifier title completedAt assignee { name } } } }" | jq -r '.data.issues.nodes // [] | .[] | " \(.identifier): \(.title) → \(.assignee.name // "unassigned")"' + ;; + + help|*) + echo "Linear CLI - Manage issues and projects" + echo "" + echo "Commands:" + echo " my-issues Your assigned open issues" + echo " my-todos Your Todo items" + echo " urgent Urgent/High priority issues (all)" + echo " teams List available teams" + echo " team [TEAM_KEY] Team issues (uses LINEAR_DEFAULT_TEAM if set)" + echo " project <name> Project issues" + echo " issue <ID> Issue details" + echo " branch <ID> Get Linear branch name for GitHub integration" + echo " create <TEAM_KEY> \"title\" [\"desc\"] Create issue" + echo " comment <ID> \"text\" Add comment" + echo " status <ID> <state> Update status" + echo " priority <ID> <level> Set priority" + echo " assign <ID> <user> Assign to user" + echo " projects List all projects" + echo " standup Daily standup summary" + ;; +esac