📦 deps(thirdparty): update snapshots
This commit is contained in:
@@ -0,0 +1,105 @@
|
||||
# Status Reporting Examples
|
||||
|
||||
## Basic Usage
|
||||
|
||||
No time filter - defaults to 7 days:
|
||||
|
||||
```
|
||||
User: "Give me a status report"
|
||||
Agent: {parses as default 7-day window}
|
||||
{gathers from available sources}
|
||||
{presents structured report}
|
||||
```
|
||||
|
||||
## Time-Constrained
|
||||
|
||||
Natural language parsing:
|
||||
|
||||
```
|
||||
User: "Status report for last 24 hours"
|
||||
Agent: {parses "last 24 hours" → "-24h"}
|
||||
{applies to all source queries}
|
||||
{presents filtered report with "Last 24 hours" header}
|
||||
```
|
||||
|
||||
## Multi-Source Report
|
||||
|
||||
Full context gathering:
|
||||
|
||||
```
|
||||
Agent gathers:
|
||||
- Graphite stack (3 branches, 3 PRs)
|
||||
- GitHub PR status (2 passing CI, 1 failing)
|
||||
- Linear issues (5 updated recently)
|
||||
- CI details (12 runs, 2 failures)
|
||||
|
||||
Agent presents:
|
||||
- Stack visualization with PR status
|
||||
- PR details with CI/review state
|
||||
- Issue activity sorted by priority
|
||||
- CI summary with failure links
|
||||
- Attention section: 1 failing CI, 1 unassigned high-priority issue
|
||||
```
|
||||
|
||||
## Graceful Degradation
|
||||
|
||||
Limited source availability:
|
||||
|
||||
```
|
||||
Agent detects:
|
||||
- git available (no Graphite)
|
||||
- gh CLI available
|
||||
- No Linear MCP
|
||||
- No CI access
|
||||
|
||||
Agent presents:
|
||||
- Standard git status (branch, commits)
|
||||
- GitHub PR section (from gh CLI)
|
||||
- Note: "Linear and CI sections unavailable"
|
||||
```
|
||||
|
||||
## Sample Output
|
||||
|
||||
```
|
||||
=== STATUS REPORT: my-project ===
|
||||
Generated: 2024-01-15 14:30 UTC
|
||||
Time filter: Last 24 hours
|
||||
|
||||
📊 GRAPHITE STACK
|
||||
main
|
||||
├─ feature/auth: ✓ synced [3 commits]
|
||||
│ PR #42: Open | CI: ✓ 8/8 | Reviews: ⏳ 0/1
|
||||
│ Updated: 2 hours ago
|
||||
└─ feature/auth-refresh: ✓ synced [2 commits]
|
||||
PR #43: Draft | CI: ⏳ running
|
||||
Updated: 30 minutes ago
|
||||
|
||||
🔀 PULL REQUESTS (2 open)
|
||||
PR #42: Add JWT authentication [Open]
|
||||
Author: @dev | Updated: 2 hours ago
|
||||
CI: ✓ 8/8 checks | Reviews: ⏳ awaiting review
|
||||
|
||||
PR #43: Token refresh flow [Draft]
|
||||
Author: @dev | Updated: 30 minutes ago
|
||||
CI: ⏳ 3/8 checks running
|
||||
|
||||
📋 ISSUES (3 updated)
|
||||
AUTH-123: Implement refresh tokens [In Progress]
|
||||
Priority: High | Assignee: @dev
|
||||
Updated: 1 hour ago
|
||||
|
||||
AUTH-124: Add rate limiting [Todo]
|
||||
Priority: Medium | Assignee: unassigned
|
||||
Updated: 4 hours ago
|
||||
|
||||
🔧 CI/CD (5 runs)
|
||||
Success: 3 | Failed: 1 | In Progress: 1
|
||||
|
||||
Recent Failures:
|
||||
lint-check: ESLint found 2 errors
|
||||
https://github.com/org/repo/actions/runs/123
|
||||
|
||||
⚠️ ATTENTION NEEDED
|
||||
◆ PR #42: Awaiting review for 2 hours
|
||||
◇ AUTH-124: High priority, unassigned
|
||||
```
|
||||
@@ -0,0 +1,244 @@
|
||||
---
|
||||
name: check-status
|
||||
description: This skill should be used when checking project status, starting sessions, reviewing activity, or when "sitrep", "status report", or "what's changed" are mentioned.
|
||||
metadata:
|
||||
version: "1.0.1"
|
||||
---
|
||||
|
||||
# Status Reporting
|
||||
|
||||
Gather -> aggregate -> present pattern for comprehensive project status across VCS, PRs, issues, CI.
|
||||
|
||||
<when_to_use>
|
||||
|
||||
- Starting work sessions (context refresh)
|
||||
- Checking project/team activity
|
||||
- Understanding PR/stack relationships
|
||||
- Quick status overview before planning
|
||||
- Reviewing recent changes across systems
|
||||
- Understanding blockers
|
||||
|
||||
NOT for: deep-dive into specific items, real-time monitoring, single-source queries
|
||||
|
||||
</when_to_use>
|
||||
|
||||
<core_pattern>
|
||||
|
||||
**Three-stage workflow**:
|
||||
|
||||
1. **Gather** - collect from multiple sources
|
||||
2. **Aggregate** - combine, filter, cross-reference by time/stack/status
|
||||
3. **Present** - format for scanning with actionable insights
|
||||
|
||||
Key principles:
|
||||
- Multi-source integration (VCS + code review + issues + CI)
|
||||
- Time-aware filtering (natural language -> query params)
|
||||
- Stack-aware organization (group by branch hierarchy)
|
||||
- Scannable output (visual indicators, relative times)
|
||||
- Actionable insights (highlight blockers, failures)
|
||||
|
||||
</core_pattern>
|
||||
|
||||
<workflow>
|
||||
|
||||
**Stage 1: Parse Constraints**
|
||||
|
||||
Extract time from natural language:
|
||||
- "last X hours" -> `-Xh`
|
||||
- "past X days" / "last X days" -> `-Xd`
|
||||
- "yesterday" -> `-1d`
|
||||
- "this morning" / "today" -> `-12h`
|
||||
- "this week" -> `-7d`
|
||||
- "since {date}" -> calculate days back
|
||||
|
||||
Default: 7 days if unspecified.
|
||||
|
||||
**Stage 2: Gather Data**
|
||||
|
||||
Run parallel queries for each available source:
|
||||
|
||||
1. **VCS State** - branch/stack structure, recent commits, working dir status
|
||||
2. **Code Review** - open PRs, CI status, review decisions, activity
|
||||
3. **Issues** - recently updated, status, priority, assignments
|
||||
4. **CI/CD** - pipeline runs, success/failure, error summaries
|
||||
|
||||
Skip unavailable sources gracefully.
|
||||
|
||||
**Stage 3: Aggregate**
|
||||
|
||||
Cross-reference and organize:
|
||||
- Group PRs by stack position (if stack-aware)
|
||||
- Filter all by time constraint
|
||||
- Correlate issues with PRs/branches
|
||||
- Identify blockers (failed CI, blocking reviews)
|
||||
- Calculate relative timestamps
|
||||
|
||||
**Stage 4: Present**
|
||||
|
||||
Format for scanning:
|
||||
- Hierarchical sections (VCS -> PRs -> Issues -> CI)
|
||||
- Visual indicators (`✓` `✗` `⏳` for status)
|
||||
- Relative timestamps for recency
|
||||
- Highlight attention-needed items
|
||||
- Include links for deep-dive
|
||||
|
||||
See [templates.md](references/templates.md) for section formats.
|
||||
|
||||
</workflow>
|
||||
|
||||
<data_sources>
|
||||
|
||||
**VCS** - stack visualization, commit history, working dir state
|
||||
- Stack-aware (Graphite, git-stack): hierarchical branch relationships
|
||||
- Standard git: branch, log, remote tracking
|
||||
|
||||
**Code Review** - PRs/MRs, CI checks, reviews, comments
|
||||
- Platforms: GitHub, GitLab, Bitbucket, Gerrit
|
||||
|
||||
**Issues** - recent updates, metadata, repo relationships
|
||||
- Platforms: Linear, Jira, GitHub Issues, GitLab Issues
|
||||
|
||||
**CI/CD** - runs, success/failure, timing, errors
|
||||
- Platforms: GitHub Actions, GitLab CI, CircleCI, Jenkins
|
||||
|
||||
Tool-specific: [graphite.md](references/graphite.md), [github.md](references/github.md), [linear.md](references/linear.md), [beads.md](references/beads.md)
|
||||
|
||||
</data_sources>
|
||||
|
||||
<aggregation>
|
||||
|
||||
**Cross-Referencing**:
|
||||
1. PRs to branches (by name)
|
||||
2. Issues to PRs (by ID in title/body)
|
||||
3. CI runs to PRs (by number/SHA)
|
||||
4. Issues to repos (by reference)
|
||||
|
||||
**Stack-Aware Organization**:
|
||||
- Group PRs by hierarchy
|
||||
- Show parent/child relationships
|
||||
- Indicate current position
|
||||
- Highlight blockers in stack order
|
||||
|
||||
**Filtering**:
|
||||
- Time: apply to all sources, use most recent update
|
||||
- Status: prioritize action-needed, open before closed
|
||||
|
||||
**Relative Timestamps**:
|
||||
- < 1 hour: "X minutes ago"
|
||||
- < 24 hours: "X hours ago"
|
||||
- < 7 days: "X days ago"
|
||||
- >= 7 days: "X weeks ago" or absolute
|
||||
|
||||
</aggregation>
|
||||
|
||||
<presentation>
|
||||
|
||||
**Visual Indicators**:
|
||||
- `✓` success | `✗` failure | `⏳` pending | `⏸` draft | `🔴` blocker
|
||||
- `▓▓▓░░` progress (3/5)
|
||||
- `◇` minor | `◆` moderate | `◆◆` severe
|
||||
|
||||
**Output Structure**:
|
||||
|
||||
```
|
||||
=== STATUS REPORT: {repo} ===
|
||||
Generated: {timestamp}
|
||||
{Time filter if applicable}
|
||||
|
||||
{VCS_SECTION}
|
||||
{PR_SECTION}
|
||||
{ISSUE_SECTION}
|
||||
{CI_SECTION}
|
||||
|
||||
⚠️ ATTENTION NEEDED
|
||||
{blockers and action items}
|
||||
```
|
||||
|
||||
See [templates.md](references/templates.md) for detailed section templates.
|
||||
|
||||
</presentation>
|
||||
|
||||
<scripts>
|
||||
|
||||
Use `scripts/sitrep.ts` for automated gathering:
|
||||
|
||||
```bash
|
||||
./scripts/sitrep.ts # All sources, 24h default
|
||||
./scripts/sitrep.ts -t 7d # Last 7 days
|
||||
./scripts/sitrep.ts -s github # Specific sources
|
||||
./scripts/sitrep.ts --format=text
|
||||
```
|
||||
|
||||
Outputs JSON (structured) or text (human-readable). Reduces agent tool calls 80%+.
|
||||
|
||||
See [implementation.md](references/implementation.md) for script structure and patterns.
|
||||
|
||||
</scripts>
|
||||
|
||||
<dependencies>
|
||||
|
||||
**Required**: VCS tool (git, gt, jj), shell access
|
||||
|
||||
**Optional** (graceful degradation):
|
||||
- Code review CLI (gh, glab)
|
||||
- Issue tracker MCP/API
|
||||
- CI/CD platform API
|
||||
|
||||
Works with ANY available subset.
|
||||
|
||||
</dependencies>
|
||||
|
||||
<rules>
|
||||
|
||||
ALWAYS:
|
||||
- Parse time constraints before queries
|
||||
- Execute queries in parallel
|
||||
- Handle missing sources gracefully
|
||||
- Use relative timestamps
|
||||
- Highlight actionable items
|
||||
- Provide links for deep-dive
|
||||
- Format for scanning
|
||||
|
||||
NEVER:
|
||||
- Fail entirely if one source unavailable
|
||||
- Block on slow queries (use timeouts)
|
||||
- Expose credentials
|
||||
- Dump raw data without organization
|
||||
|
||||
</rules>
|
||||
|
||||
<integration>
|
||||
|
||||
**As session starter**:
|
||||
1. Generate report (understand state)
|
||||
2. Identify attention-needed items
|
||||
3. Plan work (prioritize by blockers)
|
||||
4. Return periodically (track progress)
|
||||
|
||||
**Cross-skill references**:
|
||||
- Failing CI -> [debugging](../debugging/SKILL.md)
|
||||
- Before planning -> use report for context
|
||||
- When blocked -> check dependencies
|
||||
|
||||
**Automation**: daily standup, pre-commit hooks, PR creation context
|
||||
|
||||
</integration>
|
||||
|
||||
<references>
|
||||
|
||||
Tool integrations:
|
||||
- [graphite.md](references/graphite.md) - Graphite stack and PR queries
|
||||
- [github.md](references/github.md) - GitHub CLI patterns
|
||||
- [linear.md](references/linear.md) - Linear MCP integration
|
||||
- [beads.md](references/beads.md) - Local issue tracking
|
||||
|
||||
Implementation:
|
||||
- [templates.md](references/templates.md) - Output templates and formatting
|
||||
- [implementation.md](references/implementation.md) - Patterns, scripts, anti-patterns
|
||||
|
||||
Examples:
|
||||
- [EXAMPLES.md](EXAMPLES.md) - Usage examples and sample output
|
||||
|
||||
Formatting:
|
||||
|
||||
</references>
|
||||
@@ -0,0 +1,339 @@
|
||||
# Beads Integration
|
||||
|
||||
Local issue tracking with dependency awareness. Complements remote platforms (GitHub, Linear) with project-scoped work items stored in `.beads/`.
|
||||
|
||||
## Overview
|
||||
|
||||
Beads provides:
|
||||
- Local-first issue tracking (no remote dependency)
|
||||
- Dependency graphs between issues
|
||||
- Status workflow (open → in_progress → blocked → closed)
|
||||
- Priority levels and type classification
|
||||
- Assignee tracking for team awareness
|
||||
|
||||
**Key difference from Linear/GitHub**: Beads tracks work items at the project level, not org-wide. Data lives in `.beads/` directory.
|
||||
|
||||
## Core Commands for Status Reporting
|
||||
|
||||
### Stats Overview
|
||||
|
||||
```bash
|
||||
bd stats
|
||||
```
|
||||
|
||||
Returns project-level metrics:
|
||||
- Total issues, open/closed counts
|
||||
- In-progress and blocked counts
|
||||
- Ready items (unblocked, actionable)
|
||||
- Average lead time
|
||||
|
||||
**Use for**: Top-level summary section, health indicators.
|
||||
|
||||
### List Issues
|
||||
|
||||
```bash
|
||||
bd list # All issues (default limit: 20)
|
||||
bd list --status=open # Filter by status
|
||||
bd list --status=in_progress # Active work
|
||||
bd list --status=blocked # Stuck items
|
||||
bd list --priority=1 # Urgent only (1=urgent, 4=low)
|
||||
bd list --type=bug # Filter by type
|
||||
bd list --assignee=alice # Filter by assignee
|
||||
bd list --limit=10 # Pagination
|
||||
```
|
||||
|
||||
**Statuses**: `open`, `in_progress`, `blocked`, `closed`
|
||||
**Types**: `bug`, `feature`, `task`, `epic`, `chore`
|
||||
**Priority**: 1 (urgent) → 4 (low), 0 (none)
|
||||
|
||||
**Use for**: Recent activity, filtered views, assignee workload.
|
||||
|
||||
### Ready Items
|
||||
|
||||
```bash
|
||||
bd ready # Unblocked items ready for work
|
||||
bd ready --limit=5 # Top 5 actionable
|
||||
bd ready --priority=1 # Urgent and ready
|
||||
bd ready --assignee=alice # Ready for specific person
|
||||
```
|
||||
|
||||
Returns issues with zero blocking dependencies.
|
||||
|
||||
**Use for**: "What to work on next" section, actionable items.
|
||||
|
||||
### Blocked Items
|
||||
|
||||
```bash
|
||||
bd blocked
|
||||
```
|
||||
|
||||
Returns issues in blocked status with their blocking dependencies.
|
||||
|
||||
**Use for**: Dependency visibility, bottleneck identification.
|
||||
|
||||
### Issue Details
|
||||
|
||||
```bash
|
||||
bd show <issue-id> # Full details with dependencies
|
||||
```
|
||||
|
||||
Returns:
|
||||
- Full description, design notes, acceptance criteria
|
||||
- Blocking/blocked-by relationships
|
||||
- Activity history
|
||||
|
||||
**Use for**: Deep dive on specific blocked items.
|
||||
|
||||
## Data Schema
|
||||
|
||||
```typescript
|
||||
interface BeadsIssue {
|
||||
id: string; // e.g., "AG-1", "BLZ-42"
|
||||
title: string;
|
||||
description?: string;
|
||||
status: 'open' | 'in_progress' | 'blocked' | 'closed';
|
||||
issue_type: 'bug' | 'feature' | 'task' | 'epic' | 'chore';
|
||||
priority: 0 | 1 | 2 | 3 | 4; // 1=urgent, 4=low, 0=unset
|
||||
assignee?: string;
|
||||
labels: string[];
|
||||
created_at: string; // ISO 8601
|
||||
updated_at: string; // ISO 8601
|
||||
closed_at?: string;
|
||||
dependency_count: number; // Issues blocking this
|
||||
dependent_count: number; // Issues this blocks
|
||||
}
|
||||
|
||||
interface BeadsStats {
|
||||
total: number;
|
||||
open: number;
|
||||
in_progress: number;
|
||||
blocked: number;
|
||||
closed: number;
|
||||
ready: number; // Unblocked and actionable
|
||||
average_lead_time?: number; // Days from open to close
|
||||
}
|
||||
```
|
||||
|
||||
## Time Filtering
|
||||
|
||||
Beads lacks native time-based filtering. Apply client-side filtering on `updated_at`:
|
||||
|
||||
```typescript
|
||||
// Filter to issues updated within time range
|
||||
function filterByTime(issues: BeadsIssue[], hoursBack: number): BeadsIssue[] {
|
||||
const cutoff = new Date();
|
||||
cutoff.setHours(cutoff.getHours() - hoursBack);
|
||||
|
||||
return issues.filter(issue =>
|
||||
new Date(issue.updated_at) >= cutoff
|
||||
);
|
||||
}
|
||||
|
||||
// Example: last 24 hours
|
||||
const recentIssues = filterByTime(allIssues, 24);
|
||||
```
|
||||
|
||||
**Recommendation**: Fetch with higher limit, filter client-side, then present top N.
|
||||
|
||||
## Gathering Pattern
|
||||
|
||||
```typescript
|
||||
async function gatherBeadsData(timeHours: number = 24) {
|
||||
// 1. Get overview stats
|
||||
const stats = await bd.stats();
|
||||
|
||||
// 2. Get in-progress work
|
||||
const inProgress = await bd.list({
|
||||
status: 'in_progress',
|
||||
limit: 10
|
||||
});
|
||||
|
||||
// 3. Get ready items (actionable)
|
||||
const ready = await bd.ready({ limit: 5 });
|
||||
|
||||
// 4. Get blocked items with dependencies
|
||||
const blocked = await bd.blocked();
|
||||
|
||||
// 5. Get recently closed (for velocity)
|
||||
const closed = await bd.list({
|
||||
status: 'closed',
|
||||
limit: 10
|
||||
});
|
||||
const recentlyClosed = filterByTime(closed, timeHours);
|
||||
|
||||
return { stats, inProgress, ready, blocked, recentlyClosed };
|
||||
}
|
||||
```
|
||||
|
||||
## Presentation Template
|
||||
|
||||
```
|
||||
📋 BEADS ISSUES
|
||||
{stats.total} total | {stats.open} open | {stats.in_progress} active | {stats.blocked} blocked
|
||||
|
||||
Ready to Work:
|
||||
{id}: {title} [{type}, {priority_label}]
|
||||
...
|
||||
|
||||
In Progress:
|
||||
{id}: {title}
|
||||
Status: {status} | Updated: {relative_time} | Assignee: {assignee}
|
||||
...
|
||||
|
||||
Blocked ({blocked.length}):
|
||||
{id}: {title}
|
||||
⛔ Blocked by: {blocking_ids}
|
||||
...
|
||||
|
||||
Recently Closed ({recentlyClosed.length}):
|
||||
✓ {id}: {title} — closed {relative_time}
|
||||
...
|
||||
```
|
||||
|
||||
### Priority Labels
|
||||
|
||||
| Priority | Label | Indicator |
|
||||
|----------|-------|-----------|
|
||||
| 1 | urgent | 🔴 |
|
||||
| 2 | high | 🟠 |
|
||||
| 3 | normal | 🟡 |
|
||||
| 4 | low | ⚪ |
|
||||
| 0 | unset | — |
|
||||
|
||||
### Status Indicators
|
||||
|
||||
| Status | Indicator |
|
||||
|--------|-----------|
|
||||
| open | ◯ |
|
||||
| in_progress | ◐ |
|
||||
| blocked | ⛔ |
|
||||
| closed | ✓ |
|
||||
|
||||
## Cross-Referencing
|
||||
|
||||
### With GitHub PRs
|
||||
|
||||
Match beads issue IDs in PR titles/branches:
|
||||
- PR title: "AG-123: Implement feature" → links to beads AG-123
|
||||
- Branch: `ag-123-feature` → links to beads AG-123
|
||||
|
||||
```typescript
|
||||
function linkPRToBeads(prTitle: string, beadsIssues: BeadsIssue[]) {
|
||||
const match = prTitle.match(/^([A-Z]+-\d+):/);
|
||||
if (match) {
|
||||
return beadsIssues.find(i => i.id === match[1]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### With Linear Issues
|
||||
|
||||
Beads `external_ref` field can store Linear issue URL:
|
||||
|
||||
```bash
|
||||
bd update AG-123 --external-ref="https://linear.app/team/issue/TEAM-456"
|
||||
```
|
||||
|
||||
Query: Check `external_ref` for Linear correlation.
|
||||
|
||||
### With Graphite Stacks
|
||||
|
||||
Match branch names to beads issues:
|
||||
- Branch: `ag-123-feature` → beads issue AG-123
|
||||
- Stack contains multiple branches → multiple linked issues
|
||||
|
||||
## Context Detection
|
||||
|
||||
Beads requires workspace context. Detect via:
|
||||
|
||||
```bash
|
||||
# Check if beads initialized
|
||||
ls .beads/issues.db 2>/dev/null && echo "beads available"
|
||||
|
||||
# Or via MCP
|
||||
bd where-am-i
|
||||
```
|
||||
|
||||
**Auto-detection**: Include beads in sitrep when `.beads/` directory exists in project root.
|
||||
|
||||
## MCP Tools Reference
|
||||
|
||||
When using beads via MCP server:
|
||||
|
||||
| Tool | Purpose |
|
||||
|------|---------|
|
||||
| `beads__stats` | Project metrics overview |
|
||||
| `beads__list` | Query issues with filters |
|
||||
| `beads__ready` | Unblocked, actionable items |
|
||||
| `beads__blocked` | Blocked items with dependencies |
|
||||
| `beads__show` | Single issue details |
|
||||
|
||||
**Context**: Call `beads__set_context` with workspace root before other operations.
|
||||
|
||||
## Error Handling
|
||||
|
||||
```typescript
|
||||
// Handle uninitialized beads
|
||||
try {
|
||||
const stats = await bd.stats();
|
||||
} catch (e) {
|
||||
if (e.message.includes('not initialized')) {
|
||||
// Skip beads section, note in output
|
||||
return { available: false, reason: 'Beads not initialized' };
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
```
|
||||
|
||||
**Common errors**:
|
||||
- "Beads not initialized" → `.beads/` doesn't exist
|
||||
- "No context set" → call `set_context` first
|
||||
- "Issue not found" → invalid issue ID
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Prioritize Ready Items**: Show unblocked work prominently — these are actionable now
|
||||
|
||||
2. **Highlight Blockers**: Blocked items with their dependencies help identify bottlenecks
|
||||
|
||||
3. **Time-Filter Thoughtfully**: Since filtering is client-side, fetch reasonable limits (20-50) then filter
|
||||
|
||||
4. **Cross-Reference PRs**: Link beads issues to PRs/branches when ID patterns match
|
||||
|
||||
5. **Show Velocity**: Recently closed items indicate progress, especially useful for standups
|
||||
|
||||
6. **Respect Priority**: Sort by priority within each section (urgent first)
|
||||
|
||||
7. **Assignee Context**: When user has assignee, highlight their work specifically
|
||||
|
||||
## Integration Points
|
||||
|
||||
| Source | Correlation | Use Case |
|
||||
|--------|-------------|----------|
|
||||
| GitHub PRs | Issue ID in title/branch | Link PRs to tracked work |
|
||||
| Graphite stacks | Branch naming | Show stack progress per issue |
|
||||
| Linear | external_ref field | Bridge local ↔ team tracking |
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Beads not initialized"**
|
||||
|
||||
```bash
|
||||
bd init # Initialize in project root
|
||||
bd init --prefix=PROJ # Custom prefix (e.g., PROJ-1)
|
||||
```
|
||||
|
||||
**"No issues found"**
|
||||
- Check workspace context: `bd where-am-i`
|
||||
- Verify `.beads/` exists in expected location
|
||||
|
||||
**"Wrong project context"**
|
||||
|
||||
```bash
|
||||
bd set-context /path/to/project # Set correct workspace
|
||||
```
|
||||
|
||||
**Stale data**
|
||||
- Beads data is local — always fresh
|
||||
- No caching concerns unlike remote APIs
|
||||
@@ -0,0 +1,579 @@
|
||||
# GitHub Integration
|
||||
|
||||
Tool-specific patterns for integrating GitHub PR status, CI checks, and review state into status reports.
|
||||
|
||||
## Overview
|
||||
|
||||
GitHub provides comprehensive PR metadata, CI/CD integration, and code review state. Status reports should extract actionable insights from PR state, check runs, and review decisions.
|
||||
|
||||
## Core Commands
|
||||
|
||||
### GitHub CLI (gh)
|
||||
|
||||
Primary tool for GitHub integration:
|
||||
|
||||
```bash
|
||||
# List PRs with full metadata
|
||||
gh pr list --json number,title,state,author,updatedAt,statusCheckRollup,reviewDecision
|
||||
|
||||
# Get specific PR details
|
||||
gh pr view 123 --json number,title,state,statusCheckRollup,reviews,comments
|
||||
|
||||
# Check run details
|
||||
gh pr checks 123
|
||||
|
||||
# Review status
|
||||
gh pr status
|
||||
```
|
||||
|
||||
### Repository Context
|
||||
|
||||
```bash
|
||||
# Get current repo info
|
||||
gh repo view --json nameWithOwner,defaultBranch
|
||||
|
||||
# Output: {"nameWithOwner": "owner/repo", "defaultBranch": "main"}
|
||||
```
|
||||
|
||||
## Data Gathering
|
||||
|
||||
### PR List with Metadata
|
||||
|
||||
```typescript
|
||||
interface GitHubPR {
|
||||
number: number;
|
||||
title: string;
|
||||
state: 'OPEN' | 'CLOSED' | 'MERGED';
|
||||
isDraft: boolean;
|
||||
author: { login: string };
|
||||
updatedAt: string;
|
||||
statusCheckRollup: {
|
||||
state: 'SUCCESS' | 'FAILURE' | 'PENDING' | 'EXPECTED';
|
||||
contexts: CheckContext[];
|
||||
};
|
||||
reviewDecision: 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' | null;
|
||||
}
|
||||
|
||||
async function fetchOpenPRs(): Promise<GitHubPR[]> {
|
||||
const result = await exec(
|
||||
'gh pr list --json number,title,state,isDraft,author,updatedAt,statusCheckRollup,reviewDecision --limit 100'
|
||||
);
|
||||
|
||||
return JSON.parse(result);
|
||||
}
|
||||
```
|
||||
|
||||
### CI Check Status
|
||||
|
||||
```typescript
|
||||
interface CheckContext {
|
||||
name: string;
|
||||
state: 'SUCCESS' | 'FAILURE' | 'PENDING' | 'EXPECTED';
|
||||
conclusion: 'SUCCESS' | 'FAILURE' | 'NEUTRAL' | 'CANCELLED' | 'SKIPPED' | null;
|
||||
targetUrl?: string;
|
||||
}
|
||||
|
||||
function analyzeCheckStatus(pr: GitHubPR): {
|
||||
passing: number;
|
||||
failing: number;
|
||||
pending: number;
|
||||
total: number;
|
||||
failedChecks: string[];
|
||||
} {
|
||||
const contexts = pr.statusCheckRollup?.contexts || [];
|
||||
|
||||
const passing = contexts.filter(c =>
|
||||
c.state === 'SUCCESS' || c.conclusion === 'SUCCESS'
|
||||
).length;
|
||||
|
||||
const failing = contexts.filter(c =>
|
||||
c.state === 'FAILURE' || c.conclusion === 'FAILURE'
|
||||
).length;
|
||||
|
||||
const pending = contexts.filter(c =>
|
||||
c.state === 'PENDING' || c.state === 'EXPECTED'
|
||||
).length;
|
||||
|
||||
const failedChecks = contexts
|
||||
.filter(c => c.state === 'FAILURE' || c.conclusion === 'FAILURE')
|
||||
.map(c => c.name);
|
||||
|
||||
return {
|
||||
passing,
|
||||
failing,
|
||||
pending,
|
||||
total: contexts.length,
|
||||
failedChecks
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Review State
|
||||
|
||||
```typescript
|
||||
interface ReviewSummary {
|
||||
approved: number;
|
||||
changesRequested: number;
|
||||
commented: number;
|
||||
pending: number;
|
||||
decision: 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' | 'NONE';
|
||||
}
|
||||
|
||||
function summarizeReviews(pr: GitHubPR): ReviewSummary {
|
||||
// reviewDecision is aggregate state from GitHub
|
||||
const decision = pr.reviewDecision || 'NONE';
|
||||
|
||||
// For detailed review counts, fetch full reviews:
|
||||
// gh pr view {number} --json reviews
|
||||
|
||||
return {
|
||||
decision,
|
||||
// These would come from detailed review fetch if needed
|
||||
approved: decision === 'APPROVED' ? 1 : 0,
|
||||
changesRequested: decision === 'CHANGES_REQUESTED' ? 1 : 0,
|
||||
commented: 0,
|
||||
pending: decision === 'REVIEW_REQUIRED' ? 1 : 0
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Time Filtering
|
||||
|
||||
Filter PRs by update time:
|
||||
|
||||
```typescript
|
||||
async function fetchRecentPRs(since: string): Promise<GitHubPR[]> {
|
||||
// Convert time constraint to Date
|
||||
const cutoffDate = parseTimeConstraint(since); // "-24h" → Date
|
||||
|
||||
// Fetch all open PRs
|
||||
const allPRs = await fetchOpenPRs();
|
||||
|
||||
// Filter by updatedAt
|
||||
return allPRs.filter(pr => {
|
||||
const updatedAt = new Date(pr.updatedAt);
|
||||
return updatedAt >= cutoffDate;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
Alternative: Use GitHub API search:
|
||||
|
||||
```bash
|
||||
# Search PRs updated since date
|
||||
gh pr list --search "updated:>2024-01-15"
|
||||
|
||||
# Search with multiple criteria
|
||||
gh pr list --search "is:open updated:>2024-01-15 -is:draft"
|
||||
```
|
||||
|
||||
## Presentation Templates
|
||||
|
||||
### PR Section
|
||||
|
||||
```
|
||||
🔀 PULL REQUESTS ({open_count} open, {recent_count} active)
|
||||
|
||||
PR #{number}: {title} [{state}]
|
||||
Author: {author} | Updated: {relative_time}
|
||||
CI: {ci_indicator} {passing}/{total} checks {failing_names}
|
||||
Reviews: {review_indicator} {review_summary}
|
||||
{blocker_indicator}
|
||||
{pr_url}
|
||||
```
|
||||
|
||||
### CI Status Indicators
|
||||
|
||||
```typescript
|
||||
function formatCIStatus(checkSummary: ReturnType<typeof analyzeCheckStatus>): string {
|
||||
const { passing, failing, pending, total, failedChecks } = checkSummary;
|
||||
|
||||
let indicator: string;
|
||||
if (failing > 0) {
|
||||
indicator = '✗';
|
||||
} else if (pending > 0) {
|
||||
indicator = '⏳';
|
||||
} else if (passing === total && total > 0) {
|
||||
indicator = '✓';
|
||||
} else {
|
||||
indicator = '○'; // No checks
|
||||
}
|
||||
|
||||
let status = `${indicator} ${passing}/${total} checks`;
|
||||
|
||||
if (failing > 0) {
|
||||
status += ` (failing: ${failedChecks.join(', ')})`;
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
```
|
||||
|
||||
### Review Status Indicators
|
||||
|
||||
```typescript
|
||||
function formatReviewStatus(reviewSummary: ReviewSummary): string {
|
||||
const { decision } = reviewSummary;
|
||||
|
||||
const indicators: Record<string, string> = {
|
||||
'APPROVED': '✓ Approved',
|
||||
'CHANGES_REQUESTED': '👀 Changes requested',
|
||||
'REVIEW_REQUIRED': '⏸ Awaiting review',
|
||||
'NONE': '○ No reviews'
|
||||
};
|
||||
|
||||
return indicators[decision] || '○ No reviews';
|
||||
}
|
||||
```
|
||||
|
||||
### Example Output
|
||||
|
||||
```
|
||||
🔀 PULL REQUESTS (3 open, 2 active in last 24h)
|
||||
|
||||
PR #156: Add authentication middleware [OPEN]
|
||||
Author: @alice | Updated: 3 hours ago
|
||||
CI: ✓ 4/4 checks passing
|
||||
Reviews: ✓ Approved
|
||||
https://github.com/owner/repo/pull/156
|
||||
|
||||
PR #155: Fix bug in user validation [OPEN]
|
||||
Author: @bob | Updated: 5 hours ago
|
||||
CI: ✗ 2/3 checks (failing: type-check, lint)
|
||||
Reviews: 👀 Changes requested
|
||||
◆ Blocker: Failing CI needs fixing
|
||||
https://github.com/owner/repo/pull/155
|
||||
|
||||
PR #154: Update dependencies [OPEN] 🏷️ DRAFT
|
||||
Author: @dependabot | Updated: 2 days ago
|
||||
CI: ⏳ 1/2 checks pending
|
||||
Reviews: ⏸ Awaiting review
|
||||
https://github.com/owner/repo/pull/154
|
||||
```
|
||||
|
||||
## Advanced Queries
|
||||
|
||||
### PR Comments and Activity
|
||||
|
||||
```bash
|
||||
# Get comment counts
|
||||
gh pr view 123 --json comments --jq '.comments | length'
|
||||
|
||||
# Recent activity (comments, reviews, commits)
|
||||
gh pr view 123 --json timelineItems --jq '.timelineItems[] | select(.createdAt > "2024-01-15")'
|
||||
```
|
||||
|
||||
### CI Run Details
|
||||
|
||||
```bash
|
||||
# Get detailed check run info
|
||||
gh run list --workflow=ci.yml --limit 10 --json status,conclusion,createdAt,displayTitle
|
||||
|
||||
# Download logs for failed runs
|
||||
gh run view {run_id} --log-failed
|
||||
```
|
||||
|
||||
### Cross-Repository Queries
|
||||
|
||||
For monorepos or multi-repo workflows:
|
||||
|
||||
```bash
|
||||
# Query PRs across org
|
||||
gh search prs --owner=org --state=open --json number,repository,title
|
||||
|
||||
# Filter by team
|
||||
gh search prs --owner=org --team=@org/team-name --state=open
|
||||
```
|
||||
|
||||
## Performance Optimization
|
||||
|
||||
### Batch Queries
|
||||
|
||||
Minimize API calls:
|
||||
|
||||
```typescript
|
||||
async function fetchPRsBatch(prNumbers: number[]): Promise<GitHubPR[]> {
|
||||
// Single gh pr list call with all metadata
|
||||
const allPRs = await fetchOpenPRs();
|
||||
|
||||
// Filter to requested PRs
|
||||
return allPRs.filter(pr => prNumbers.includes(pr.number));
|
||||
}
|
||||
```
|
||||
|
||||
### Caching
|
||||
|
||||
Cache PR data to avoid rate limits:
|
||||
|
||||
```typescript
|
||||
interface PRCache {
|
||||
timestamp: Date;
|
||||
prs: GitHubPR[];
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
function getCachedPRs(ttl = 300000): GitHubPR[] | null {
|
||||
// Cache for 5 minutes by default
|
||||
const cache = loadCache();
|
||||
if (cache && Date.now() - cache.timestamp.getTime() < ttl) {
|
||||
return cache.prs;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### Parallel Fetching
|
||||
|
||||
```typescript
|
||||
async function fetchCompletePRData(): Promise<PRData> {
|
||||
const [prs, repo, workflow_runs] = await Promise.all([
|
||||
fetchOpenPRs(),
|
||||
fetchRepoInfo(),
|
||||
fetchRecentWorkflowRuns()
|
||||
]);
|
||||
|
||||
return { prs, repo, workflow_runs };
|
||||
}
|
||||
```
|
||||
|
||||
## Cross-Referencing
|
||||
|
||||
### Link PRs to Branches
|
||||
|
||||
```typescript
|
||||
function linkPRsToBranches(prs: GitHubPR[], branches: string[]): Map<string, GitHubPR> {
|
||||
// Fetch branch info for each PR
|
||||
const prBranchMap = new Map<string, GitHubPR>();
|
||||
|
||||
for (const pr of prs) {
|
||||
// Get head ref (branch name) from PR
|
||||
const headRef = await exec(`gh pr view ${pr.number} --json headRefName --jq .headRefName`);
|
||||
prBranchMap.set(headRef.trim(), pr);
|
||||
}
|
||||
|
||||
return prBranchMap;
|
||||
}
|
||||
```
|
||||
|
||||
### Link PRs to Issues
|
||||
|
||||
```typescript
|
||||
function extractLinkedIssues(prBody: string): string[] {
|
||||
// Match: "Closes #123", "Fixes #456", "Resolves #789"
|
||||
const patterns = [
|
||||
/(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)s?\s+#(\d+)/gi,
|
||||
/#(\d+)/g // Generic issue references
|
||||
];
|
||||
|
||||
const issueNumbers: string[] = [];
|
||||
for (const pattern of patterns) {
|
||||
const matches = prBody.matchAll(pattern);
|
||||
for (const match of matches) {
|
||||
issueNumbers.push(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...new Set(issueNumbers)]; // Deduplicate
|
||||
}
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Authentication
|
||||
|
||||
```typescript
|
||||
async function ensureGitHubAuth(): Promise<boolean> {
|
||||
try {
|
||||
await exec('gh auth status');
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('GitHub authentication required. Run: gh auth login');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Rate Limiting
|
||||
|
||||
```typescript
|
||||
async function checkRateLimit(): Promise<{ remaining: number; resetAt: Date }> {
|
||||
const result = await exec('gh api rate_limit --jq .rate');
|
||||
const data = JSON.parse(result);
|
||||
|
||||
return {
|
||||
remaining: data.remaining,
|
||||
resetAt: new Date(data.reset * 1000)
|
||||
};
|
||||
}
|
||||
|
||||
async function withRateLimitCheck<T>(fn: () => Promise<T>): Promise<T> {
|
||||
const limit = await checkRateLimit();
|
||||
|
||||
if (limit.remaining < 10) {
|
||||
const waitTime = limit.resetAt.getTime() - Date.now();
|
||||
console.warn(`Rate limit low (${limit.remaining}). Resets in ${waitTime}ms`);
|
||||
}
|
||||
|
||||
return fn();
|
||||
}
|
||||
```
|
||||
|
||||
### Repository Detection
|
||||
|
||||
```typescript
|
||||
async function detectGitHubRepo(): Promise<string | null> {
|
||||
try {
|
||||
const result = await exec('gh repo view --json nameWithOwner --jq .nameWithOwner');
|
||||
return result.trim();
|
||||
} catch (error) {
|
||||
// Not in a GitHub repo or gh not configured
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
### With Graphite (see graphite.md)
|
||||
|
||||
Enrich Graphite stack with GitHub PR details:
|
||||
|
||||
```typescript
|
||||
async function enrichGraphiteStackWithGitHub(stack: StackNode[]): Promise<void> {
|
||||
const prNumbers = stack.map(n => n.prNumber).filter(Boolean);
|
||||
const prs = await fetchPRsBatch(prNumbers);
|
||||
|
||||
for (const node of stack) {
|
||||
const pr = prs.find(p => p.number === node.prNumber);
|
||||
if (pr) {
|
||||
node.githubPR = pr;
|
||||
node.ciStatus = analyzeCheckStatus(pr);
|
||||
node.reviewStatus = summarizeReviews(pr);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With CI/CD Tools
|
||||
|
||||
```typescript
|
||||
async function fetchWorkflowRuns(since: string): Promise<WorkflowRun[]> {
|
||||
const cutoff = parseTimeConstraint(since);
|
||||
const cutoffISO = cutoff.toISOString();
|
||||
|
||||
const result = await exec(
|
||||
`gh run list --json status,conclusion,createdAt,displayTitle,workflowName,url ` +
|
||||
`--created ">=${cutoffISO}" --limit 50`
|
||||
);
|
||||
|
||||
return JSON.parse(result);
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Minimize API Calls
|
||||
|
||||
- Use `--json` flag to fetch all needed fields in single call
|
||||
- Cache results with appropriate TTL
|
||||
- Use `gh pr list` once, filter in memory
|
||||
|
||||
### Handle Missing Data
|
||||
|
||||
```typescript
|
||||
function safelyAccessPRData(pr: GitHubPR): {
|
||||
hasChecks: boolean;
|
||||
hasReviews: boolean;
|
||||
isComplete: boolean;
|
||||
} {
|
||||
return {
|
||||
hasChecks: Boolean(pr.statusCheckRollup?.contexts?.length),
|
||||
hasReviews: Boolean(pr.reviewDecision),
|
||||
isComplete: Boolean(pr.statusCheckRollup && pr.reviewDecision)
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Relative Timestamps
|
||||
|
||||
```typescript
|
||||
function formatRelativeTime(isoDate: string): string {
|
||||
const date = new Date(isoDate);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 60) return `${minutes} minutes ago`;
|
||||
if (hours < 24) return `${hours} hours ago`;
|
||||
return `${days} days ago`;
|
||||
}
|
||||
```
|
||||
|
||||
## CLI Reference
|
||||
|
||||
Essential GitHub CLI commands:
|
||||
|
||||
```bash
|
||||
# PR listing
|
||||
gh pr list # All open PRs
|
||||
gh pr list --limit 100 # More PRs
|
||||
gh pr list --json {fields} # Structured output
|
||||
gh pr list --search "query" # Search PRs
|
||||
|
||||
# PR details
|
||||
gh pr view {number} # Human-readable
|
||||
gh pr view {number} --json {fields} # Structured
|
||||
gh pr checks {number} # CI checks
|
||||
gh pr diff {number} # Show diff
|
||||
|
||||
# Repository info
|
||||
gh repo view # Current repo
|
||||
gh repo view --json {fields} # Structured
|
||||
|
||||
# API access
|
||||
gh api /repos/{owner}/{repo}/pulls # Direct API
|
||||
gh api rate_limit # Check limits
|
||||
|
||||
# Search
|
||||
gh search prs {query} # Search PRs
|
||||
gh search issues {query} # Search issues
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### gh CLI Not Found
|
||||
|
||||
```bash
|
||||
# Install GitHub CLI
|
||||
# macOS: brew install gh
|
||||
# Linux: See https://github.com/cli/cli#installation
|
||||
|
||||
# Verify installation
|
||||
gh --version
|
||||
```
|
||||
|
||||
### Not Authenticated
|
||||
|
||||
```bash
|
||||
# Login to GitHub
|
||||
gh auth login
|
||||
|
||||
# Check status
|
||||
gh auth status
|
||||
```
|
||||
|
||||
### Wrong Repository Context
|
||||
|
||||
```bash
|
||||
# Verify current repo
|
||||
gh repo view
|
||||
|
||||
# Switch to different repo
|
||||
cd /path/to/repo
|
||||
|
||||
# Or specify repo explicitly
|
||||
gh pr list --repo owner/repo
|
||||
```
|
||||
@@ -0,0 +1,428 @@
|
||||
# Graphite Integration
|
||||
|
||||
Tool-specific patterns for integrating Graphite (gt) stack visualization and PR management into status reports.
|
||||
|
||||
## Overview
|
||||
|
||||
Graphite provides stack-aware version control with visual branch hierarchies and integrated PR management. Status reports should leverage stack structure for context-rich presentation.
|
||||
|
||||
## Core Commands
|
||||
|
||||
### Stack Visualization
|
||||
|
||||
```bash
|
||||
# Get visual tree of stacked branches
|
||||
gt log
|
||||
|
||||
# Output includes:
|
||||
# - Branch hierarchy (parent/child relationships)
|
||||
# - PR status per branch
|
||||
# - Commit counts
|
||||
# - Current branch indicator (◆)
|
||||
# - Branch states (needs restack, needs submit, ready to merge)
|
||||
```
|
||||
|
||||
**Example Output**:
|
||||
|
||||
```
|
||||
◆ feature/auth-refactor (3) - #123 ✓ Ready to merge
|
||||
├─ feature/add-jwt (2) - #122 ⏳ In progress
|
||||
└─ feature/update-middleware (1) - #121 ⏸ Draft
|
||||
```
|
||||
|
||||
### Branch State
|
||||
|
||||
```bash
|
||||
# Get current stack state as JSON
|
||||
gt stack --json
|
||||
|
||||
# Returns:
|
||||
# - Branch metadata (name, parent, children)
|
||||
# - PR associations
|
||||
# - Commit SHAs and messages
|
||||
# - Sync status (ahead/behind trunk)
|
||||
```
|
||||
|
||||
### PR Submission Status
|
||||
|
||||
```bash
|
||||
# Check if branches need submission
|
||||
gt stack
|
||||
|
||||
# Shows branches with:
|
||||
# - "needs submit" → changes not pushed to PR
|
||||
# - "needs restack" → parent branch updated
|
||||
# - "ready to merge" → approved, passing CI
|
||||
```
|
||||
|
||||
## Data Gathering
|
||||
|
||||
### Stack Structure
|
||||
|
||||
Extract hierarchical branch relationships:
|
||||
|
||||
```typescript
|
||||
interface StackNode {
|
||||
branch: string;
|
||||
prNumber?: number;
|
||||
prStatus?: 'draft' | 'open' | 'ready' | 'merged';
|
||||
commitCount: number;
|
||||
parent?: string;
|
||||
children: string[];
|
||||
isCurrent: boolean;
|
||||
needsRestack: boolean;
|
||||
needsSubmit: boolean;
|
||||
}
|
||||
|
||||
async function getStackStructure(): Promise<StackNode[]> {
|
||||
// Parse gt log output or gt stack --json
|
||||
const output = await exec('gt log');
|
||||
|
||||
// Extract:
|
||||
// - Branch names and hierarchy
|
||||
// - PR numbers (from "#123" markers)
|
||||
// - Status indicators (✓ ⏳ ⏸)
|
||||
// - Commit counts (from "(N)" markers)
|
||||
// - Current branch (◆ marker)
|
||||
|
||||
return parseStackTree(output);
|
||||
}
|
||||
```
|
||||
|
||||
### PR Integration
|
||||
|
||||
Graphite automatically links branches to PRs:
|
||||
|
||||
```typescript
|
||||
// Get PR metadata for stack
|
||||
async function getStackPRs(branches: string[]): Promise<PRMetadata[]> {
|
||||
// Option 1: Parse from gt log (includes basic status)
|
||||
// Option 2: Query GitHub directly with PR numbers
|
||||
// Option 3: Use gt pr status --json (if available)
|
||||
|
||||
const prNumbers = branches
|
||||
.map(b => extractPRNumber(b))
|
||||
.filter(Boolean);
|
||||
|
||||
// Fetch details from GitHub (see github.md)
|
||||
return fetchPRDetails(prNumbers);
|
||||
}
|
||||
```
|
||||
|
||||
## Time Filtering
|
||||
|
||||
Graphite doesn't natively support time filtering, so filter results:
|
||||
|
||||
```typescript
|
||||
async function getRecentStackActivity(since: string): Promise<StackActivity> {
|
||||
// Get full stack
|
||||
const stack = await getStackStructure();
|
||||
|
||||
// Parse time constraint
|
||||
const cutoff = parseTimeConstraint(since); // "-24h" → Date
|
||||
|
||||
// Filter by git commit timestamps
|
||||
for (const node of stack) {
|
||||
const commits = await exec(`git log ${node.branch} --since="${cutoff}" --format="%H %s %cr"`);
|
||||
node.recentCommits = parseCommits(commits);
|
||||
}
|
||||
|
||||
// Only show branches with activity
|
||||
return stack.filter(n => n.recentCommits.length > 0);
|
||||
}
|
||||
```
|
||||
|
||||
## Presentation Templates
|
||||
|
||||
### Stack Tree Format
|
||||
|
||||
```
|
||||
📊 GRAPHITE STACK
|
||||
{current_branch_name}
|
||||
|
||||
{tree visualization from gt log}
|
||||
|
||||
Stack Summary:
|
||||
Branches: {total} ({open} with PRs)
|
||||
Ready to merge: {ready_count}
|
||||
Needs attention: {needs_restack + needs_submit}
|
||||
```
|
||||
|
||||
### Stack-Aware PR Grouping
|
||||
|
||||
Organize PRs by stack position (bottom to top):
|
||||
|
||||
```
|
||||
🔀 PULL REQUESTS (Stack-Aware)
|
||||
|
||||
Stack: {stack_name}
|
||||
├─ PR #123: [feature/auth-refactor] Refactor authentication
|
||||
│ CI: ✓ 3/3 passing | Reviews: ✓ 2 approved
|
||||
│ Updated: 3 hours ago
|
||||
│ └─ Ready to merge ✓
|
||||
│
|
||||
├─ PR #122: [feature/add-jwt] Add JWT token support
|
||||
│ CI: ⏳ 2/3 passing | Reviews: 👀 1 change requested
|
||||
│ Updated: 5 hours ago
|
||||
│ └─ Depends on: PR #121
|
||||
│
|
||||
└─ PR #121: [feature/update-middleware] Update auth middleware
|
||||
CI: ✗ 1/3 failing | Reviews: ⏸ No reviews
|
||||
Updated: 1 day ago
|
||||
└─ Blocker: CI failing ◆◆
|
||||
```
|
||||
|
||||
### Attention Indicators
|
||||
|
||||
Highlight stack-specific issues:
|
||||
|
||||
```
|
||||
⚠️ STACK ATTENTION NEEDED
|
||||
◆◆ PR #121: Blocking entire stack (failing CI)
|
||||
◆ Branch feature/add-jwt: Needs restack (parent updated)
|
||||
◇ Branch feature/auth-refactor: Needs submit (local changes)
|
||||
```
|
||||
|
||||
## Cross-Referencing
|
||||
|
||||
### Link Stack to Issues
|
||||
|
||||
Match issue IDs in PR titles/bodies:
|
||||
|
||||
```typescript
|
||||
function linkStackToIssues(stack: StackNode[], issues: Issue[]): void {
|
||||
for (const node of stack) {
|
||||
// Extract issue references from PR title
|
||||
// Pattern: "BLZ-123: Feature title" or "[BLZ-123] Feature title"
|
||||
const issueKeys = extractIssueKeys(node.prTitle);
|
||||
|
||||
// Find matching issues
|
||||
node.relatedIssues = issues.filter(i => issueKeys.includes(i.key));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Dependency Tracking
|
||||
|
||||
Show blocked/blocking relationships:
|
||||
|
||||
```typescript
|
||||
interface StackDependencies {
|
||||
branch: string;
|
||||
blockedBy: string[]; // Parent branches not merged
|
||||
blocking: string[]; // Child branches waiting
|
||||
}
|
||||
|
||||
function analyzeStackDependencies(stack: StackNode[]): StackDependencies[] {
|
||||
return stack.map(node => ({
|
||||
branch: node.branch,
|
||||
blockedBy: node.parent && !isReadyToMerge(node.parent) ? [node.parent] : [],
|
||||
blocking: node.children.filter(child => isReadyToMerge(node) && !isReadyToMerge(child))
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Efficient Queries
|
||||
|
||||
Minimize git/Graphite calls:
|
||||
1. Single `gt log` for stack structure
|
||||
2. Single `git log --all --since` for commit history
|
||||
3. Batch PR queries to GitHub (see github.md)
|
||||
|
||||
### State Caching
|
||||
|
||||
Cache stack state to avoid repeated parsing:
|
||||
|
||||
```typescript
|
||||
interface StackCache {
|
||||
timestamp: Date;
|
||||
stack: StackNode[];
|
||||
ttl: number; // milliseconds
|
||||
}
|
||||
|
||||
function getCachedStack(ttl = 60000): StackNode[] | null {
|
||||
const cache = loadCache();
|
||||
if (cache && Date.now() - cache.timestamp.getTime() < ttl) {
|
||||
return cache.stack;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
### Error Handling
|
||||
|
||||
Handle common Graphite errors:
|
||||
|
||||
```typescript
|
||||
try {
|
||||
const stack = await exec('gt log');
|
||||
} catch (error) {
|
||||
if (error.message.includes('not a git repository')) {
|
||||
return null; // Gracefully skip Graphite section
|
||||
}
|
||||
if (error.message.includes('graphite not initialized')) {
|
||||
// Suggest: gt repo init
|
||||
return null;
|
||||
}
|
||||
throw error; // Unexpected error
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
### With GitHub (see github.md)
|
||||
|
||||
Combine Graphite stack structure with GitHub PR details:
|
||||
|
||||
```typescript
|
||||
async function enrichStackWithGitHub(stack: StackNode[]): Promise<void> {
|
||||
const prNumbers = stack
|
||||
.map(n => n.prNumber)
|
||||
.filter(Boolean);
|
||||
|
||||
const prDetails = await fetchGitHubPRs(prNumbers); // See github.md
|
||||
|
||||
for (const node of stack) {
|
||||
const pr = prDetails.find(p => p.number === node.prNumber);
|
||||
if (pr) {
|
||||
node.ciStatus = pr.ciStatus;
|
||||
node.reviewStatus = pr.reviewStatus;
|
||||
node.updatedAt = pr.updatedAt;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Linear (see linear.md)
|
||||
|
||||
Link Linear issues to stack branches:
|
||||
|
||||
```typescript
|
||||
async function linkStackToLinear(stack: StackNode[]): Promise<void> {
|
||||
// Extract all issue keys from PR titles
|
||||
const issueKeys = stack
|
||||
.flatMap(n => extractIssueKeys(n.prTitle || ''))
|
||||
.filter(Boolean);
|
||||
|
||||
// Fetch Linear issues
|
||||
const issues = await fetchLinearIssues({ keys: issueKeys });
|
||||
|
||||
// Annotate stack nodes
|
||||
for (const node of stack) {
|
||||
const keys = extractIssueKeys(node.prTitle || '');
|
||||
node.linearIssues = issues.filter(i => keys.includes(i.identifier));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Stack Health Score
|
||||
|
||||
Calculate stack quality metrics:
|
||||
|
||||
```typescript
|
||||
interface StackHealth {
|
||||
score: number; // 0-100
|
||||
issues: string[];
|
||||
readyToMerge: number;
|
||||
needsWork: number;
|
||||
}
|
||||
|
||||
function calculateStackHealth(stack: StackNode[]): StackHealth {
|
||||
let score = 100;
|
||||
const issues: string[] = [];
|
||||
|
||||
const needsRestack = stack.filter(n => n.needsRestack).length;
|
||||
const needsSubmit = stack.filter(n => n.needsSubmit).length;
|
||||
const failingCI = stack.filter(n => n.ciStatus === 'failing').length;
|
||||
const readyToMerge = stack.filter(n => n.prStatus === 'ready').length;
|
||||
|
||||
score -= needsRestack * 10; // -10 per restack needed
|
||||
score -= needsSubmit * 5; // -5 per submit needed
|
||||
score -= failingCI * 20; // -20 per failing CI
|
||||
|
||||
if (needsRestack) issues.push(`${needsRestack} branches need restack`);
|
||||
if (needsSubmit) issues.push(`${needsSubmit} branches need submit`);
|
||||
if (failingCI) issues.push(`${failingCI} PRs with failing CI`);
|
||||
|
||||
return {
|
||||
score: Math.max(0, score),
|
||||
issues,
|
||||
readyToMerge,
|
||||
needsWork: needsRestack + needsSubmit + failingCI
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### Stack Timeline
|
||||
|
||||
Show activity timeline across stack:
|
||||
|
||||
```
|
||||
📅 STACK TIMELINE (Last 24 hours)
|
||||
|
||||
2 hours ago │ PR #123 approved by @reviewer
|
||||
3 hours ago │ feature/auth-refactor: Pushed 2 commits
|
||||
5 hours ago │ PR #122: CI checks passing
|
||||
1 day ago │ feature/add-jwt: Created PR
|
||||
```
|
||||
|
||||
## CLI Reference
|
||||
|
||||
Essential Graphite commands for status reporting:
|
||||
|
||||
```bash
|
||||
# Stack visualization
|
||||
gt log # Visual tree
|
||||
gt log --short # Compact format
|
||||
gt log --json # Machine-readable
|
||||
|
||||
# Stack state
|
||||
gt stack # Current stack info
|
||||
gt stack --json # Structured output
|
||||
|
||||
# Branch operations (for context)
|
||||
gt upstack # Show branches above
|
||||
gt downstack # Show branches below
|
||||
|
||||
# PR operations (for context)
|
||||
gt pr status # PR status for stack
|
||||
gt submit --dry-run # Preview what would be submitted
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Stack Not Showing
|
||||
|
||||
```bash
|
||||
# Verify Graphite initialized
|
||||
gt repo init
|
||||
|
||||
# Verify on a branch
|
||||
git branch
|
||||
|
||||
# Check for trunk configuration
|
||||
gt repo --show
|
||||
```
|
||||
|
||||
### PR Associations Missing
|
||||
|
||||
```bash
|
||||
# PRs might not be associated with branches
|
||||
# Check with:
|
||||
gt pr status
|
||||
|
||||
# Re-associate if needed:
|
||||
gt pr submit
|
||||
```
|
||||
|
||||
### Performance Issues
|
||||
|
||||
Large stacks (>20 branches) can slow down:
|
||||
- Cache `gt log` output
|
||||
- Limit depth with `gt log --depth 10`
|
||||
- Filter to relevant branches only
|
||||
- Consider pagination for display
|
||||
@@ -0,0 +1,161 @@
|
||||
# Implementation Patterns
|
||||
|
||||
Technical patterns for status report generation.
|
||||
|
||||
## Parallel Queries
|
||||
|
||||
Execute source queries concurrently:
|
||||
|
||||
```typescript
|
||||
const [vcsData, prData, issueData, ciData] = await Promise.allSettled([
|
||||
fetchVCSState(timeFilter),
|
||||
fetchPRStatus(timeFilter),
|
||||
fetchIssues(timeFilter),
|
||||
fetchCIStatus(timeFilter)
|
||||
]);
|
||||
|
||||
// Handle each result (success or failure)
|
||||
// Skip sections where source unavailable
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
Graceful degradation:
|
||||
- Source unavailable → skip section, note in output
|
||||
- Partial data → show available, note gaps
|
||||
- API rate limits → use cached data, note staleness
|
||||
- Auth failures → prompt for credentials or skip
|
||||
|
||||
## Caching Strategy
|
||||
|
||||
For expensive queries:
|
||||
- Cache with timestamp
|
||||
- Reuse if fresh (< 5 min)
|
||||
- Allow bypass with flag
|
||||
- Clear on explicit refresh
|
||||
|
||||
## Scripts
|
||||
|
||||
The `scripts/` directory contains Bun scripts for data gathering:
|
||||
|
||||
```
|
||||
scripts/
|
||||
├── sitrep.ts # Entry point - orchestrates gatherers
|
||||
├── gatherers/
|
||||
│ ├── graphite.ts # Graphite stack data
|
||||
│ ├── github.ts # GitHub PRs, CI status
|
||||
│ ├── linear.ts # Linear issues (via Claude CLI headless)
|
||||
│ └── beads.ts # Beads local issues
|
||||
└── lib/
|
||||
├── time.ts # Time parsing utilities
|
||||
└── types.ts # Shared type definitions
|
||||
```
|
||||
|
||||
**Usage**:
|
||||
|
||||
```bash
|
||||
./scripts/sitrep.ts # All sources, 24h default
|
||||
./scripts/sitrep.ts -t 7d # All sources, last 7 days
|
||||
./scripts/sitrep.ts -s github,beads # Specific sources only
|
||||
./scripts/sitrep.ts --format=text # Human-readable output
|
||||
```
|
||||
|
||||
**Output formats**: `json` (default, structured) | `text` (human-readable)
|
||||
|
||||
**Benefits**:
|
||||
- Single command, parallel gathering
|
||||
- Graceful degradation
|
||||
- Consistent JSON schema
|
||||
- Reduces agent tool calls 80%+
|
||||
|
||||
## Extensibility
|
||||
|
||||
### Adding New Sources
|
||||
|
||||
1. Create reference doc in `references/`
|
||||
2. Define data schema
|
||||
3. Implement query function with time filter
|
||||
4. Add aggregation logic
|
||||
5. Design presentation template
|
||||
6. Update workflow docs
|
||||
|
||||
### Custom Aggregations
|
||||
|
||||
Optional sections when data available:
|
||||
- Velocity metrics (PRs merged/day)
|
||||
- Team activity (commits by author)
|
||||
- Quality indicators (test coverage trends)
|
||||
- Deployment frequency
|
||||
|
||||
### Tool-Specific Docs
|
||||
|
||||
Reference documents should cover:
|
||||
- Optimal CLI/API calls
|
||||
- Response parsing
|
||||
- Rate limit handling
|
||||
- Auth patterns
|
||||
- Caching recommendations
|
||||
|
||||
## Context Awareness
|
||||
|
||||
Map repos to relevant filters:
|
||||
|
||||
```json
|
||||
{
|
||||
"mappings": [
|
||||
{
|
||||
"path": "/absolute/path/to/repo",
|
||||
"filters": {
|
||||
"issues": { "team": "TEAM-ID" },
|
||||
"labels": ["repo-name"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/path/with/*",
|
||||
"pattern": true,
|
||||
"filters": {
|
||||
"issues": { "project": "PROJECT-ID" }
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaults": {
|
||||
"time_period": "7d",
|
||||
"issue_limit": 10,
|
||||
"pr_limit": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Lookup strategy**:
|
||||
1. Exact path match
|
||||
2. Pattern match (wildcards)
|
||||
3. Repo name extraction
|
||||
4. Default filters
|
||||
|
||||
**Config location**: `~/.config/claude/status-reporting/config.json`
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### Sequential Queries
|
||||
|
||||
**Problem**: Waiting for each source before next
|
||||
**Why fails**: Slow, blocks on failures
|
||||
**Instead**: `Promise.allSettled()` for parallel
|
||||
|
||||
### Rigid Source Requirements
|
||||
|
||||
**Problem**: Failing if expected source missing
|
||||
**Why fails**: Breaks in different environments
|
||||
**Instead**: Detect available, skip unavailable
|
||||
|
||||
### Absolute Timestamps Only
|
||||
|
||||
**Problem**: Raw dates without context
|
||||
**Why fails**: Hard to scan for recency
|
||||
**Instead**: Relative ("2 hours ago") with absolute in detail
|
||||
|
||||
### Unstructured Output
|
||||
|
||||
**Problem**: Dumping all data without organization
|
||||
**Why fails**: Not scannable, misses insights
|
||||
**Instead**: Templates with hierarchy and indicators
|
||||
@@ -0,0 +1,669 @@
|
||||
# Linear Integration
|
||||
|
||||
Tool-specific patterns for integrating Linear issue tracking into status reports via the **streamlinear MCP server** (`github:obra/streamlinear`).
|
||||
|
||||
> **Important**: This guide is specifically for the streamlinear MCP, not the official Linear MCP. The streamlinear server uses a single `mcp__linear__linear` tool with action-based dispatch rather than separate tools per operation.
|
||||
|
||||
## Overview
|
||||
|
||||
Linear provides issue tracking with team-based organization, project management, and rich metadata. Status reports should surface recently active issues relevant to current work context.
|
||||
|
||||
## Streamlinear MCP Tool
|
||||
|
||||
All Linear operations go through a single tool with an `action` parameter:
|
||||
|
||||
```typescript
|
||||
// Search your active issues
|
||||
await mcp__linear__linear({
|
||||
action: 'search'
|
||||
});
|
||||
|
||||
// Search with text query
|
||||
await mcp__linear__linear({
|
||||
action: 'search',
|
||||
query: 'authentication bug'
|
||||
});
|
||||
|
||||
// Search with filters
|
||||
await mcp__linear__linear({
|
||||
action: 'search',
|
||||
query: {
|
||||
team: 'BLZ',
|
||||
state: 'In Progress',
|
||||
assignee: 'me'
|
||||
}
|
||||
});
|
||||
|
||||
// Get issue details
|
||||
await mcp__linear__linear({
|
||||
action: 'get',
|
||||
id: 'BLZ-123' // Also accepts URLs or UUIDs
|
||||
});
|
||||
|
||||
// Update issue
|
||||
await mcp__linear__linear({
|
||||
action: 'update',
|
||||
id: 'BLZ-123',
|
||||
state: 'Done'
|
||||
});
|
||||
|
||||
// Add comment
|
||||
await mcp__linear__linear({
|
||||
action: 'comment',
|
||||
id: 'BLZ-123',
|
||||
body: 'Fixed in commit abc123'
|
||||
});
|
||||
|
||||
// Create issue
|
||||
await mcp__linear__linear({
|
||||
action: 'create',
|
||||
title: 'Bug title',
|
||||
team: 'BLZ',
|
||||
body: 'Description here',
|
||||
priority: 2
|
||||
});
|
||||
|
||||
// Raw GraphQL for advanced queries
|
||||
await mcp__linear__linear({
|
||||
action: 'graphql',
|
||||
graphql: 'query { teams { nodes { id key name } } }'
|
||||
});
|
||||
```
|
||||
|
||||
## Action Reference
|
||||
|
||||
| Action | Purpose | Key Parameters |
|
||||
|--------|---------|----------------|
|
||||
| `search` | Find issues | `query` (string or object with filters) |
|
||||
| `get` | Issue details | `id` (identifier, URL, or UUID) |
|
||||
| `update` | Change issue | `id`, `state`, `priority`, `assignee`, `labels` |
|
||||
| `comment` | Add comment | `id`, `body` |
|
||||
| `create` | New issue | `title`, `team`, `body`, `priority`, `labels` |
|
||||
| `graphql` | Raw queries | `graphql`, `variables` |
|
||||
| `help` | Full docs | (none) |
|
||||
|
||||
## Priority Values
|
||||
|
||||
| Value | Meaning |
|
||||
|-------|---------|
|
||||
| 0 | None |
|
||||
| 1 | Urgent |
|
||||
| 2 | High |
|
||||
| 3 | Medium |
|
||||
| 4 | Low |
|
||||
|
||||
## Data Gathering
|
||||
|
||||
### Issue Listing
|
||||
|
||||
```typescript
|
||||
interface LinearIssue {
|
||||
identifier: string; // "BLZ-123"
|
||||
title: string;
|
||||
state: {
|
||||
name: string; // "In Progress", "Done", etc.
|
||||
type: string; // "started", "completed", etc.
|
||||
};
|
||||
priority: number; // 0-4 (0=none, 1=urgent, 2=high, 3=normal, 4=low)
|
||||
assignee?: {
|
||||
name: string;
|
||||
email: string;
|
||||
};
|
||||
labels: Array<{
|
||||
name: string;
|
||||
color: string;
|
||||
}>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
async function fetchTeamIssues(teamKey: string): Promise<LinearIssue[]> {
|
||||
const result = await mcp__linear__linear({
|
||||
action: 'search',
|
||||
query: { team: teamKey }
|
||||
});
|
||||
|
||||
return result.issues;
|
||||
}
|
||||
|
||||
async function fetchMyActiveIssues(): Promise<LinearIssue[]> {
|
||||
const result = await mcp__linear__linear({
|
||||
action: 'search'
|
||||
});
|
||||
|
||||
return result.issues;
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Queries with GraphQL
|
||||
|
||||
For complex filtering not supported by the search action, use GraphQL:
|
||||
|
||||
```typescript
|
||||
// Get all teams
|
||||
async function listTeams(): Promise<Array<{id: string, key: string, name: string}>> {
|
||||
const result = await mcp__linear__linear({
|
||||
action: 'graphql',
|
||||
graphql: 'query { teams { nodes { id key name } } }'
|
||||
});
|
||||
|
||||
return result.teams.nodes;
|
||||
}
|
||||
|
||||
// Get issues updated in last N days across all teams
|
||||
async function fetchRecentIssues(daysBack: number = 7): Promise<LinearIssue[]> {
|
||||
const result = await mcp__linear__linear({
|
||||
action: 'graphql',
|
||||
graphql: `
|
||||
query {
|
||||
viewer {
|
||||
assignedIssues(
|
||||
filter: { state: { type: { nin: ["completed", "canceled"] } } }
|
||||
first: 30
|
||||
orderBy: updatedAt
|
||||
) {
|
||||
nodes {
|
||||
identifier
|
||||
title
|
||||
state { name type }
|
||||
team { key }
|
||||
priority
|
||||
updatedAt
|
||||
url
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
});
|
||||
|
||||
return result.viewer.assignedIssues.nodes;
|
||||
}
|
||||
|
||||
// Filter by state type
|
||||
async function fetchIssuesByStateType(
|
||||
stateType: 'unstarted' | 'started' | 'completed' | 'canceled'
|
||||
): Promise<LinearIssue[]> {
|
||||
const result = await mcp__linear__linear({
|
||||
action: 'graphql',
|
||||
graphql: `
|
||||
query($stateType: String!) {
|
||||
issues(
|
||||
filter: { state: { type: { eq: $stateType } } }
|
||||
first: 50
|
||||
) {
|
||||
nodes {
|
||||
identifier
|
||||
title
|
||||
state { name type }
|
||||
team { key }
|
||||
priority
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: { stateType }
|
||||
});
|
||||
|
||||
return result.issues.nodes;
|
||||
}
|
||||
```
|
||||
|
||||
### Context-Aware Filtering
|
||||
|
||||
Map repository to Linear team/project:
|
||||
|
||||
```typescript
|
||||
interface LinearContext {
|
||||
filterBy: 'team' | 'project' | 'query';
|
||||
team?: string; // Team key (e.g., "BLZ")
|
||||
project?: string;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
interface RepoMapping {
|
||||
path: string;
|
||||
pattern?: boolean; // If true, path supports wildcards
|
||||
linear: LinearContext;
|
||||
}
|
||||
|
||||
interface LinearConfig {
|
||||
mappings: RepoMapping[];
|
||||
defaults: {
|
||||
daysBack: number;
|
||||
limit: number;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
Example configuration:
|
||||
|
||||
```json
|
||||
{
|
||||
"mappings": [
|
||||
{
|
||||
"path": "/Users/mg/Developer/outfitter/blz",
|
||||
"linear": {
|
||||
"filterBy": "team",
|
||||
"team": "BLZ"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "/Users/mg/Developer/*",
|
||||
"pattern": true,
|
||||
"linear": {
|
||||
"filterBy": "query",
|
||||
"query": "outfitter"
|
||||
}
|
||||
}
|
||||
],
|
||||
"defaults": {
|
||||
"daysBack": 7,
|
||||
"limit": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Context Resolution
|
||||
|
||||
```typescript
|
||||
async function resolveLinearContext(cwd: string, config: LinearConfig): Promise<LinearContext | null> {
|
||||
// Try exact path match first
|
||||
for (const mapping of config.mappings) {
|
||||
if (!mapping.pattern && mapping.path === cwd) {
|
||||
return mapping.linear;
|
||||
}
|
||||
}
|
||||
|
||||
// Try pattern match
|
||||
for (const mapping of config.mappings) {
|
||||
if (mapping.pattern) {
|
||||
const regex = new RegExp('^' + mapping.path.replace(/\*/g, '.*') + '$');
|
||||
if (regex.test(cwd)) {
|
||||
return mapping.linear;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: query-based search using repo name
|
||||
const repoName = await getRepoName(cwd);
|
||||
if (repoName) {
|
||||
return {
|
||||
filterBy: 'query',
|
||||
query: repoName.split('/')[1] // Extract short name from "owner/repo"
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
```
|
||||
|
||||
## Presentation Templates
|
||||
|
||||
### Issue Section
|
||||
|
||||
```
|
||||
LINEAR ISSUES (Recent Activity - {team_name})
|
||||
{count} issues updated in last {period}
|
||||
|
||||
{issue_identifier}: {title} [{state}]
|
||||
Priority: {priority_label} | Assignee: {assignee_name}
|
||||
Labels: {label_list}
|
||||
Updated: {relative_time}
|
||||
{issue_url}
|
||||
```
|
||||
|
||||
### Priority Formatting
|
||||
|
||||
```typescript
|
||||
function formatPriority(priority: number): string {
|
||||
const labels: Record<number, string> = {
|
||||
0: 'None',
|
||||
1: 'Urgent',
|
||||
2: 'High',
|
||||
3: 'Medium',
|
||||
4: 'Low'
|
||||
};
|
||||
|
||||
return labels[priority] || 'None';
|
||||
}
|
||||
```
|
||||
|
||||
### Example Output
|
||||
|
||||
```
|
||||
LINEAR ISSUES (Recent Activity - BLZ Team)
|
||||
5 issues updated in last 7 days
|
||||
|
||||
BLZ-162: Implement authentication middleware [In Progress]
|
||||
Priority: High | Assignee: Alice Smith
|
||||
Labels: backend, security
|
||||
Updated: 3 hours ago
|
||||
https://linear.app/outfitter/issue/BLZ-162
|
||||
|
||||
BLZ-161: Fix user validation bug [Done]
|
||||
Priority: Urgent | Assignee: Bob Jones
|
||||
Labels: bug, backend
|
||||
Updated: 5 hours ago
|
||||
https://linear.app/outfitter/issue/BLZ-161
|
||||
|
||||
BLZ-158: Update dependencies [Todo]
|
||||
Priority: Low | Assignee: Unassigned
|
||||
Labels: maintenance
|
||||
Updated: 2 days ago
|
||||
https://linear.app/outfitter/issue/BLZ-158
|
||||
```
|
||||
|
||||
## Cross-Referencing
|
||||
|
||||
### Link Issues to PRs
|
||||
|
||||
Extract issue references from PR titles/bodies:
|
||||
|
||||
```typescript
|
||||
function extractIssueReferences(text: string): string[] {
|
||||
// Pattern: "BLZ-123" or "[BLZ-123]" or "BLZ-123:"
|
||||
const pattern = /\[?([A-Z]{2,}-\d+)\]?:?/g;
|
||||
const matches = text.matchAll(pattern);
|
||||
|
||||
return Array.from(matches, m => m[1]);
|
||||
}
|
||||
|
||||
async function linkIssuesToPRs(
|
||||
issues: LinearIssue[],
|
||||
prs: GitHubPR[]
|
||||
): Promise<Map<string, GitHubPR[]>> {
|
||||
const issueMap = new Map<string, GitHubPR[]>();
|
||||
|
||||
for (const issue of issues) {
|
||||
const relatedPRs = prs.filter(pr => {
|
||||
const refs = extractIssueReferences(pr.title + ' ' + pr.body);
|
||||
return refs.includes(issue.identifier);
|
||||
});
|
||||
|
||||
if (relatedPRs.length > 0) {
|
||||
issueMap.set(issue.identifier, relatedPRs);
|
||||
}
|
||||
}
|
||||
|
||||
return issueMap;
|
||||
}
|
||||
```
|
||||
|
||||
### Annotate Issues with PR Status
|
||||
|
||||
```
|
||||
LINEAR ISSUES (with PR Status)
|
||||
|
||||
BLZ-162: Implement authentication middleware [In Progress]
|
||||
Priority: High | Assignee: Alice Smith
|
||||
PRs: #156 (Approved, CI passing)
|
||||
Updated: 3 hours ago
|
||||
|
||||
BLZ-161: Fix user validation bug [Done]
|
||||
Priority: Urgent | Assignee: Bob Jones
|
||||
PRs: #155 (CI failing, changes requested)
|
||||
Updated: 5 hours ago
|
||||
```
|
||||
|
||||
## State Matching
|
||||
|
||||
The streamlinear MCP supports fuzzy state matching:
|
||||
|
||||
```typescript
|
||||
// These all work:
|
||||
await mcp__linear__linear({ action: 'update', id: 'BLZ-123', state: 'done' });
|
||||
await mcp__linear__linear({ action: 'update', id: 'BLZ-123', state: 'Done' });
|
||||
await mcp__linear__linear({ action: 'update', id: 'BLZ-123', state: 'in prog' });
|
||||
await mcp__linear__linear({ action: 'update', id: 'BLZ-123', state: 'In Progress' });
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### MCP Availability
|
||||
|
||||
```typescript
|
||||
async function checkLinearMCPAvailable(): Promise<boolean> {
|
||||
try {
|
||||
await mcp__linear__linear({ action: 'search' });
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.warn('Linear MCP not available:', error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Graceful Degradation
|
||||
|
||||
```typescript
|
||||
async function fetchLinearIssuesSafe(
|
||||
context: LinearContext | null
|
||||
): Promise<LinearIssue[] | null> {
|
||||
if (!context) {
|
||||
console.log('No Linear context for current repo');
|
||||
return null;
|
||||
}
|
||||
|
||||
const available = await checkLinearMCPAvailable();
|
||||
if (!available) {
|
||||
console.log('Linear MCP not available, skipping issue section');
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (context.filterBy === 'team' && context.team) {
|
||||
return await fetchTeamIssues(context.team);
|
||||
} else if (context.filterBy === 'query' && context.query) {
|
||||
const result = await mcp__linear__linear({
|
||||
action: 'search',
|
||||
query: context.query
|
||||
});
|
||||
return result.issues;
|
||||
}
|
||||
return await fetchMyActiveIssues();
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch Linear issues:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Configuration Management
|
||||
|
||||
### Config File Location
|
||||
|
||||
Store mapping config in skill directory or user config:
|
||||
|
||||
```
|
||||
~/.config/claude/status-reporting/linear-config.json
|
||||
```
|
||||
|
||||
Or project-specific:
|
||||
|
||||
```
|
||||
.claude/linear-mapping.json
|
||||
```
|
||||
|
||||
### Loading Configuration
|
||||
|
||||
```typescript
|
||||
async function loadLinearConfig(): Promise<LinearConfig> {
|
||||
const configPaths = [
|
||||
// User config
|
||||
path.join(os.homedir(), '.config/claude/status-reporting/linear-config.json'),
|
||||
// Project config
|
||||
path.join(process.cwd(), '.claude/linear-mapping.json')
|
||||
];
|
||||
|
||||
for (const configPath of configPaths) {
|
||||
if (await fileExists(configPath)) {
|
||||
const content = await Bun.file(configPath).text();
|
||||
return JSON.parse(content);
|
||||
}
|
||||
}
|
||||
|
||||
// Return defaults
|
||||
return {
|
||||
mappings: [],
|
||||
defaults: {
|
||||
daysBack: 7,
|
||||
limit: 10
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### Team Key vs Team Name
|
||||
|
||||
Use team keys (e.g., "BLZ") rather than full names:
|
||||
- Keys are shorter and less prone to typos
|
||||
- The streamlinear MCP expects keys in query filters
|
||||
- Keys are visible in issue identifiers (BLZ-123)
|
||||
|
||||
Get team keys:
|
||||
|
||||
```typescript
|
||||
const result = await mcp__linear__linear({
|
||||
action: 'graphql',
|
||||
graphql: 'query { teams { nodes { id key name } } }'
|
||||
});
|
||||
// Returns: [{ id: "uuid", key: "BLZ", name: "BLZ Team" }, ...]
|
||||
```
|
||||
|
||||
### Relative Time Display
|
||||
|
||||
```typescript
|
||||
function formatRelativeTime(isoDate: string): string {
|
||||
const date = new Date(isoDate);
|
||||
const now = new Date();
|
||||
const diff = now.getTime() - date.getTime();
|
||||
|
||||
const minutes = Math.floor(diff / 60000);
|
||||
const hours = Math.floor(diff / 3600000);
|
||||
const days = Math.floor(diff / 86400000);
|
||||
|
||||
if (minutes < 60) return `${minutes} minutes ago`;
|
||||
if (hours < 24) return `${hours} hours ago`;
|
||||
if (days < 7) return `${days} days ago`;
|
||||
return date.toLocaleDateString();
|
||||
}
|
||||
```
|
||||
|
||||
### Issue Prioritization
|
||||
|
||||
Show high-priority and urgent issues first:
|
||||
|
||||
```typescript
|
||||
function sortIssuesByPriority(issues: LinearIssue[]): LinearIssue[] {
|
||||
return issues.sort((a, b) => {
|
||||
// Lower number = higher priority (1=urgent, 2=high, 3=normal, 4=low)
|
||||
// 0=none goes to end
|
||||
const priorityA = a.priority === 0 ? 99 : a.priority;
|
||||
const priorityB = b.priority === 0 ? 99 : b.priority;
|
||||
|
||||
if (priorityA !== priorityB) {
|
||||
return priorityA - priorityB;
|
||||
}
|
||||
|
||||
// Same priority: sort by updated time (most recent first)
|
||||
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Integration Points
|
||||
|
||||
### With GitHub (see github.md)
|
||||
|
||||
Correlate Linear issues with GitHub PRs:
|
||||
|
||||
```typescript
|
||||
async function correlateLinearWithGitHub(
|
||||
issues: LinearIssue[],
|
||||
prs: GitHubPR[]
|
||||
): Promise<void> {
|
||||
for (const issue of issues) {
|
||||
// Find PRs referencing this issue
|
||||
const relatedPRs = prs.filter(pr => {
|
||||
const refs = extractIssueReferences(pr.title + ' ' + (pr.body || ''));
|
||||
return refs.includes(issue.identifier);
|
||||
});
|
||||
|
||||
if (relatedPRs.length > 0) {
|
||||
issue.relatedPRs = relatedPRs;
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### With Graphite (see graphite.md)
|
||||
|
||||
Show Linear issues alongside stack:
|
||||
|
||||
```typescript
|
||||
async function annotateStackWithLinear(
|
||||
stack: StackNode[],
|
||||
issues: LinearIssue[]
|
||||
): Promise<void> {
|
||||
for (const node of stack) {
|
||||
if (!node.prTitle) continue;
|
||||
|
||||
const refs = extractIssueReferences(node.prTitle);
|
||||
node.linearIssues = issues.filter(issue =>
|
||||
refs.includes(issue.identifier)
|
||||
);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Linear MCP Not Found
|
||||
|
||||
Verify the streamlinear MCP server is configured in `~/.claude.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"linear": {
|
||||
"type": "stdio",
|
||||
"command": "npx",
|
||||
"args": ["-y", "github:obra/streamlinear"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Ensure `LINEAR_API_TOKEN` is set in your environment.
|
||||
|
||||
### No Issues Returned
|
||||
|
||||
```typescript
|
||||
// Debug: Check available teams
|
||||
const teams = await mcp__linear__linear({
|
||||
action: 'graphql',
|
||||
graphql: 'query { teams { nodes { id key name } } }'
|
||||
});
|
||||
console.log('Available teams:', teams);
|
||||
|
||||
// Debug: Try broader search
|
||||
const allIssues = await mcp__linear__linear({
|
||||
action: 'search',
|
||||
query: ''
|
||||
});
|
||||
console.log('Total issues accessible:', allIssues.length);
|
||||
```
|
||||
|
||||
### Authentication Issues
|
||||
|
||||
The streamlinear MCP reads `LINEAR_API_TOKEN` from environment. Verify it's set:
|
||||
|
||||
```bash
|
||||
echo $LINEAR_API_TOKEN
|
||||
```
|
||||
|
||||
Generate a new token at: <https://linear.app/settings/api>
|
||||
@@ -0,0 +1,106 @@
|
||||
# Presentation Templates
|
||||
|
||||
Output formats and section templates for status reports.
|
||||
|
||||
## Output Structure
|
||||
|
||||
```
|
||||
=== STATUS REPORT: {repo-name} ===
|
||||
Generated: {timestamp}
|
||||
{Time filter: "Last 24 hours" if applicable}
|
||||
|
||||
{VCS_SECTION}
|
||||
{PR_SECTION}
|
||||
{ISSUE_SECTION}
|
||||
{CI_SECTION}
|
||||
```
|
||||
|
||||
## Visual Indicators
|
||||
|
||||
**Status**:
|
||||
- `✓` success, passing, approved
|
||||
- `✗` failure, failed, rejected
|
||||
- `⏳` in-progress, pending
|
||||
- `⏸` paused, draft
|
||||
- `🔴` blocker, critical
|
||||
|
||||
**Progress** (use `░▓`):
|
||||
- `▓▓▓░░` — 3/5 checks passing
|
||||
|
||||
**Severity** (use `◇◆`):
|
||||
- `◇` minor, informational
|
||||
- `◆` moderate, needs attention
|
||||
- `◆◆` severe, blocking
|
||||
|
||||
## Section Templates
|
||||
|
||||
### VCS Section (Stack-Aware)
|
||||
|
||||
```
|
||||
📊 {VCS_NAME} STACK
|
||||
{visual tree with branch relationships}
|
||||
├─ {branch}: {status} [{commit_count} commits]
|
||||
│ PR #{num}: {pr_status} | CI: {ci_status}
|
||||
│ Updated: {relative_time}
|
||||
```
|
||||
|
||||
### VCS Section (Standard)
|
||||
|
||||
```
|
||||
📊 VERSION CONTROL
|
||||
Current branch: {branch}
|
||||
Status: {clean | modified | ahead X, behind Y}
|
||||
Recent commits: {count} in last {period}
|
||||
```
|
||||
|
||||
### PR Section
|
||||
|
||||
```
|
||||
🔀 PULL REQUESTS ({open_count} open)
|
||||
PR #{num}: {title} [{state}]
|
||||
Author: {author} | Updated: {relative_time}
|
||||
CI: {status_indicator} {pass}/{total} checks
|
||||
Reviews: {status_indicator} {approved}/{total} reviewers
|
||||
{blocker indicator if applicable}
|
||||
```
|
||||
|
||||
### Issue Section
|
||||
|
||||
```
|
||||
📋 ISSUES (Recent Activity)
|
||||
{issue_key}: {title} [{status}]
|
||||
Priority: {priority} | Assignee: {assignee}
|
||||
Updated: {relative_time}
|
||||
{link}
|
||||
```
|
||||
|
||||
### CI Section
|
||||
|
||||
```
|
||||
🔧 CI/CD ({total} runs)
|
||||
Success: {success_count} | Failed: {failed_count} | In Progress: {pending_count}
|
||||
|
||||
{if failures exist:}
|
||||
Recent Failures:
|
||||
{workflow_name}: {error_summary}
|
||||
{link to run}
|
||||
```
|
||||
|
||||
### Attention Section
|
||||
|
||||
Highlight action-needed items at top:
|
||||
|
||||
```
|
||||
⚠️ ATTENTION NEEDED
|
||||
◆◆ PR #123: CI failing for 2 days (blocks deployment)
|
||||
◆ Issue BLZ-45: High priority, unassigned
|
||||
◇ Branch feature/old: No activity for 14 days
|
||||
```
|
||||
|
||||
## Formatting Guidelines
|
||||
|
||||
- Limit line length: 80-120 chars
|
||||
- Align columns for tabular data
|
||||
- Use indentation for hierarchy
|
||||
- Preserve links for clickability
|
||||
- Relative timestamps for recency
|
||||
@@ -0,0 +1,243 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* detect.ts - Detect available project tools
|
||||
*
|
||||
* Quick pre-flight check for sitrep service availability.
|
||||
* Returns JSON with boolean availability for each tool.
|
||||
*
|
||||
* Usage:
|
||||
* ./detect.ts # JSON output
|
||||
* ./detect.ts --format=text # Human-readable output
|
||||
*/
|
||||
|
||||
import { parseArgs } from "node:util";
|
||||
|
||||
/**
|
||||
* Result of detecting available project tools.
|
||||
*/
|
||||
interface DetectResult {
|
||||
/** Whether Graphite CLI is available and initialized */
|
||||
graphite: boolean;
|
||||
/** Whether GitHub CLI is available and authenticated */
|
||||
github: boolean;
|
||||
/** Whether Linear MCP is available */
|
||||
linear: boolean;
|
||||
/** Whether Beads issue tracking is initialized */
|
||||
beads: boolean;
|
||||
/** Human-readable status details for each tool */
|
||||
details: {
|
||||
graphite?: string;
|
||||
github?: string;
|
||||
linear?: string;
|
||||
beads?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
format: { type: "string", short: "f", default: "json" },
|
||||
help: { type: "boolean", short: "h" },
|
||||
},
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
console.log(`
|
||||
detect.ts - Detect available project tools for sitrep
|
||||
|
||||
Usage:
|
||||
./detect.ts [options]
|
||||
|
||||
Options:
|
||||
-f, --format <fmt> Output format: json, text [default: json]
|
||||
-h, --help Show this help
|
||||
|
||||
Output:
|
||||
JSON object with boolean availability for each service:
|
||||
- graphite: gt CLI installed and initialized
|
||||
- github: gh CLI installed and authenticated
|
||||
- linear: Linear MCP available (checks for mcp tools)
|
||||
- beads: .beads/ directory exists in current project
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a command exists in PATH.
|
||||
* @param cmd - Command name to check
|
||||
* @returns True if command is available
|
||||
*/
|
||||
async function commandExists(cmd: string): Promise<boolean> {
|
||||
const proc = Bun.spawn(["command", "-v", cmd], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
await proc.exited;
|
||||
return proc.exitCode === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a command and captures output.
|
||||
* @param cmd - Command and arguments array
|
||||
* @returns Object with success status and combined output
|
||||
*/
|
||||
async function runCommand(
|
||||
cmd: string[],
|
||||
): Promise<{ success: boolean; output: string }> {
|
||||
const proc = Bun.spawn(cmd, {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
await proc.exited;
|
||||
return {
|
||||
success: proc.exitCode === 0,
|
||||
output: stdout || stderr,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects Graphite CLI availability and initialization status.
|
||||
* @returns Availability and detail message
|
||||
*/
|
||||
async function detectGraphite(): Promise<{ available: boolean; detail?: string }> {
|
||||
if (!(await commandExists("gt"))) {
|
||||
return { available: false, detail: "gt CLI not installed" };
|
||||
}
|
||||
|
||||
// Check if initialized in this repo
|
||||
const { success } = await runCommand(["gt", "state"]);
|
||||
if (!success) {
|
||||
return { available: false, detail: "gt not initialized in this repo" };
|
||||
}
|
||||
|
||||
return { available: true, detail: "gt CLI ready" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects GitHub CLI availability and authentication status.
|
||||
* @returns Availability and detail message
|
||||
*/
|
||||
async function detectGitHub(): Promise<{ available: boolean; detail?: string }> {
|
||||
if (!(await commandExists("gh"))) {
|
||||
return { available: false, detail: "gh CLI not installed" };
|
||||
}
|
||||
|
||||
// Check auth status
|
||||
const { success, output } = await runCommand(["gh", "auth", "status"]);
|
||||
if (!success) {
|
||||
return { available: false, detail: "gh not authenticated" };
|
||||
}
|
||||
|
||||
// Extract account info if available
|
||||
const match = output.match(/Logged in to .+ as (\S+)/);
|
||||
const user = match ? match[1] : "authenticated";
|
||||
return { available: true, detail: `gh CLI ready (${user})` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects Linear MCP availability.
|
||||
* @returns Availability and detail message
|
||||
*/
|
||||
async function detectLinear(): Promise<{ available: boolean; detail?: string }> {
|
||||
// Linear detection is tricky - we check for the MCP tool availability
|
||||
// In Claude Code context, this would be detected via tool availability
|
||||
// For script context, we check if claude CLI exists and has linear configured
|
||||
|
||||
if (!(await commandExists("claude"))) {
|
||||
return { available: false, detail: "claude CLI not installed" };
|
||||
}
|
||||
|
||||
// We can't easily detect MCP availability from a script
|
||||
// Return unknown/check-at-runtime
|
||||
return { available: false, detail: "Linear MCP - check at runtime" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects Beads issue tracking initialization.
|
||||
* @returns Availability and detail message
|
||||
*/
|
||||
async function detectBeads(): Promise<{ available: boolean; detail?: string }> {
|
||||
const beadsDir = Bun.file(".beads/metadata.json");
|
||||
const exists = await beadsDir.exists();
|
||||
|
||||
if (!exists) {
|
||||
return { available: false, detail: ".beads/ not initialized" };
|
||||
}
|
||||
|
||||
return { available: true, detail: "beads initialized" };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects all available project tools in parallel.
|
||||
* @returns Detection results for all tools
|
||||
*/
|
||||
async function detect(): Promise<DetectResult> {
|
||||
const [graphite, github, linear, beads] = await Promise.all([
|
||||
detectGraphite(),
|
||||
detectGitHub(),
|
||||
detectLinear(),
|
||||
detectBeads(),
|
||||
]);
|
||||
|
||||
return {
|
||||
graphite: graphite.available,
|
||||
github: github.available,
|
||||
linear: linear.available,
|
||||
beads: beads.available,
|
||||
details: {
|
||||
graphite: graphite.detail,
|
||||
github: github.detail,
|
||||
linear: linear.detail,
|
||||
beads: beads.detail,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats detection results as human-readable text.
|
||||
* @param result - Detection results to format
|
||||
* @returns Formatted text output
|
||||
*/
|
||||
function formatText(result: DetectResult): string {
|
||||
const lines: string[] = ["PROJECT TOOLS", ""];
|
||||
|
||||
const status = (available: boolean) => (available ? "✓" : "✗");
|
||||
|
||||
lines.push(`${status(result.graphite)} Graphite: ${result.details.graphite}`);
|
||||
lines.push(`${status(result.github)} GitHub: ${result.details.github}`);
|
||||
lines.push(`${status(result.linear)} Linear: ${result.details.linear}`);
|
||||
lines.push(`${status(result.beads)} Beads: ${result.details.beads}`);
|
||||
|
||||
const available = [
|
||||
result.graphite && "graphite",
|
||||
result.github && "github",
|
||||
result.linear && "linear",
|
||||
result.beads && "beads",
|
||||
].filter(Boolean);
|
||||
|
||||
lines.push("");
|
||||
lines.push(
|
||||
available.length > 0
|
||||
? `Available: ${available.join(", ")}`
|
||||
: "No tools detected",
|
||||
);
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const result = await detect();
|
||||
|
||||
if (values.format === "text") {
|
||||
console.log(formatText(result));
|
||||
} else {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Error:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
Executable
+195
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Beads gatherer for status
|
||||
*
|
||||
* Collects local issue data from .beads/ directory
|
||||
* - Stats overview
|
||||
* - In-progress work
|
||||
* - Ready items (unblocked)
|
||||
* - Blocked items with dependencies
|
||||
* - Recently closed (filtered by time)
|
||||
*/
|
||||
|
||||
import { parseArgs } from "node:util";
|
||||
import { filterByTime, parseTimeConstraint } from "../lib/time";
|
||||
import type {
|
||||
BeadsData,
|
||||
BeadsIssue,
|
||||
BeadsStats,
|
||||
GathererResult,
|
||||
} from "../lib/types";
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
time: { type: "string", short: "t", default: "24h" },
|
||||
workspace: { type: "string", short: "w" },
|
||||
help: { type: "boolean", short: "h" },
|
||||
},
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
console.log(`
|
||||
beads-gatherer.ts - Gather beads issue data
|
||||
|
||||
Usage:
|
||||
./beads-gatherer.ts [options]
|
||||
|
||||
Options:
|
||||
-t, --time <constraint> Time constraint (24h, 7d, 2w) [default: 24h]
|
||||
-w, --workspace <path> Workspace root [default: current directory]
|
||||
-h, --help Show this help
|
||||
|
||||
Output:
|
||||
JSON GathererResult with BeadsData
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of running a bd CLI command.
|
||||
*/
|
||||
interface BdOutput<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a bd CLI command and parses JSON output.
|
||||
* @param args - Arguments to pass to bd
|
||||
* @returns Parsed output or error
|
||||
*/
|
||||
async function runBd<T>(args: string[]): Promise<BdOutput<T>> {
|
||||
const workspaceArgs = values.workspace
|
||||
? ["--workspace-root", values.workspace]
|
||||
: [];
|
||||
|
||||
const proc = Bun.spawn(["bd", ...workspaceArgs, ...args, "--json"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
if (exitCode !== 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: stderr || `bd exited with code ${exitCode}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(stdout);
|
||||
return { success: true, data };
|
||||
} catch {
|
||||
return { success: false, error: `Failed to parse bd output: ${stdout}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if Beads is initialized in workspace.
|
||||
* @returns True if .beads directory exists
|
||||
*/
|
||||
async function checkBeadsAvailable(): Promise<boolean> {
|
||||
// Check if .beads directory exists
|
||||
const beadsDir = values.workspace ? `${values.workspace}/.beads` : ".beads";
|
||||
|
||||
const file = Bun.file(`${beadsDir}/issues.db`);
|
||||
return file.exists();
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers Beads issue tracking data.
|
||||
* @returns Gatherer result with Beads data
|
||||
*/
|
||||
async function gatherBeadsData(): Promise<GathererResult<BeadsData>> {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Check if beads is available
|
||||
const available = await checkBeadsAvailable();
|
||||
if (!available) {
|
||||
return {
|
||||
source: "beads",
|
||||
status: "unavailable",
|
||||
reason: "Beads not initialized (.beads/ directory not found)",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse time constraint
|
||||
const timeValue = values.time ?? "24h";
|
||||
let timeMs: number;
|
||||
try {
|
||||
timeMs = parseTimeConstraint(timeValue);
|
||||
} catch (e) {
|
||||
return {
|
||||
source: "beads",
|
||||
status: "error",
|
||||
error: e instanceof Error ? e.message : "Invalid time constraint",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// Gather data in parallel
|
||||
const [
|
||||
statsResult,
|
||||
inProgressResult,
|
||||
readyResult,
|
||||
blockedResult,
|
||||
closedResult,
|
||||
] = await Promise.all([
|
||||
runBd<BeadsStats>(["stats"]),
|
||||
runBd<BeadsIssue[]>(["list", "--status=in_progress", "--limit=10"]),
|
||||
runBd<BeadsIssue[]>(["ready", "--limit=10"]),
|
||||
runBd<BeadsIssue[]>(["blocked"]),
|
||||
runBd<BeadsIssue[]>(["list", "--status=closed", "--limit=20"]),
|
||||
]);
|
||||
|
||||
// Check for fatal errors (stats should always work if beads is available)
|
||||
if (!statsResult.success) {
|
||||
return {
|
||||
source: "beads",
|
||||
status: "error",
|
||||
error: statsResult.error || "Failed to get beads stats",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// Build result, handling partial failures gracefully
|
||||
const stats = statsResult.data ?? {
|
||||
total: 0,
|
||||
open: 0,
|
||||
in_progress: 0,
|
||||
blocked: 0,
|
||||
closed: 0,
|
||||
};
|
||||
const inProgress = inProgressResult.success
|
||||
? (inProgressResult.data ?? [])
|
||||
: [];
|
||||
const ready = readyResult.success ? (readyResult.data ?? []) : [];
|
||||
const blocked = blockedResult.success ? (blockedResult.data ?? []) : [];
|
||||
const closed = closedResult.success ? (closedResult.data ?? []) : [];
|
||||
|
||||
// Filter closed issues by time constraint (client-side)
|
||||
const recentlyClosed = filterByTime(closed, timeMs);
|
||||
|
||||
return {
|
||||
source: "beads",
|
||||
status: "success",
|
||||
data: {
|
||||
stats,
|
||||
inProgress,
|
||||
ready,
|
||||
blocked,
|
||||
recentlyClosed,
|
||||
},
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// Main execution
|
||||
const result = await gatherBeadsData();
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
Executable
+282
@@ -0,0 +1,282 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* GitHub gatherer for status
|
||||
*
|
||||
* Collects data via `gh` CLI:
|
||||
* - Open PRs with CI status and review decisions
|
||||
* - Recent workflow runs
|
||||
*/
|
||||
|
||||
import { parseArgs } from "node:util";
|
||||
import { parseTimeConstraint, toCutoffDate } from "../lib/time";
|
||||
import type {
|
||||
GathererResult,
|
||||
GitHubData,
|
||||
GitHubPR,
|
||||
GitHubWorkflowRun,
|
||||
} from "../lib/types";
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
time: { type: "string", short: "t", default: "24h" },
|
||||
repo: { type: "string", short: "r" },
|
||||
help: { type: "boolean", short: "h" },
|
||||
},
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
console.log(`
|
||||
github-gatherer.ts - Gather GitHub PR and CI data
|
||||
|
||||
Usage:
|
||||
./github-gatherer.ts [options]
|
||||
|
||||
Options:
|
||||
-t, --time <constraint> Time constraint (24h, 7d, 2w) [default: 24h]
|
||||
-r, --repo <owner/repo> Repository [default: current repo]
|
||||
-h, --help Show this help
|
||||
|
||||
Output:
|
||||
JSON GathererResult with GitHubData
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of running a gh CLI command.
|
||||
*/
|
||||
interface GhOutput<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a gh CLI command and parses JSON output.
|
||||
* @param args - Arguments to pass to gh
|
||||
* @returns Parsed output or error
|
||||
*/
|
||||
async function runGh<T>(args: string[]): Promise<GhOutput<T>> {
|
||||
const repoArgs = values.repo ? ["-R", values.repo] : [];
|
||||
|
||||
const proc = Bun.spawn(["gh", ...repoArgs, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
if (exitCode !== 0) {
|
||||
return {
|
||||
success: false,
|
||||
error: stderr || `gh exited with code ${exitCode}`,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(stdout);
|
||||
return { success: true, data };
|
||||
} catch {
|
||||
return { success: false, error: `Failed to parse gh output: ${stdout}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if gh CLI is installed.
|
||||
* @returns True if gh is available
|
||||
*/
|
||||
async function checkGhAvailable(): Promise<boolean> {
|
||||
const proc = Bun.spawn(["which", "gh"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
return exitCode === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if gh CLI is authenticated.
|
||||
* @returns True if authenticated
|
||||
*/
|
||||
async function checkGhAuth(): Promise<boolean> {
|
||||
const proc = Bun.spawn(["gh", "auth", "status"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
return exitCode === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the current repository name (owner/repo format).
|
||||
* @returns Repository name or null if not in a repo
|
||||
*/
|
||||
async function getRepoName(): Promise<string | null> {
|
||||
if (values.repo) return values.repo;
|
||||
|
||||
const proc = Bun.spawn(
|
||||
["gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
},
|
||||
);
|
||||
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
if (exitCode !== 0) return null;
|
||||
return stdout.trim();
|
||||
}
|
||||
|
||||
// GraphQL query for PRs with status checks
|
||||
const PR_QUERY_FIELDS = [
|
||||
"number",
|
||||
"title",
|
||||
"state",
|
||||
"isDraft",
|
||||
"author",
|
||||
"updatedAt",
|
||||
"url",
|
||||
"headRefName",
|
||||
"statusCheckRollup",
|
||||
"reviewDecision",
|
||||
].join(",");
|
||||
|
||||
/**
|
||||
* Gathers GitHub data including PRs and workflow runs.
|
||||
* @returns Gatherer result with GitHub data
|
||||
*/
|
||||
async function gatherGitHubData(): Promise<GathererResult<GitHubData>> {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Check gh CLI availability
|
||||
const ghAvailable = await checkGhAvailable();
|
||||
if (!ghAvailable) {
|
||||
return {
|
||||
source: "github",
|
||||
status: "unavailable",
|
||||
reason: "gh CLI not installed",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// Check authentication
|
||||
const ghAuth = await checkGhAuth();
|
||||
if (!ghAuth) {
|
||||
return {
|
||||
source: "github",
|
||||
status: "unavailable",
|
||||
reason: "gh CLI not authenticated (run: gh auth login)",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// Get repo name
|
||||
const repo = await getRepoName();
|
||||
if (!repo) {
|
||||
return {
|
||||
source: "github",
|
||||
status: "unavailable",
|
||||
reason: "Not in a GitHub repository",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse time constraint
|
||||
const timeValue = values.time ?? "24h";
|
||||
let timeMs: number;
|
||||
try {
|
||||
timeMs = parseTimeConstraint(timeValue);
|
||||
} catch (e) {
|
||||
return {
|
||||
source: "github",
|
||||
status: "error",
|
||||
error: e instanceof Error ? e.message : "Invalid time constraint",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const cutoff = toCutoffDate(timeMs);
|
||||
const _cutoffDate = cutoff.toISOString().split("T")[0]; // YYYY-MM-DD for search
|
||||
|
||||
// Gather data in parallel
|
||||
const [prsResult, runsResult] = await Promise.all([
|
||||
// Get open PRs (no date filter needed - we want all open)
|
||||
runGh<GitHubPR[]>([
|
||||
"pr",
|
||||
"list",
|
||||
"--state=open",
|
||||
"--json",
|
||||
PR_QUERY_FIELDS,
|
||||
"--limit=20",
|
||||
]),
|
||||
// Get recent workflow runs
|
||||
runGh<GitHubWorkflowRun[]>([
|
||||
"run",
|
||||
"list",
|
||||
"--json",
|
||||
"name,status,conclusion,createdAt,url",
|
||||
"--limit=20",
|
||||
]),
|
||||
]);
|
||||
|
||||
if (!prsResult.success && !runsResult.success) {
|
||||
return {
|
||||
source: "github",
|
||||
status: "error",
|
||||
error:
|
||||
prsResult.error || runsResult.error || "Failed to fetch GitHub data",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// Transform PR data to match our types
|
||||
const openPRs: GitHubPR[] = (prsResult.data || []).map(
|
||||
(pr: Record<string, unknown>) => ({
|
||||
number: pr.number as number,
|
||||
title: pr.title as string,
|
||||
state: pr.state as "OPEN" | "CLOSED" | "MERGED",
|
||||
isDraft: pr.isDraft as boolean,
|
||||
author: pr.author as { login: string },
|
||||
updatedAt: pr.updatedAt as string,
|
||||
url: pr.url as string,
|
||||
headRefName: pr.headRefName as string,
|
||||
statusCheckRollup: pr.statusCheckRollup as GitHubPR["statusCheckRollup"],
|
||||
reviewDecision: pr.reviewDecision as GitHubPR["reviewDecision"],
|
||||
}),
|
||||
);
|
||||
|
||||
// Filter workflow runs by time
|
||||
const allRuns = runsResult.data || [];
|
||||
const recentRuns: GitHubWorkflowRun[] = allRuns
|
||||
.filter(
|
||||
(run: Record<string, unknown>) =>
|
||||
new Date(run.createdAt as string) >= cutoff,
|
||||
)
|
||||
.map((run: Record<string, unknown>) => ({
|
||||
name: run.name as string,
|
||||
status: run.status as string,
|
||||
conclusion: run.conclusion as string | null,
|
||||
createdAt: run.createdAt as string,
|
||||
url: run.url as string,
|
||||
}));
|
||||
|
||||
return {
|
||||
source: "github",
|
||||
status: "success",
|
||||
data: {
|
||||
repo,
|
||||
openPRs,
|
||||
recentRuns,
|
||||
},
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// Main execution
|
||||
const result = await gatherGitHubData();
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
Executable
+375
@@ -0,0 +1,375 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* Graphite gatherer for status
|
||||
*
|
||||
* Collects stack and branch data via `gt` CLI:
|
||||
* - Stack structure and hierarchy
|
||||
* - Branch PR status
|
||||
* - Restack/submit needs
|
||||
* - Recent commits via git
|
||||
*/
|
||||
|
||||
import { parseArgs } from "node:util";
|
||||
import { parseTimeConstraint, toGitSince } from "../lib/time";
|
||||
import type {
|
||||
GathererResult,
|
||||
GraphiteBranch,
|
||||
GraphiteData,
|
||||
} from "../lib/types";
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
time: { type: "string", short: "t", default: "24h" },
|
||||
help: { type: "boolean", short: "h" },
|
||||
},
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
console.log(`
|
||||
graphite-gatherer.ts - Gather Graphite stack data
|
||||
|
||||
Usage:
|
||||
./graphite-gatherer.ts [options]
|
||||
|
||||
Options:
|
||||
-t, --time <constraint> Time constraint for commits (24h, 7d, 2w) [default: 24h]
|
||||
-h, --help Show this help
|
||||
|
||||
Output:
|
||||
JSON GathererResult with GraphiteData
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of running a shell command.
|
||||
*/
|
||||
interface CmdOutput {
|
||||
success: boolean;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a shell command and captures output.
|
||||
* @param cmd - Command to run
|
||||
* @param args - Arguments to pass
|
||||
* @returns Command output
|
||||
*/
|
||||
async function runCmd(cmd: string, args: string[]): Promise<CmdOutput> {
|
||||
const proc = Bun.spawn([cmd, ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
return { success: exitCode === 0, stdout, stderr };
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if Graphite CLI is installed.
|
||||
* @returns True if gt is available
|
||||
*/
|
||||
async function checkGtAvailable(): Promise<boolean> {
|
||||
const result = await runCmd("which", ["gt"]);
|
||||
return result.success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if current directory is a git repository.
|
||||
* @returns True if in a git repo
|
||||
*/
|
||||
async function checkGitRepo(): Promise<boolean> {
|
||||
const result = await runCmd("git", ["rev-parse", "--git-dir"]);
|
||||
return result.success;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets Graphite stack state from gt CLI.
|
||||
* @returns Stack state or null on failure
|
||||
*/
|
||||
async function getGtState(): Promise<{
|
||||
branches: GraphiteBranch[];
|
||||
stacks: string[][];
|
||||
currentBranch: string;
|
||||
trunk: string;
|
||||
} | null> {
|
||||
// Get structured state from gt
|
||||
const result = await runCmd("gt", ["log", "--json"]);
|
||||
|
||||
if (!result.success) {
|
||||
// Try alternate: gt state
|
||||
const stateResult = await runCmd("gt", ["state"]);
|
||||
if (!stateResult.success) return null;
|
||||
|
||||
// Parse text output as fallback
|
||||
return parseGtStateText(stateResult.stdout);
|
||||
}
|
||||
|
||||
try {
|
||||
const data = JSON.parse(result.stdout);
|
||||
return parseGtLogJson(data);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses JSON output from gt log command.
|
||||
* @param data - Raw JSON data
|
||||
* @returns Structured Graphite state
|
||||
*/
|
||||
function parseGtLogJson(data: unknown): {
|
||||
branches: GraphiteBranch[];
|
||||
stacks: string[][];
|
||||
currentBranch: string;
|
||||
trunk: string;
|
||||
} {
|
||||
// gt log --json returns array of branch entries
|
||||
const entries = Array.isArray(data) ? data : [];
|
||||
|
||||
const branchMap = new Map<string, GraphiteBranch>();
|
||||
let currentBranch = "main";
|
||||
const trunk = "main";
|
||||
|
||||
for (const entry of entries) {
|
||||
const branch: GraphiteBranch = {
|
||||
name: entry.branch || entry.name || "",
|
||||
prNumber: entry.pr?.number,
|
||||
prStatus: mapPrState(entry.pr?.state, entry.pr?.isDraft),
|
||||
prUrl: entry.pr?.url,
|
||||
parent: entry.parent,
|
||||
children: [],
|
||||
isCurrent: entry.isCurrent || entry.current || false,
|
||||
needsRestack: entry.needsRestack || false,
|
||||
needsSubmit: entry.needsSubmit || false,
|
||||
commitCount: entry.commitCount || entry.commits?.length || 0,
|
||||
};
|
||||
|
||||
if (branch.isCurrent) {
|
||||
currentBranch = branch.name;
|
||||
}
|
||||
|
||||
branchMap.set(branch.name, branch);
|
||||
}
|
||||
|
||||
// Build children relationships
|
||||
for (const branch of branchMap.values()) {
|
||||
if (branch.parent && branchMap.has(branch.parent)) {
|
||||
branchMap.get(branch.parent)?.children.push(branch.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Build stacks (branches that share a root)
|
||||
const stacks = buildStacks(branchMap, trunk);
|
||||
|
||||
return {
|
||||
branches: Array.from(branchMap.values()),
|
||||
stacks,
|
||||
currentBranch,
|
||||
trunk,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses text output from gt state command (fallback).
|
||||
* @param text - Raw text output
|
||||
* @returns Structured Graphite state
|
||||
*/
|
||||
function parseGtStateText(text: string): {
|
||||
branches: GraphiteBranch[];
|
||||
stacks: string[][];
|
||||
currentBranch: string;
|
||||
trunk: string;
|
||||
} {
|
||||
// Fallback parser for text output
|
||||
const lines = text.split("\n").filter((l) => l.trim());
|
||||
const branches: GraphiteBranch[] = [];
|
||||
let currentBranch = "main";
|
||||
|
||||
for (const line of lines) {
|
||||
// Look for branch indicators like "◉ branch-name" or "○ branch-name"
|
||||
const match = line.match(/[◉○●◐]\s+(\S+)/);
|
||||
if (match) {
|
||||
const name = match[1];
|
||||
const isCurrent = line.includes("◉") || line.includes("●");
|
||||
if (isCurrent) currentBranch = name;
|
||||
|
||||
branches.push({
|
||||
name,
|
||||
children: [],
|
||||
isCurrent,
|
||||
needsRestack: line.includes("restack"),
|
||||
needsSubmit: line.includes("submit"),
|
||||
commitCount: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
branches,
|
||||
stacks: branches.length > 0 ? [branches.map((b) => b.name)] : [],
|
||||
currentBranch,
|
||||
trunk: "main",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps PR state from API to internal status.
|
||||
* @param state - API state string
|
||||
* @param isDraft - Whether PR is a draft
|
||||
* @returns Normalized status
|
||||
*/
|
||||
function mapPrState(
|
||||
state?: string,
|
||||
isDraft?: boolean,
|
||||
): "draft" | "open" | "ready" | "merged" | "closed" | undefined {
|
||||
if (!state) return undefined;
|
||||
if (isDraft) return "draft";
|
||||
|
||||
switch (state.toLowerCase()) {
|
||||
case "open":
|
||||
return "open";
|
||||
case "merged":
|
||||
return "merged";
|
||||
case "closed":
|
||||
return "closed";
|
||||
default:
|
||||
return "open";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds stack arrays from branch relationships.
|
||||
* @param branchMap - Map of branch names to branch data
|
||||
* @param trunk - Trunk branch name
|
||||
* @returns Array of stacks (each stack is array of branch names)
|
||||
*/
|
||||
function buildStacks(
|
||||
branchMap: Map<string, GraphiteBranch>,
|
||||
trunk: string,
|
||||
): string[][] {
|
||||
const stacks: string[][] = [];
|
||||
const visited = new Set<string>();
|
||||
|
||||
// Find root branches (parent is trunk or undefined)
|
||||
const roots = Array.from(branchMap.values()).filter(
|
||||
(b) => !b.parent || b.parent === trunk || !branchMap.has(b.parent),
|
||||
);
|
||||
|
||||
for (const root of roots) {
|
||||
if (visited.has(root.name)) continue;
|
||||
|
||||
const stack: string[] = [];
|
||||
const queue = [root.name];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const name = queue.shift();
|
||||
if (!name || visited.has(name)) continue;
|
||||
|
||||
visited.add(name);
|
||||
stack.push(name);
|
||||
|
||||
const branch = branchMap.get(name);
|
||||
if (branch) {
|
||||
queue.push(...branch.children);
|
||||
}
|
||||
}
|
||||
|
||||
if (stack.length > 0) {
|
||||
stacks.push(stack);
|
||||
}
|
||||
}
|
||||
|
||||
return stacks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets count of recent commits within time window.
|
||||
* @param timeMs - Time window in milliseconds
|
||||
* @returns Number of commits
|
||||
*/
|
||||
async function getRecentCommits(timeMs: number): Promise<number> {
|
||||
const since = toGitSince(timeMs);
|
||||
const result = await runCmd("git", ["log", `--since=${since}`, "--oneline"]);
|
||||
|
||||
if (!result.success) return 0;
|
||||
return result.stdout.split("\n").filter((l) => l.trim()).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers Graphite stack and branch data.
|
||||
* @returns Gatherer result with Graphite data
|
||||
*/
|
||||
async function gatherGraphiteData(): Promise<GathererResult<GraphiteData>> {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Check prerequisites
|
||||
const gtAvailable = await checkGtAvailable();
|
||||
if (!gtAvailable) {
|
||||
return {
|
||||
source: "graphite",
|
||||
status: "unavailable",
|
||||
reason: "gt CLI not installed",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
const isGitRepo = await checkGitRepo();
|
||||
if (!isGitRepo) {
|
||||
return {
|
||||
source: "graphite",
|
||||
status: "unavailable",
|
||||
reason: "Not in a git repository",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse time constraint
|
||||
const timeValue = values.time ?? "24h";
|
||||
let timeMs: number;
|
||||
try {
|
||||
timeMs = parseTimeConstraint(timeValue);
|
||||
} catch (e) {
|
||||
return {
|
||||
source: "graphite",
|
||||
status: "error",
|
||||
error: e instanceof Error ? e.message : "Invalid time constraint",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// Get graphite state
|
||||
const state = await getGtState();
|
||||
if (!state) {
|
||||
return {
|
||||
source: "graphite",
|
||||
status: "error",
|
||||
error: "Failed to parse gt output",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// Get recent commit count (informational)
|
||||
const _recentCommits = await getRecentCommits(timeMs);
|
||||
|
||||
return {
|
||||
source: "graphite",
|
||||
status: "success",
|
||||
data: {
|
||||
currentBranch: state.currentBranch,
|
||||
trunk: state.trunk,
|
||||
branches: state.branches,
|
||||
stacks: state.stacks,
|
||||
},
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// Main execution
|
||||
const result = await gatherGraphiteData();
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
Executable
+253
@@ -0,0 +1,253 @@
|
||||
#!/usr/bin/env bun
|
||||
|
||||
/**
|
||||
* Linear gatherer for status
|
||||
*
|
||||
* Collects Linear issue data via Claude CLI headless mode (MCP)
|
||||
* - Checks if Linear MCP is configured
|
||||
* - Queries recent issues via claude --print
|
||||
*/
|
||||
|
||||
import { homedir } from "node:os";
|
||||
import { parseArgs } from "node:util";
|
||||
import { parseTimeConstraint, toISOPeriod } from "../lib/time";
|
||||
import type { GathererResult, LinearData, LinearIssue } from "../lib/types";
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
time: { type: "string", short: "t", default: "24h" },
|
||||
team: { type: "string" },
|
||||
help: { type: "boolean", short: "h" },
|
||||
},
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
console.log(`
|
||||
linear-gatherer.ts - Gather Linear issue data
|
||||
|
||||
Usage:
|
||||
./linear-gatherer.ts [options]
|
||||
|
||||
Options:
|
||||
-t, --time <constraint> Time constraint (24h, 7d, 2w) [default: 24h]
|
||||
--team <team-key> Linear team key to filter by
|
||||
-h, --help Show this help
|
||||
|
||||
Output:
|
||||
JSON GathererResult with LinearData
|
||||
|
||||
Note:
|
||||
Requires Linear MCP to be configured in Claude settings.
|
||||
Uses 'claude --print' headless mode to query via MCP.
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if Linear MCP is configured in Claude settings.
|
||||
* @returns True if Linear MCP is configured
|
||||
*/
|
||||
async function checkLinearMCPConfigured(): Promise<boolean> {
|
||||
// Check both user and project settings
|
||||
const settingsPaths = [
|
||||
`${homedir()}/.claude/settings.json`,
|
||||
`${homedir()}/.claude/settings.local.json`,
|
||||
".claude/settings.json",
|
||||
".claude/settings.local.json",
|
||||
];
|
||||
|
||||
for (const path of settingsPaths) {
|
||||
const file = Bun.file(path);
|
||||
if (await file.exists()) {
|
||||
try {
|
||||
const content = await file.json();
|
||||
// Check for Linear in mcpServers
|
||||
if (content.mcpServers) {
|
||||
const servers = Object.keys(content.mcpServers);
|
||||
if (servers.some((s) => s.toLowerCase().includes("linear"))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Continue checking other files
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if Claude CLI is installed.
|
||||
* @returns True if claude is available
|
||||
*/
|
||||
async function checkClaudeCliAvailable(): Promise<boolean> {
|
||||
const proc = Bun.spawn(["which", "claude"], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
const exitCode = await proc.exited;
|
||||
return exitCode === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries Linear issues via Claude CLI headless mode.
|
||||
* @param timeMs - Time window in milliseconds
|
||||
* @param team - Optional team key to filter by
|
||||
* @returns Array of issues or null on failure
|
||||
*/
|
||||
async function queryLinearViaClaude(
|
||||
timeMs: number,
|
||||
team?: string,
|
||||
): Promise<LinearIssue[] | null> {
|
||||
const _period = toISOPeriod(timeMs);
|
||||
|
||||
// Build the prompt for Claude
|
||||
const teamFilter = team ? ` for team ${team}` : "";
|
||||
const prompt = `Use the Linear MCP tools to list issues updated in the last ${Math.round(timeMs / (60 * 60 * 1000))} hours${teamFilter}.
|
||||
|
||||
Return ONLY a JSON array of issues with this structure (no other text):
|
||||
[
|
||||
{
|
||||
"identifier": "TEAM-123",
|
||||
"title": "Issue title",
|
||||
"state": { "name": "In Progress", "type": "started" },
|
||||
"priority": 2,
|
||||
"assignee": { "name": "Person Name" },
|
||||
"labels": [{ "name": "label1" }],
|
||||
"createdAt": "ISO date",
|
||||
"updatedAt": "ISO date",
|
||||
"url": "https://linear.app/..."
|
||||
}
|
||||
]
|
||||
|
||||
If no issues found, return an empty array [].`;
|
||||
|
||||
try {
|
||||
const proc = Bun.spawn(
|
||||
[
|
||||
"claude",
|
||||
"--print",
|
||||
prompt,
|
||||
"--output-format",
|
||||
"json",
|
||||
"--max-turns",
|
||||
"3",
|
||||
],
|
||||
{
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
timeout: 60000, // 60 second timeout
|
||||
},
|
||||
);
|
||||
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
if (exitCode !== 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Try to parse the response
|
||||
// Claude's JSON output may include a wrapper, extract the issues array
|
||||
const parsed = JSON.parse(stdout);
|
||||
|
||||
// Handle different response formats
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed as LinearIssue[];
|
||||
}
|
||||
|
||||
// If wrapped in a result object
|
||||
if (parsed.result && Array.isArray(parsed.result)) {
|
||||
return parsed.result as LinearIssue[];
|
||||
}
|
||||
|
||||
// If wrapped in content
|
||||
if (parsed.content) {
|
||||
const content =
|
||||
typeof parsed.content === "string"
|
||||
? parsed.content
|
||||
: JSON.stringify(parsed.content);
|
||||
// Try to extract JSON array from content
|
||||
const match = content.match(/\[[\s\S]*\]/);
|
||||
if (match) {
|
||||
return JSON.parse(match[0]) as LinearIssue[];
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers Linear issue data via MCP.
|
||||
* @returns Gatherer result with Linear data
|
||||
*/
|
||||
async function gatherLinearData(): Promise<GathererResult<LinearData>> {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Check if Linear MCP is configured
|
||||
const mcpConfigured = await checkLinearMCPConfigured();
|
||||
if (!mcpConfigured) {
|
||||
return {
|
||||
source: "linear",
|
||||
status: "unavailable",
|
||||
reason: "Linear MCP not configured in Claude settings",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// Check if Claude CLI is available
|
||||
const claudeAvailable = await checkClaudeCliAvailable();
|
||||
if (!claudeAvailable) {
|
||||
return {
|
||||
source: "linear",
|
||||
status: "unavailable",
|
||||
reason: "Claude CLI not installed",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// Parse time constraint
|
||||
const timeValue = values.time ?? "24h";
|
||||
let timeMs: number;
|
||||
try {
|
||||
timeMs = parseTimeConstraint(timeValue);
|
||||
} catch (e) {
|
||||
return {
|
||||
source: "linear",
|
||||
status: "error",
|
||||
error: e instanceof Error ? e.message : "Invalid time constraint",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// Query Linear via Claude
|
||||
const issues = await queryLinearViaClaude(timeMs, values.team);
|
||||
|
||||
if (issues === null) {
|
||||
return {
|
||||
source: "linear",
|
||||
status: "error",
|
||||
error: "Failed to query Linear via Claude CLI",
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
source: "linear",
|
||||
status: "success",
|
||||
data: {
|
||||
team: values.team,
|
||||
issues,
|
||||
},
|
||||
timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
// Main execution
|
||||
const result = await gatherLinearData();
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Time parsing utilities for status gatherers
|
||||
*/
|
||||
|
||||
const TIME_UNITS: Record<string, number> = {
|
||||
h: 60 * 60 * 1000,
|
||||
d: 24 * 60 * 60 * 1000,
|
||||
w: 7 * 24 * 60 * 60 * 1000,
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse time constraint string to milliseconds
|
||||
* @example parseTimeConstraint("24h") → 86400000
|
||||
* @example parseTimeConstraint("7d") → 604800000
|
||||
* @example parseTimeConstraint("2w") → 1209600000
|
||||
*/
|
||||
export function parseTimeConstraint(input: string): number {
|
||||
const match = input.match(/^(\d+)([hdw])$/i);
|
||||
if (!match) {
|
||||
throw new Error(
|
||||
`Invalid time constraint: ${input}. Use format like "24h", "7d", or "2w"`,
|
||||
);
|
||||
}
|
||||
const [, value, unit] = match;
|
||||
const multiplier = TIME_UNITS[unit.toLowerCase()];
|
||||
if (!multiplier) {
|
||||
throw new Error(`Unknown time unit: ${unit}`);
|
||||
}
|
||||
return parseInt(value, 10) * multiplier;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cutoff Date from milliseconds offset
|
||||
*/
|
||||
export function toCutoffDate(ms: number): Date {
|
||||
return new Date(Date.now() - ms);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to git --since format
|
||||
* @example toGitSince(86400000) → "2024-12-21T12:00:00"
|
||||
*/
|
||||
export function toGitSince(ms: number): string {
|
||||
return toCutoffDate(ms).toISOString().replace("Z", "");
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to ISO 8601 duration for Linear
|
||||
* @example toISOPeriod(86400000) → "-P1D" (1 day)
|
||||
* @example toISOPeriod(604800000) → "-P7D" (7 days)
|
||||
*/
|
||||
export function toISOPeriod(ms: number): string {
|
||||
const hours = ms / (60 * 60 * 1000);
|
||||
if (hours < 24) {
|
||||
return `-PT${Math.round(hours)}H`;
|
||||
}
|
||||
const days = Math.round(hours / 24);
|
||||
return `-P${days}D`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to human-readable relative time
|
||||
* @example toRelativeTime(new Date(Date.now() - 3600000)) → "1 hour ago"
|
||||
*/
|
||||
export function toRelativeTime(date: Date | string): string {
|
||||
const d = typeof date === "string" ? new Date(date) : date;
|
||||
const ms = Date.now() - d.getTime();
|
||||
|
||||
if (ms < 60 * 1000) return "just now";
|
||||
if (ms < 60 * 60 * 1000) {
|
||||
const mins = Math.floor(ms / (60 * 1000));
|
||||
return `${mins} minute${mins === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
if (ms < 24 * 60 * 60 * 1000) {
|
||||
const hours = Math.floor(ms / (60 * 60 * 1000));
|
||||
return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
const days = Math.floor(ms / (24 * 60 * 60 * 1000));
|
||||
return `${days} day${days === 1 ? "" : "s"} ago`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter items by updated_at field within time window
|
||||
*/
|
||||
export function filterByTime<
|
||||
T extends { updated_at?: string; updatedAt?: string },
|
||||
>(items: T[], ms: number): T[] {
|
||||
const cutoff = toCutoffDate(ms);
|
||||
return items.filter((item) => {
|
||||
const updatedAt = item.updated_at || item.updatedAt;
|
||||
if (!updatedAt) return false;
|
||||
return new Date(updatedAt) >= cutoff;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Format time constraint for display
|
||||
* @example formatTimeConstraint("24h") → "last 24 hours"
|
||||
*/
|
||||
export function formatTimeConstraint(input: string): string {
|
||||
const match = input.match(/^(\d+)([hdw])$/i);
|
||||
if (!match) return input;
|
||||
|
||||
const [, value, unit] = match;
|
||||
const num = parseInt(value, 10);
|
||||
|
||||
switch (unit.toLowerCase()) {
|
||||
case "h":
|
||||
return `last ${num} hour${num === 1 ? "" : "s"}`;
|
||||
case "d":
|
||||
return `last ${num} day${num === 1 ? "" : "s"}`;
|
||||
case "w":
|
||||
return `last ${num} week${num === 1 ? "" : "s"}`;
|
||||
default:
|
||||
return input;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* Shared types for status gatherers
|
||||
*/
|
||||
|
||||
/** Possible states for a gatherer operation. */
|
||||
export type GathererStatus = "success" | "unavailable" | "error";
|
||||
|
||||
/**
|
||||
* Result returned by a status gatherer.
|
||||
* @typeParam T - Type of gathered data
|
||||
*/
|
||||
export interface GathererResult<T = unknown> {
|
||||
/** Source identifier */
|
||||
source: string;
|
||||
/** Operation status */
|
||||
status: GathererStatus;
|
||||
/** Gathered data (present when success) */
|
||||
data?: T;
|
||||
/** Error message (present when error) */
|
||||
error?: string;
|
||||
/** Unavailability reason (present when unavailable) */
|
||||
reason?: string;
|
||||
/** ISO timestamp of when data was gathered */
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue from Beads local issue tracking.
|
||||
*/
|
||||
export interface BeadsIssue {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
status: "open" | "in_progress" | "blocked" | "closed";
|
||||
issue_type: "bug" | "feature" | "task" | "epic" | "chore";
|
||||
priority: 0 | 1 | 2 | 3 | 4;
|
||||
assignee?: string;
|
||||
labels: string[];
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
closed_at?: string;
|
||||
dependency_count: number;
|
||||
dependent_count: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregated statistics for Beads issues.
|
||||
*/
|
||||
export interface BeadsStats {
|
||||
total: number;
|
||||
open: number;
|
||||
in_progress: number;
|
||||
blocked: number;
|
||||
closed: number;
|
||||
ready: number;
|
||||
average_lead_time?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data gathered from Beads issue tracker.
|
||||
*/
|
||||
export interface BeadsData {
|
||||
stats: BeadsStats;
|
||||
inProgress: BeadsIssue[];
|
||||
ready: BeadsIssue[];
|
||||
blocked: BeadsIssue[];
|
||||
recentlyClosed: BeadsIssue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Pull request from GitHub.
|
||||
*/
|
||||
export interface GitHubPR {
|
||||
number: number;
|
||||
title: string;
|
||||
state: "OPEN" | "CLOSED" | "MERGED";
|
||||
isDraft: boolean;
|
||||
author: { login: string };
|
||||
updatedAt: string;
|
||||
url: string;
|
||||
headRefName: string;
|
||||
statusCheckRollup?: {
|
||||
state: "SUCCESS" | "FAILURE" | "PENDING" | "EXPECTED";
|
||||
contexts?: Array<{
|
||||
name: string;
|
||||
state: string;
|
||||
conclusion?: string;
|
||||
}>;
|
||||
};
|
||||
reviewDecision?: "APPROVED" | "CHANGES_REQUESTED" | "REVIEW_REQUIRED" | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Actions workflow run.
|
||||
*/
|
||||
export interface GitHubWorkflowRun {
|
||||
name: string;
|
||||
status: string;
|
||||
conclusion: string | null;
|
||||
createdAt: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data gathered from GitHub.
|
||||
*/
|
||||
export interface GitHubData {
|
||||
repo: string;
|
||||
openPRs: GitHubPR[];
|
||||
recentRuns: GitHubWorkflowRun[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Branch in a Graphite stack.
|
||||
*/
|
||||
export interface GraphiteBranch {
|
||||
name: string;
|
||||
prNumber?: number;
|
||||
prStatus?: "draft" | "open" | "ready" | "merged" | "closed";
|
||||
prUrl?: string;
|
||||
parent?: string;
|
||||
children: string[];
|
||||
isCurrent: boolean;
|
||||
needsRestack: boolean;
|
||||
needsSubmit: boolean;
|
||||
commitCount: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data gathered from Graphite.
|
||||
*/
|
||||
export interface GraphiteData {
|
||||
currentBranch: string;
|
||||
trunk: string;
|
||||
branches: GraphiteBranch[];
|
||||
stacks: string[][]; // Each stack as array of branch names
|
||||
}
|
||||
|
||||
/**
|
||||
* Issue from Linear.
|
||||
*/
|
||||
export interface LinearIssue {
|
||||
identifier: string;
|
||||
title: string;
|
||||
state: {
|
||||
name: string;
|
||||
type: string;
|
||||
};
|
||||
priority: number;
|
||||
assignee?: { name: string };
|
||||
labels: Array<{ name: string }>;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Data gathered from Linear.
|
||||
*/
|
||||
export interface LinearData {
|
||||
team?: string;
|
||||
issues: LinearIssue[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Aggregated status report result.
|
||||
*/
|
||||
export interface SitrepResult {
|
||||
timeConstraint: string;
|
||||
timestamp: string;
|
||||
sources: string[];
|
||||
results: {
|
||||
graphite?: GathererResult<GraphiteData>;
|
||||
github?: GathererResult<GitHubData>;
|
||||
linear?: GathererResult<LinearData>;
|
||||
beads?: GathererResult<BeadsData>;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,346 @@
|
||||
#!/usr/bin/env bun
|
||||
/**
|
||||
* sitrep.ts - Status report orchestrator
|
||||
*
|
||||
* Entry point for gathering status data from multiple sources.
|
||||
* Runs gatherers in parallel, aggregates results, outputs JSON or text.
|
||||
*
|
||||
* Usage:
|
||||
* ./sitrep.ts # All sources, 24h default
|
||||
* ./sitrep.ts -t 7d # All sources, last 7 days
|
||||
* ./sitrep.ts -s github,beads # Specific sources only
|
||||
* ./sitrep.ts -t 24h -s graphite # Combined
|
||||
* ./sitrep.ts --format=text # Human-readable output
|
||||
*/
|
||||
|
||||
import { parseArgs } from "node:util";
|
||||
import { formatTimeConstraint, toRelativeTime } from "./lib/time";
|
||||
import type {
|
||||
BeadsData,
|
||||
GathererResult,
|
||||
GitHubData,
|
||||
GraphiteData,
|
||||
LinearData,
|
||||
SitrepResult,
|
||||
} from "./lib/types";
|
||||
|
||||
const SOURCES = ["graphite", "github", "linear", "beads"] as const;
|
||||
/** Available status data sources. */
|
||||
type Source = (typeof SOURCES)[number];
|
||||
|
||||
const { values } = parseArgs({
|
||||
args: Bun.argv.slice(2),
|
||||
options: {
|
||||
time: { type: "string", short: "t", default: "24h" },
|
||||
sources: { type: "string", short: "s" },
|
||||
format: { type: "string", short: "f", default: "json" },
|
||||
help: { type: "boolean", short: "h" },
|
||||
},
|
||||
});
|
||||
|
||||
if (values.help) {
|
||||
console.log(`
|
||||
sitrep.ts - Generate status report across multiple sources
|
||||
|
||||
Usage:
|
||||
./sitrep.ts [options]
|
||||
|
||||
Options:
|
||||
-t, --time <constraint> Time constraint (24h, 7d, 2w) [default: 24h]
|
||||
-s, --sources <list> Comma-separated sources: graphite,github,linear,beads,all
|
||||
[default: auto-detect available]
|
||||
-f, --format <fmt> Output format: json, text [default: json]
|
||||
-h, --help Show this help
|
||||
|
||||
Examples:
|
||||
./sitrep.ts # All available sources, last 24 hours
|
||||
./sitrep.ts -t 7d # Last 7 days
|
||||
./sitrep.ts -s github,beads # Only GitHub and Beads
|
||||
./sitrep.ts --format=text # Human-readable output
|
||||
|
||||
Sources:
|
||||
graphite - Stack structure, branches, PR status (requires gt CLI)
|
||||
github - Open PRs, CI status, workflow runs (requires gh CLI)
|
||||
linear - Issues from Linear (requires Linear MCP in Claude settings)
|
||||
beads - Local issues from .beads/ directory
|
||||
`);
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Get script directory for running gatherers
|
||||
const scriptDir = import.meta.dir;
|
||||
|
||||
/**
|
||||
* Runs a gatherer script for a specific source.
|
||||
* @param source - Source to gather data from
|
||||
* @returns Gatherer result with data or error
|
||||
*/
|
||||
async function runGatherer<T>(source: Source): Promise<GathererResult<T>> {
|
||||
const gathererPath = `${scriptDir}/gatherers/${source}-gatherer.ts`;
|
||||
const timeValue = values.time ?? "24h";
|
||||
const args = [gathererPath, "-t", timeValue];
|
||||
|
||||
const proc = Bun.spawn(["bun", ...args], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
|
||||
const stdout = await new Response(proc.stdout).text();
|
||||
const stderr = await new Response(proc.stderr).text();
|
||||
const exitCode = await proc.exited;
|
||||
|
||||
if (exitCode !== 0) {
|
||||
return {
|
||||
source,
|
||||
status: "error",
|
||||
error: stderr || `Gatherer exited with code ${exitCode}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(stdout) as GathererResult<T>;
|
||||
} catch {
|
||||
return {
|
||||
source,
|
||||
status: "error",
|
||||
error: `Failed to parse gatherer output: ${stdout.slice(0, 200)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses source list from command line arguments.
|
||||
* @returns Array of validated source names
|
||||
*/
|
||||
function parseSources(): Source[] {
|
||||
if (!values.sources || values.sources === "all") {
|
||||
return [...SOURCES];
|
||||
}
|
||||
|
||||
const requested = values.sources
|
||||
.split(",")
|
||||
.map((s) => s.trim().toLowerCase());
|
||||
const valid: Source[] = [];
|
||||
|
||||
for (const s of requested) {
|
||||
if (SOURCES.includes(s as Source)) {
|
||||
valid.push(s as Source);
|
||||
} else {
|
||||
console.error(`Warning: Unknown source "${s}", skipping`);
|
||||
}
|
||||
}
|
||||
|
||||
return valid.length > 0 ? valid : [...SOURCES];
|
||||
}
|
||||
|
||||
/**
|
||||
* Gathers status data from all specified sources in parallel.
|
||||
* @param sources - Sources to gather data from
|
||||
* @returns Aggregated sitrep result
|
||||
*/
|
||||
async function gatherAll(sources: Source[]): Promise<SitrepResult> {
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
// Run all gatherers in parallel
|
||||
const promises = sources.map(async (source) => {
|
||||
switch (source) {
|
||||
case "graphite":
|
||||
return { source, result: await runGatherer<GraphiteData>(source) };
|
||||
case "github":
|
||||
return { source, result: await runGatherer<GitHubData>(source) };
|
||||
case "linear":
|
||||
return { source, result: await runGatherer<LinearData>(source) };
|
||||
case "beads":
|
||||
return { source, result: await runGatherer<BeadsData>(source) };
|
||||
}
|
||||
});
|
||||
|
||||
const settled = await Promise.allSettled(promises);
|
||||
|
||||
// Build results object
|
||||
const results: SitrepResult["results"] = {};
|
||||
|
||||
for (const item of settled) {
|
||||
if (item.status === "fulfilled") {
|
||||
const { source, result } = item.value;
|
||||
results[source] = result;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
timeConstraint: values.time ?? "24h",
|
||||
timestamp,
|
||||
sources,
|
||||
results,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Formats sitrep result as human-readable text report.
|
||||
* @param result - Sitrep result to format
|
||||
* @returns Formatted text output
|
||||
*/
|
||||
function formatTextReport(result: SitrepResult): string {
|
||||
const lines: string[] = [];
|
||||
const timeLabel = formatTimeConstraint(result.timeConstraint);
|
||||
|
||||
lines.push(`SITREP — ${timeLabel}`);
|
||||
lines.push(`Generated: ${new Date(result.timestamp).toLocaleString()}`);
|
||||
lines.push("");
|
||||
|
||||
// Graphite section
|
||||
if (result.results.graphite) {
|
||||
const g = result.results.graphite;
|
||||
if (g.status === "success" && g.data) {
|
||||
const data = g.data as GraphiteData;
|
||||
lines.push(
|
||||
`📊 GRAPHITE (${data.stacks.length} stacks, ${data.branches.length} branches)`,
|
||||
);
|
||||
lines.push(` Current: ${data.currentBranch}`);
|
||||
|
||||
for (const branch of data.branches) {
|
||||
const current = branch.isCurrent ? " ●" : "";
|
||||
const pr = branch.prNumber ? ` PR #${branch.prNumber}` : "";
|
||||
const status = branch.prStatus ? ` [${branch.prStatus}]` : "";
|
||||
const flags: string[] = [];
|
||||
if (branch.needsRestack) flags.push("needs restack");
|
||||
if (branch.needsSubmit) flags.push("needs submit");
|
||||
const flagStr = flags.length > 0 ? ` (${flags.join(", ")})` : "";
|
||||
|
||||
lines.push(` ${branch.name}${current}${pr}${status}${flagStr}`);
|
||||
}
|
||||
lines.push("");
|
||||
} else if (g.status === "unavailable") {
|
||||
lines.push(`📊 GRAPHITE: ${g.reason}`);
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
// GitHub section
|
||||
if (result.results.github) {
|
||||
const gh = result.results.github;
|
||||
if (gh.status === "success" && gh.data) {
|
||||
const data = gh.data as GitHubData;
|
||||
lines.push(`🔀 GITHUB (${data.openPRs.length} open PRs)`);
|
||||
lines.push(` Repo: ${data.repo}`);
|
||||
|
||||
for (const pr of data.openPRs) {
|
||||
const draft = pr.isDraft ? " [draft]" : "";
|
||||
const ci = pr.statusCheckRollup?.state
|
||||
? ` CI: ${pr.statusCheckRollup.state.toLowerCase()}`
|
||||
: "";
|
||||
const review = pr.reviewDecision
|
||||
? ` Review: ${pr.reviewDecision.toLowerCase()}`
|
||||
: "";
|
||||
const time = toRelativeTime(pr.updatedAt);
|
||||
|
||||
lines.push(` #${pr.number}: ${pr.title}${draft}`);
|
||||
lines.push(` ${ci}${review} — ${time}`);
|
||||
}
|
||||
|
||||
if (data.recentRuns.length > 0) {
|
||||
const failed = data.recentRuns.filter(
|
||||
(r) => r.conclusion === "failure",
|
||||
).length;
|
||||
const passed = data.recentRuns.filter(
|
||||
(r) => r.conclusion === "success",
|
||||
).length;
|
||||
lines.push(` Workflow runs: ${passed} passed, ${failed} failed`);
|
||||
}
|
||||
lines.push("");
|
||||
} else if (gh.status === "unavailable") {
|
||||
lines.push(`🔀 GITHUB: ${gh.reason}`);
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
// Linear section
|
||||
if (result.results.linear) {
|
||||
const lin = result.results.linear;
|
||||
if (lin.status === "success" && lin.data) {
|
||||
const data = lin.data as LinearData;
|
||||
lines.push(`📋 LINEAR (${data.issues.length} issues)`);
|
||||
|
||||
for (const issue of data.issues.slice(0, 10)) {
|
||||
const assignee = issue.assignee ? ` @${issue.assignee.name}` : "";
|
||||
const time = toRelativeTime(issue.updatedAt);
|
||||
|
||||
lines.push(` ${issue.identifier}: ${issue.title}`);
|
||||
lines.push(` [${issue.state.name}]${assignee} — ${time}`);
|
||||
}
|
||||
lines.push("");
|
||||
} else if (lin.status === "unavailable") {
|
||||
lines.push(`📋 LINEAR: ${lin.reason}`);
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
// Beads section
|
||||
if (result.results.beads) {
|
||||
const b = result.results.beads;
|
||||
if (b.status === "success" && b.data) {
|
||||
const data = b.data as BeadsData;
|
||||
const { stats } = data;
|
||||
|
||||
lines.push(
|
||||
`📝 BEADS (${stats.total} total, ${stats.open} open, ${stats.in_progress} active, ${stats.blocked} blocked)`,
|
||||
);
|
||||
|
||||
if (data.inProgress.length > 0) {
|
||||
lines.push(" In Progress:");
|
||||
for (const issue of data.inProgress) {
|
||||
const time = toRelativeTime(issue.updated_at);
|
||||
lines.push(` ${issue.id}: ${issue.title} — ${time}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.ready.length > 0) {
|
||||
lines.push(" Ready to Work:");
|
||||
for (const issue of data.ready.slice(0, 5)) {
|
||||
const priority = ["", "🔴", "🟠", "🟡", "⚪"][issue.priority] || "";
|
||||
lines.push(` ${priority} ${issue.id}: ${issue.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.blocked.length > 0) {
|
||||
lines.push(` Blocked (${data.blocked.length}):`);
|
||||
for (const issue of data.blocked.slice(0, 3)) {
|
||||
lines.push(` ⛔ ${issue.id}: ${issue.title}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (data.recentlyClosed.length > 0) {
|
||||
lines.push(` Recently Closed (${data.recentlyClosed.length}):`);
|
||||
for (const issue of data.recentlyClosed.slice(0, 3)) {
|
||||
const time = toRelativeTime(issue.closed_at || issue.updated_at);
|
||||
lines.push(` ✓ ${issue.id}: ${issue.title} — ${time}`);
|
||||
}
|
||||
}
|
||||
lines.push("");
|
||||
} else if (b.status === "unavailable") {
|
||||
lines.push(`📝 BEADS: ${b.reason}`);
|
||||
lines.push("");
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
// Main execution
|
||||
async function main() {
|
||||
const sources = parseSources();
|
||||
const result = await gatherAll(sources);
|
||||
|
||||
if (values.format === "text") {
|
||||
console.log(formatTextReport(result));
|
||||
} else {
|
||||
console.log(JSON.stringify(result, null, 2));
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Fatal error:", err);
|
||||
process.exit(1);
|
||||
});
|
||||
Reference in New Issue
Block a user