linear-ai-build 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +3 -0
- package/README.md +220 -0
- package/bin/cli.js +21 -0
- package/bin/cli.ts +51 -0
- package/bin/init.ts +252 -0
- package/commands/build-from-linear.md +193 -0
- package/commands/generate-stories.md +110 -0
- package/dist/bridge/config.d.ts +11 -0
- package/dist/bridge/config.js +24 -0
- package/dist/bridge/executor.d.ts +8 -0
- package/dist/bridge/executor.js +147 -0
- package/dist/bridge/index.d.ts +3 -0
- package/dist/bridge/index.js +137 -0
- package/dist/bridge/linear-helpers.d.ts +6 -0
- package/dist/bridge/linear-helpers.js +43 -0
- package/dist/bridge/reporter.d.ts +7 -0
- package/dist/bridge/reporter.js +79 -0
- package/dist/bridge/state.d.ts +10 -0
- package/dist/bridge/state.js +37 -0
- package/dist/bridge/transformer.d.ts +22 -0
- package/dist/bridge/transformer.js +142 -0
- package/dist/bridge/types.d.ts +44 -0
- package/dist/bridge/types.js +1 -0
- package/dist/bridge/watcher.d.ts +13 -0
- package/dist/bridge/watcher.js +104 -0
- package/dist/generate-sdk.d.ts +1 -0
- package/dist/generate-sdk.js +471 -0
- package/dist/index.d.ts +33 -0
- package/dist/index.js +232 -0
- package/package.json +61 -0
- package/src/bridge/config.ts +39 -0
- package/src/bridge/executor.ts +167 -0
- package/src/bridge/index.ts +156 -0
- package/src/bridge/linear-helpers.ts +49 -0
- package/src/bridge/reporter.ts +93 -0
- package/src/bridge/state.ts +50 -0
- package/src/bridge/transformer.ts +173 -0
- package/src/bridge/types.ts +45 -0
- package/src/bridge/watcher.ts +122 -0
- package/src/generate-sdk.ts +570 -0
- package/src/index.ts +287 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import Anthropic from '@anthropic-ai/sdk';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
// Initialize Anthropic client
|
|
5
|
+
const anthropic = new Anthropic({
|
|
6
|
+
apiKey: process.env.ANTHROPIC_API_KEY,
|
|
7
|
+
});
|
|
8
|
+
/**
|
|
9
|
+
* Step 1: Generate PRD from feature description using Claude
|
|
10
|
+
*/
|
|
11
|
+
async function generatePRD(featureDescription) {
|
|
12
|
+
console.log('🤔 Generating PRD with Claude...\n');
|
|
13
|
+
const response = await anthropic.messages.create({
|
|
14
|
+
model: 'claude-sonnet-4-20250514',
|
|
15
|
+
max_tokens: 4000,
|
|
16
|
+
messages: [{
|
|
17
|
+
role: 'user',
|
|
18
|
+
content: `You are an expert product manager. Create a comprehensive Product Requirements Document (PRD) for the following feature:
|
|
19
|
+
|
|
20
|
+
FEATURE REQUEST:
|
|
21
|
+
${featureDescription}
|
|
22
|
+
|
|
23
|
+
Generate a detailed PRD with:
|
|
24
|
+
1. Executive summary
|
|
25
|
+
2. 3-5 user stories following the format: "As a [persona], I want to [action] so that [benefit]"
|
|
26
|
+
3. For each story, include acceptance criteria
|
|
27
|
+
4. Technical notes and considerations
|
|
28
|
+
|
|
29
|
+
Respond ONLY with valid JSON matching this schema:
|
|
30
|
+
{
|
|
31
|
+
"title": "Feature Title",
|
|
32
|
+
"summary": "2-3 sentence executive summary",
|
|
33
|
+
"stories": [
|
|
34
|
+
{
|
|
35
|
+
"title": "Story title",
|
|
36
|
+
"description": "As a [persona], I want to [action] so that [benefit]",
|
|
37
|
+
"persona": "User type",
|
|
38
|
+
"acceptanceCriteria": ["criterion 1", "criterion 2"],
|
|
39
|
+
"priority": "high|medium|low"
|
|
40
|
+
}
|
|
41
|
+
],
|
|
42
|
+
"technicalNotes": ["note 1", "note 2"]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
Return ONLY the JSON, no markdown formatting or explanation.`
|
|
46
|
+
}]
|
|
47
|
+
});
|
|
48
|
+
const content = response.content[0];
|
|
49
|
+
if (content.type !== 'text') {
|
|
50
|
+
throw new Error('Unexpected response type');
|
|
51
|
+
}
|
|
52
|
+
// Parse JSON response
|
|
53
|
+
const prd = JSON.parse(content.text);
|
|
54
|
+
// Save PRD to file
|
|
55
|
+
const prdsDir = path.join(process.cwd(), '.linear', 'prds');
|
|
56
|
+
await fs.mkdir(prdsDir, { recursive: true });
|
|
57
|
+
const filename = `${prd.title.toLowerCase().replace(/\s+/g, '-')}-${Date.now()}.json`;
|
|
58
|
+
await fs.writeFile(path.join(prdsDir, filename), JSON.stringify(prd, null, 2));
|
|
59
|
+
console.log(`✅ PRD generated and saved to .linear/prds/${filename}\n`);
|
|
60
|
+
return prd;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Step 2: Break down each story into tasks using Claude
|
|
64
|
+
*/
|
|
65
|
+
async function generateTasks(story) {
|
|
66
|
+
console.log(` 🔨 Breaking down: ${story.title}...`);
|
|
67
|
+
const response = await anthropic.messages.create({
|
|
68
|
+
model: 'claude-sonnet-4-20250514',
|
|
69
|
+
max_tokens: 2000,
|
|
70
|
+
messages: [{
|
|
71
|
+
role: 'user',
|
|
72
|
+
content: `Break down this user story into concrete development tasks:
|
|
73
|
+
|
|
74
|
+
STORY: ${story.title}
|
|
75
|
+
DESCRIPTION: ${story.description}
|
|
76
|
+
ACCEPTANCE CRITERIA:
|
|
77
|
+
${story.acceptanceCriteria.map((ac, i) => `${i + 1}. ${ac}`).join('\n')}
|
|
78
|
+
|
|
79
|
+
Create 3-7 specific tasks covering:
|
|
80
|
+
- Frontend/UI work
|
|
81
|
+
- Backend/API work
|
|
82
|
+
- Database changes (if needed)
|
|
83
|
+
- Testing requirements
|
|
84
|
+
|
|
85
|
+
For each task provide:
|
|
86
|
+
- Clear title (verb-object format like "Create login form" or "Implement JWT authentication")
|
|
87
|
+
- Description with technical details
|
|
88
|
+
- Effort estimate (1=small, 3=medium, 5=large)
|
|
89
|
+
- Whether it can run in parallel with other tasks
|
|
90
|
+
|
|
91
|
+
Respond ONLY with valid JSON array:
|
|
92
|
+
[
|
|
93
|
+
{
|
|
94
|
+
"title": "Task title",
|
|
95
|
+
"description": "Technical details of what needs to be done",
|
|
96
|
+
"effort": 1-5,
|
|
97
|
+
"parallel": true|false
|
|
98
|
+
}
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
Return ONLY the JSON array, no markdown or explanation.`
|
|
102
|
+
}]
|
|
103
|
+
});
|
|
104
|
+
const content = response.content[0];
|
|
105
|
+
if (content.type !== 'text') {
|
|
106
|
+
throw new Error('Unexpected response type');
|
|
107
|
+
}
|
|
108
|
+
const tasks = JSON.parse(content.text);
|
|
109
|
+
console.log(` ✓ Generated ${tasks.length} tasks`);
|
|
110
|
+
return tasks;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Step 3: Create issues in Linear using MCP tools
|
|
114
|
+
*/
|
|
115
|
+
async function createInLinear(prd, teamId, projectName) {
|
|
116
|
+
console.log('\n📝 Creating issues in Linear via MCP...\n');
|
|
117
|
+
// Build the tool use request for Claude
|
|
118
|
+
const toolMessages = [
|
|
119
|
+
{
|
|
120
|
+
role: 'user',
|
|
121
|
+
content: `You have access to Linear through MCP tools. Create a project and issues for this PRD:
|
|
122
|
+
|
|
123
|
+
PROJECT NAME: ${projectName || prd.title}
|
|
124
|
+
TEAM ID: ${teamId}
|
|
125
|
+
|
|
126
|
+
PRD DATA:
|
|
127
|
+
${JSON.stringify(prd, null, 2)}
|
|
128
|
+
|
|
129
|
+
INSTRUCTIONS:
|
|
130
|
+
1. First, search for existing projects with name "${projectName || prd.title}" to avoid duplicates
|
|
131
|
+
2. If project doesn't exist, create it with the PRD summary as description
|
|
132
|
+
3. For each story in the PRD:
|
|
133
|
+
- Create a parent issue with the story title and description
|
|
134
|
+
- Add label "user-story" if available
|
|
135
|
+
- Set priority based on the story's priority field
|
|
136
|
+
4. For each task within a story:
|
|
137
|
+
- Create a child issue linked to the parent story
|
|
138
|
+
- Include effort estimate in the description
|
|
139
|
+
- Add label "task" if available
|
|
140
|
+
|
|
141
|
+
After creating all issues, provide a summary with:
|
|
142
|
+
- Project URL
|
|
143
|
+
- Number of stories created
|
|
144
|
+
- Number of tasks created
|
|
145
|
+
- Any issues encountered
|
|
146
|
+
|
|
147
|
+
Use Linear MCP tools to accomplish this.`
|
|
148
|
+
}
|
|
149
|
+
];
|
|
150
|
+
const response = await anthropic.messages.create({
|
|
151
|
+
model: 'claude-sonnet-4-20250514',
|
|
152
|
+
max_tokens: 8000,
|
|
153
|
+
messages: toolMessages,
|
|
154
|
+
tools: [
|
|
155
|
+
{
|
|
156
|
+
type: 'mcp',
|
|
157
|
+
name: 'linear',
|
|
158
|
+
mcp_server_name: 'linear'
|
|
159
|
+
}
|
|
160
|
+
]
|
|
161
|
+
});
|
|
162
|
+
// Process the response (Claude will have used Linear MCP tools)
|
|
163
|
+
let finalResponse = '';
|
|
164
|
+
for (const block of response.content) {
|
|
165
|
+
if (block.type === 'text') {
|
|
166
|
+
finalResponse += block.text;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
console.log('\n✅ Linear issues created!\n');
|
|
170
|
+
return finalResponse;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Main workflow
|
|
174
|
+
*/
|
|
175
|
+
async function main() {
|
|
176
|
+
const args = process.argv.slice(2);
|
|
177
|
+
if (args.length === 0) {
|
|
178
|
+
console.log(`
|
|
179
|
+
Usage: npm start -- "Feature description" [teamId] [projectName]
|
|
180
|
+
|
|
181
|
+
Example:
|
|
182
|
+
npm start -- "Add user authentication with social login" "TEAM-123" "Auth Epic"
|
|
183
|
+
|
|
184
|
+
Environment variables required:
|
|
185
|
+
ANTHROPIC_API_KEY=sk-ant-...
|
|
186
|
+
LINEAR_API_KEY=lin_api_... (for MCP server)
|
|
187
|
+
`);
|
|
188
|
+
process.exit(1);
|
|
189
|
+
}
|
|
190
|
+
const featureDescription = args[0];
|
|
191
|
+
const teamId = args[1] || process.env.LINEAR_TEAM_ID;
|
|
192
|
+
const projectName = args[2];
|
|
193
|
+
if (!teamId) {
|
|
194
|
+
console.error('❌ Error: Team ID required (provide as argument or LINEAR_TEAM_ID env var)');
|
|
195
|
+
process.exit(1);
|
|
196
|
+
}
|
|
197
|
+
try {
|
|
198
|
+
// Step 1: Generate PRD
|
|
199
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
200
|
+
console.log(' STEP 1: Generate PRD');
|
|
201
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
|
202
|
+
const prd = await generatePRD(featureDescription);
|
|
203
|
+
console.log(`📋 PRD Summary:`);
|
|
204
|
+
console.log(` Title: ${prd.title}`);
|
|
205
|
+
console.log(` Stories: ${prd.stories.length}`);
|
|
206
|
+
console.log('');
|
|
207
|
+
// Step 2: Generate tasks for each story
|
|
208
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
209
|
+
console.log(' STEP 2: Generate Tasks');
|
|
210
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n');
|
|
211
|
+
for (const story of prd.stories) {
|
|
212
|
+
story.tasks = await generateTasks(story);
|
|
213
|
+
}
|
|
214
|
+
const totalTasks = prd.stories.reduce((sum, s) => sum + s.tasks.length, 0);
|
|
215
|
+
console.log(`\n✅ Generated ${totalTasks} total tasks across ${prd.stories.length} stories\n`);
|
|
216
|
+
// Step 3: Create in Linear
|
|
217
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
218
|
+
console.log(' STEP 3: Create in Linear');
|
|
219
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
220
|
+
const summary = await createInLinear(prd, teamId, projectName);
|
|
221
|
+
console.log(summary);
|
|
222
|
+
}
|
|
223
|
+
catch (error) {
|
|
224
|
+
console.error('❌ Error:', error);
|
|
225
|
+
process.exit(1);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
// Run if called directly
|
|
229
|
+
if (require.main === module) {
|
|
230
|
+
main();
|
|
231
|
+
}
|
|
232
|
+
export { generatePRD, generateTasks, createInLinear };
|
package/package.json
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "linear-ai-build",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Generate user stories from feature descriptions, push them to Linear, then have AI agents build the code with Claude Flow",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"bin": {
|
|
8
|
+
"linear-ai-build": "bin/cli.js"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"build": "tsc",
|
|
12
|
+
"start": "tsx src/index.ts",
|
|
13
|
+
"generate": "tsx src/generate-sdk.ts",
|
|
14
|
+
"init": "tsx bin/init.ts",
|
|
15
|
+
"dev": "tsx watch src/index.ts",
|
|
16
|
+
"clean": "rm -rf dist",
|
|
17
|
+
"prepublishOnly": "npm run build",
|
|
18
|
+
"watch": "tsx bin/cli.ts watch",
|
|
19
|
+
"build:issue": "tsx bin/cli.ts build"
|
|
20
|
+
},
|
|
21
|
+
"files": [
|
|
22
|
+
"dist",
|
|
23
|
+
"bin",
|
|
24
|
+
"commands",
|
|
25
|
+
"src",
|
|
26
|
+
".env.example",
|
|
27
|
+
"README.md"
|
|
28
|
+
],
|
|
29
|
+
"keywords": [
|
|
30
|
+
"linear",
|
|
31
|
+
"claude",
|
|
32
|
+
"claude-code",
|
|
33
|
+
"claude-flow",
|
|
34
|
+
"mcp",
|
|
35
|
+
"user-stories",
|
|
36
|
+
"agile",
|
|
37
|
+
"ai",
|
|
38
|
+
"ai-agents",
|
|
39
|
+
"code-generation",
|
|
40
|
+
"automation"
|
|
41
|
+
],
|
|
42
|
+
"repository": {
|
|
43
|
+
"type": "git",
|
|
44
|
+
"url": "git+https://github.com/alwick/linear-ai-build.git"
|
|
45
|
+
},
|
|
46
|
+
"author": "",
|
|
47
|
+
"license": "MIT",
|
|
48
|
+
"dependencies": {
|
|
49
|
+
"@anthropic-ai/sdk": "^0.32.1",
|
|
50
|
+
"@linear/sdk": "^29.0.0",
|
|
51
|
+
"dotenv": "^16.4.5",
|
|
52
|
+
"tsx": "^4.7.0"
|
|
53
|
+
},
|
|
54
|
+
"devDependencies": {
|
|
55
|
+
"@types/node": "^20.11.0",
|
|
56
|
+
"typescript": "^5.3.3"
|
|
57
|
+
},
|
|
58
|
+
"engines": {
|
|
59
|
+
"node": ">=18.0.0"
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import * as path from 'path';
|
|
2
|
+
|
|
3
|
+
export interface BridgeConfig {
|
|
4
|
+
linearApiKey: string;
|
|
5
|
+
linearTeamId: string;
|
|
6
|
+
pollIntervalMs: number;
|
|
7
|
+
aiLabel: string;
|
|
8
|
+
targetRepoPath: string;
|
|
9
|
+
targetBranch: string;
|
|
10
|
+
contextDir: string;
|
|
11
|
+
processedFile: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function loadConfig(): BridgeConfig {
|
|
15
|
+
const linearApiKey = process.env.LINEAR_API_KEY;
|
|
16
|
+
if (!linearApiKey) {
|
|
17
|
+
console.error('Missing LINEAR_API_KEY environment variable');
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const linearTeamId = process.env.LINEAR_TEAM_ID;
|
|
22
|
+
if (!linearTeamId) {
|
|
23
|
+
console.error('Missing LINEAR_TEAM_ID environment variable');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const targetRepoPath = process.env.TARGET_REPO_PATH || process.cwd();
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
linearApiKey,
|
|
31
|
+
linearTeamId,
|
|
32
|
+
pollIntervalMs: parseInt(process.env.POLL_INTERVAL_MS || '30000', 10),
|
|
33
|
+
aiLabel: process.env.AI_BUILD_LABEL || 'ai-build',
|
|
34
|
+
targetRepoPath,
|
|
35
|
+
targetBranch: process.env.TARGET_BRANCH || 'main',
|
|
36
|
+
contextDir: path.join(targetRepoPath, '.linear', 'context'),
|
|
37
|
+
processedFile: path.join(targetRepoPath, '.linear', 'processed.json'),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { spawn, execSync } from 'child_process';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as path from 'path';
|
|
4
|
+
import type { LinearIssueContext, WorkflowResult } from './types.js';
|
|
5
|
+
import { buildWorkflow } from './transformer.js';
|
|
6
|
+
import type { BridgeConfig } from './config.js';
|
|
7
|
+
|
|
8
|
+
function createBranch(identifier: string, baseBranch: string, cwd: string): string {
|
|
9
|
+
const branchName = `linear/${identifier.toLowerCase()}`;
|
|
10
|
+
|
|
11
|
+
try {
|
|
12
|
+
execSync(`git checkout ${baseBranch}`, { cwd, stdio: 'pipe' });
|
|
13
|
+
} catch {
|
|
14
|
+
console.log(` Warning: could not checkout ${baseBranch}, continuing on current branch`);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
execSync(`git pull origin ${baseBranch}`, { cwd, stdio: 'pipe' });
|
|
19
|
+
} catch {
|
|
20
|
+
console.log(` Warning: could not pull ${baseBranch}, continuing with local state`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
execSync(`git checkout -b ${branchName}`, { cwd, stdio: 'pipe' });
|
|
25
|
+
} catch {
|
|
26
|
+
// Branch may already exist
|
|
27
|
+
try {
|
|
28
|
+
execSync(`git checkout ${branchName}`, { cwd, stdio: 'pipe' });
|
|
29
|
+
} catch {
|
|
30
|
+
console.log(` Warning: could not create or checkout branch ${branchName}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return branchName;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export class WorkflowExecutor {
|
|
38
|
+
private config: BridgeConfig;
|
|
39
|
+
|
|
40
|
+
constructor(config: BridgeConfig) {
|
|
41
|
+
this.config = config;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async execute(ctx: LinearIssueContext): Promise<WorkflowResult> {
|
|
45
|
+
const identifier = ctx.story.identifier;
|
|
46
|
+
console.log(`\nExecuting workflow for ${identifier}: ${ctx.story.title}`);
|
|
47
|
+
|
|
48
|
+
// Create git branch in target repo
|
|
49
|
+
const branchName = createBranch(identifier, this.config.targetBranch, this.config.targetRepoPath);
|
|
50
|
+
console.log(` Branch: ${branchName}`);
|
|
51
|
+
|
|
52
|
+
// Write context file
|
|
53
|
+
await fs.mkdir(this.config.contextDir, { recursive: true });
|
|
54
|
+
const contextPath = path.join(this.config.contextDir, `${identifier}.json`);
|
|
55
|
+
await fs.writeFile(contextPath, JSON.stringify(ctx, null, 2));
|
|
56
|
+
|
|
57
|
+
// Build and write workflow JSON
|
|
58
|
+
const workflow = buildWorkflow(ctx);
|
|
59
|
+
const workflowPath = path.join(this.config.contextDir, `${identifier}-workflow.json`);
|
|
60
|
+
await fs.writeFile(workflowPath, JSON.stringify(workflow, null, 2));
|
|
61
|
+
console.log(` Workflow: ${workflowPath}`);
|
|
62
|
+
|
|
63
|
+
// Execute Claude Flow
|
|
64
|
+
try {
|
|
65
|
+
const output = await this.runClaudeFlow(workflowPath, contextPath);
|
|
66
|
+
console.log(` Workflow completed for ${identifier}`);
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
success: true,
|
|
70
|
+
branchName,
|
|
71
|
+
summary: `Workflow completed for ${identifier}`,
|
|
72
|
+
errors: [],
|
|
73
|
+
taskResults: ctx.tasks.map(t => ({
|
|
74
|
+
taskId: t.id,
|
|
75
|
+
taskIdentifier: t.identifier,
|
|
76
|
+
status: 'completed' as const,
|
|
77
|
+
summary: `Implemented: ${t.title}`,
|
|
78
|
+
})),
|
|
79
|
+
};
|
|
80
|
+
} catch (err) {
|
|
81
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
82
|
+
console.error(` Workflow failed for ${identifier}: ${errorMsg}`);
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
success: false,
|
|
86
|
+
branchName,
|
|
87
|
+
summary: `Workflow failed for ${identifier}: ${errorMsg}`,
|
|
88
|
+
errors: [errorMsg],
|
|
89
|
+
taskResults: ctx.tasks.map(t => ({
|
|
90
|
+
taskId: t.id,
|
|
91
|
+
taskIdentifier: t.identifier,
|
|
92
|
+
status: 'failed' as const,
|
|
93
|
+
summary: `Failed: ${errorMsg}`,
|
|
94
|
+
})),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
private runClaudeFlow(workflowPath: string, contextPath: string): Promise<string> {
|
|
100
|
+
return new Promise((resolve, reject) => {
|
|
101
|
+
const child = spawn(
|
|
102
|
+
'claude-flow',
|
|
103
|
+
[
|
|
104
|
+
'automation',
|
|
105
|
+
'run-workflow',
|
|
106
|
+
workflowPath,
|
|
107
|
+
'--claude',
|
|
108
|
+
'--non-interactive',
|
|
109
|
+
'--output-format',
|
|
110
|
+
'stream-json',
|
|
111
|
+
],
|
|
112
|
+
{
|
|
113
|
+
cwd: this.config.targetRepoPath,
|
|
114
|
+
env: {
|
|
115
|
+
...process.env,
|
|
116
|
+
CONTEXT_FILE: contextPath,
|
|
117
|
+
},
|
|
118
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
119
|
+
},
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
let stdout = '';
|
|
123
|
+
let stderr = '';
|
|
124
|
+
|
|
125
|
+
child.stdout.on('data', (data: Buffer) => {
|
|
126
|
+
const chunk = data.toString();
|
|
127
|
+
stdout += chunk;
|
|
128
|
+
for (const line of chunk.split('\n').filter(Boolean)) {
|
|
129
|
+
try {
|
|
130
|
+
const event = JSON.parse(line);
|
|
131
|
+
if (event.type === 'message') {
|
|
132
|
+
const text = event.content?.[0]?.text || '';
|
|
133
|
+
if (text) console.log(` [agent] ${text.slice(0, 120)}`);
|
|
134
|
+
}
|
|
135
|
+
} catch {
|
|
136
|
+
// Not JSON, log raw output
|
|
137
|
+
if (line.trim()) console.log(` [claude-flow] ${line.trim().slice(0, 120)}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
child.stderr.on('data', (data: Buffer) => {
|
|
143
|
+
stderr += data.toString();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
child.on('error', (err) => {
|
|
147
|
+
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
|
148
|
+
reject(
|
|
149
|
+
new Error(
|
|
150
|
+
'claude-flow not found. Install it with: npm install -g claude-flow@alpha',
|
|
151
|
+
),
|
|
152
|
+
);
|
|
153
|
+
} else {
|
|
154
|
+
reject(err);
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
child.on('close', (code: number | null) => {
|
|
159
|
+
if (code === 0) {
|
|
160
|
+
resolve(stdout);
|
|
161
|
+
} else {
|
|
162
|
+
reject(new Error(`claude-flow exited with code ${code}:\n${stderr || stdout}`));
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import 'dotenv/config';
|
|
2
|
+
import { loadConfig } from './config.js';
|
|
3
|
+
import { LinearWatcher } from './watcher.js';
|
|
4
|
+
import { WorkflowExecutor } from './executor.js';
|
|
5
|
+
import { LinearReporter } from './reporter.js';
|
|
6
|
+
import { ProcessingState } from './state.js';
|
|
7
|
+
import { getLinearClient, resolveTeamId } from './linear-helpers.js';
|
|
8
|
+
import type { LinearIssueContext } from './types.js';
|
|
9
|
+
|
|
10
|
+
function parseAcceptanceCriteria(description: string): string[] {
|
|
11
|
+
const match = description.match(
|
|
12
|
+
/\*\*Acceptance Criteria:?\*\*\s*\n([\s\S]*?)(?:\n\n|\n\*\*|$)/i,
|
|
13
|
+
);
|
|
14
|
+
if (!match) return [];
|
|
15
|
+
return match[1]
|
|
16
|
+
.split('\n')
|
|
17
|
+
.map(line => line.replace(/^\s*[-*\d.]+\s*/, '').trim())
|
|
18
|
+
.filter(Boolean);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function watch(): Promise<void> {
|
|
22
|
+
const config = loadConfig();
|
|
23
|
+
const state = new ProcessingState(config.processedFile);
|
|
24
|
+
await state.load();
|
|
25
|
+
|
|
26
|
+
const watcher = new LinearWatcher(config, state.getSet());
|
|
27
|
+
const executor = new WorkflowExecutor(config);
|
|
28
|
+
const reporter = new LinearReporter();
|
|
29
|
+
|
|
30
|
+
// Graceful shutdown
|
|
31
|
+
const shutdown = async () => {
|
|
32
|
+
console.log('\nShutting down...');
|
|
33
|
+
watcher.stop();
|
|
34
|
+
await state.save();
|
|
35
|
+
process.exit(0);
|
|
36
|
+
};
|
|
37
|
+
process.on('SIGINT', shutdown);
|
|
38
|
+
process.on('SIGTERM', shutdown);
|
|
39
|
+
|
|
40
|
+
watcher.onIssueFound = async (ctx: LinearIssueContext) => {
|
|
41
|
+
console.log(`\nFound issue: ${ctx.story.identifier} - ${ctx.story.title}`);
|
|
42
|
+
console.log(` Tasks: ${ctx.tasks.length}`);
|
|
43
|
+
console.log(` URL: ${ctx.story.url}`);
|
|
44
|
+
|
|
45
|
+
// Mark as processed to prevent re-processing
|
|
46
|
+
state.add(ctx.story.id);
|
|
47
|
+
await state.save();
|
|
48
|
+
|
|
49
|
+
try {
|
|
50
|
+
await reporter.markInProgress(ctx);
|
|
51
|
+
const result = await executor.execute(ctx);
|
|
52
|
+
|
|
53
|
+
if (result.success) {
|
|
54
|
+
await reporter.markComplete(ctx, result);
|
|
55
|
+
console.log(` Done: ${ctx.story.identifier} → branch ${result.branchName}`);
|
|
56
|
+
} else {
|
|
57
|
+
await reporter.markFailed(ctx, result.summary);
|
|
58
|
+
console.error(` Failed: ${ctx.story.identifier}`);
|
|
59
|
+
}
|
|
60
|
+
} catch (err) {
|
|
61
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
62
|
+
console.error(` Error processing ${ctx.story.identifier}: ${msg}`);
|
|
63
|
+
await reporter.markFailed(ctx, msg);
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
await watcher.start();
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function build(issueIdentifier: string): Promise<void> {
|
|
71
|
+
const config = loadConfig();
|
|
72
|
+
const executor = new WorkflowExecutor(config);
|
|
73
|
+
const reporter = new LinearReporter();
|
|
74
|
+
|
|
75
|
+
const linear = getLinearClient();
|
|
76
|
+
const teamId = await resolveTeamId(config.linearTeamId);
|
|
77
|
+
|
|
78
|
+
// Parse issue number from identifier (e.g., "WIC-42" → 42)
|
|
79
|
+
const parts = issueIdentifier.split('-');
|
|
80
|
+
const issueNumber = parseInt(parts[parts.length - 1], 10);
|
|
81
|
+
|
|
82
|
+
if (isNaN(issueNumber)) {
|
|
83
|
+
console.error(`Invalid issue identifier: ${issueIdentifier}`);
|
|
84
|
+
console.error('Expected format: TEAM-123 (e.g., WIC-42)');
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const issues = await linear.issues({
|
|
89
|
+
filter: {
|
|
90
|
+
team: { id: { eq: teamId } },
|
|
91
|
+
number: { eq: issueNumber },
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const issue = issues.nodes[0];
|
|
96
|
+
if (!issue) {
|
|
97
|
+
console.error(`Issue ${issueIdentifier} not found in team ${config.linearTeamId}`);
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const children = await issue.children();
|
|
102
|
+
const labels = await issue.labels();
|
|
103
|
+
const project = await issue.project;
|
|
104
|
+
|
|
105
|
+
const ctx: LinearIssueContext = {
|
|
106
|
+
story: {
|
|
107
|
+
id: issue.id,
|
|
108
|
+
identifier: issue.identifier,
|
|
109
|
+
title: issue.title,
|
|
110
|
+
description: issue.description || '',
|
|
111
|
+
url: issue.url,
|
|
112
|
+
priority: issue.priority,
|
|
113
|
+
branchName: issue.branchName,
|
|
114
|
+
labels: labels.nodes.map(l => l.name),
|
|
115
|
+
acceptanceCriteria: parseAcceptanceCriteria(issue.description || ''),
|
|
116
|
+
},
|
|
117
|
+
tasks: children.nodes.map(child => ({
|
|
118
|
+
id: child.id,
|
|
119
|
+
identifier: child.identifier,
|
|
120
|
+
title: child.title,
|
|
121
|
+
description: child.description || '',
|
|
122
|
+
effort: child.estimate ?? null,
|
|
123
|
+
url: child.url,
|
|
124
|
+
})),
|
|
125
|
+
project: project
|
|
126
|
+
? {
|
|
127
|
+
id: project.id,
|
|
128
|
+
name: project.name,
|
|
129
|
+
description: project.description || '',
|
|
130
|
+
url: project.url,
|
|
131
|
+
}
|
|
132
|
+
: undefined,
|
|
133
|
+
meta: {
|
|
134
|
+
teamId,
|
|
135
|
+
teamKey: config.linearTeamId,
|
|
136
|
+
fetchedAt: new Date().toISOString(),
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
console.log(`Building: ${ctx.story.identifier} - ${ctx.story.title}`);
|
|
141
|
+
console.log(` Tasks: ${ctx.tasks.length}`);
|
|
142
|
+
console.log(` URL: ${ctx.story.url}`);
|
|
143
|
+
console.log('');
|
|
144
|
+
|
|
145
|
+
await reporter.markInProgress(ctx);
|
|
146
|
+
const result = await executor.execute(ctx);
|
|
147
|
+
|
|
148
|
+
if (result.success) {
|
|
149
|
+
await reporter.markComplete(ctx, result);
|
|
150
|
+
console.log(`\nDone! Branch: ${result.branchName}`);
|
|
151
|
+
} else {
|
|
152
|
+
await reporter.markFailed(ctx, result.summary);
|
|
153
|
+
console.error(`\nFailed: ${result.summary}`);
|
|
154
|
+
process.exit(1);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { LinearClient } from '@linear/sdk';
|
|
2
|
+
|
|
3
|
+
let linear: LinearClient;
|
|
4
|
+
|
|
5
|
+
export function getLinearClient(): LinearClient {
|
|
6
|
+
if (!linear) {
|
|
7
|
+
const apiKey = process.env.LINEAR_API_KEY;
|
|
8
|
+
if (!apiKey) throw new Error('Missing LINEAR_API_KEY environment variable');
|
|
9
|
+
linear = new LinearClient({ apiKey });
|
|
10
|
+
}
|
|
11
|
+
return linear;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
15
|
+
|
|
16
|
+
export async function resolveTeamId(teamIdOrKey: string): Promise<string> {
|
|
17
|
+
if (UUID_RE.test(teamIdOrKey)) return teamIdOrKey;
|
|
18
|
+
|
|
19
|
+
const client = getLinearClient();
|
|
20
|
+
const viewer = await client.viewer;
|
|
21
|
+
const teams = await viewer.teams();
|
|
22
|
+
const match = teams.nodes.find(t => t.key.toLowerCase() === teamIdOrKey.toLowerCase());
|
|
23
|
+
if (!match) {
|
|
24
|
+
const available = teams.nodes.map(t => `${t.key} (${t.name})`).join(', ');
|
|
25
|
+
throw new Error(`Team key "${teamIdOrKey}" not found. Available teams: ${available}`);
|
|
26
|
+
}
|
|
27
|
+
console.log(` Resolved team: ${match.key} → ${match.name} (${match.id})`);
|
|
28
|
+
return match.id;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function findStateByType(teamId: string, type: string): Promise<string | undefined> {
|
|
32
|
+
const client = getLinearClient();
|
|
33
|
+
const states = await client.workflowStates({
|
|
34
|
+
filter: { team: { id: { eq: teamId } }, type: { eq: type } },
|
|
35
|
+
});
|
|
36
|
+
return states.nodes[0]?.id;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function findStateByName(teamId: string, name: string): Promise<string | undefined> {
|
|
40
|
+
const client = getLinearClient();
|
|
41
|
+
const states = await client.workflowStates({
|
|
42
|
+
filter: { team: { id: { eq: teamId } }, name: { eqIgnoreCase: name } },
|
|
43
|
+
});
|
|
44
|
+
return states.nodes[0]?.id;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function delay(ms: number): Promise<void> {
|
|
48
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
49
|
+
}
|