@colmbus72/yeehaw 0.5.0 → 0.6.1
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/claude-plugin/skills/yeehaw-development/SKILL.md +70 -0
- package/dist/app.js +166 -15
- package/dist/components/CritterHeader.d.ts +7 -0
- package/dist/components/CritterHeader.js +81 -0
- package/dist/components/List.d.ts +2 -0
- package/dist/components/List.js +1 -1
- package/dist/components/Panel.js +1 -1
- package/dist/components/ScrollableMarkdown.js +1 -1
- package/dist/lib/auth/index.d.ts +2 -0
- package/dist/lib/auth/index.js +3 -0
- package/dist/lib/auth/linear.d.ts +20 -0
- package/dist/lib/auth/linear.js +79 -0
- package/dist/lib/auth/storage.d.ts +12 -0
- package/dist/lib/auth/storage.js +53 -0
- package/dist/lib/context.d.ts +10 -0
- package/dist/lib/context.js +63 -0
- package/dist/lib/critters.d.ts +33 -0
- package/dist/lib/critters.js +164 -0
- package/dist/lib/hotkeys.d.ts +1 -1
- package/dist/lib/hotkeys.js +6 -2
- package/dist/lib/issues/github.d.ts +11 -0
- package/dist/lib/issues/github.js +154 -0
- package/dist/lib/issues/index.d.ts +14 -0
- package/dist/lib/issues/index.js +27 -0
- package/dist/lib/issues/linear.d.ts +24 -0
- package/dist/lib/issues/linear.js +345 -0
- package/dist/lib/issues/types.d.ts +82 -0
- package/dist/lib/issues/types.js +2 -0
- package/dist/lib/paths.d.ts +1 -0
- package/dist/lib/paths.js +1 -0
- package/dist/lib/tmux.d.ts +1 -0
- package/dist/lib/tmux.js +50 -1
- package/dist/types.d.ts +19 -0
- package/dist/views/BarnContext.d.ts +2 -1
- package/dist/views/BarnContext.js +136 -14
- package/dist/views/CritterDetailView.d.ts +10 -0
- package/dist/views/CritterDetailView.js +117 -0
- package/dist/views/CritterLogsView.d.ts +8 -0
- package/dist/views/CritterLogsView.js +100 -0
- package/dist/views/IssuesView.d.ts +2 -1
- package/dist/views/IssuesView.js +775 -103
- package/dist/views/LivestockDetailView.d.ts +2 -1
- package/dist/views/LivestockDetailView.js +8 -1
- package/dist/views/ProjectContext.js +35 -1
- package/package.json +1 -1
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// src/lib/auth/storage.ts
|
|
2
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
3
|
+
import YAML from 'js-yaml';
|
|
4
|
+
import { AUTH_FILE, YEEHAW_DIR } from '../paths.js';
|
|
5
|
+
import { mkdirSync } from 'fs';
|
|
6
|
+
export function loadAuth() {
|
|
7
|
+
if (!existsSync(YEEHAW_DIR)) {
|
|
8
|
+
mkdirSync(YEEHAW_DIR, { recursive: true });
|
|
9
|
+
}
|
|
10
|
+
if (!existsSync(AUTH_FILE)) {
|
|
11
|
+
return {};
|
|
12
|
+
}
|
|
13
|
+
try {
|
|
14
|
+
const content = readFileSync(AUTH_FILE, 'utf-8');
|
|
15
|
+
return YAML.load(content) || {};
|
|
16
|
+
}
|
|
17
|
+
catch {
|
|
18
|
+
return {};
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
export function saveAuth(auth) {
|
|
22
|
+
if (!existsSync(YEEHAW_DIR)) {
|
|
23
|
+
mkdirSync(YEEHAW_DIR, { recursive: true });
|
|
24
|
+
}
|
|
25
|
+
writeFileSync(AUTH_FILE, YAML.dump(auth), 'utf-8');
|
|
26
|
+
}
|
|
27
|
+
export function getLinearToken() {
|
|
28
|
+
const auth = loadAuth();
|
|
29
|
+
if (!auth.linear?.accessToken) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
// Check expiration if set
|
|
33
|
+
if (auth.linear.expiresAt) {
|
|
34
|
+
const expiresAt = new Date(auth.linear.expiresAt);
|
|
35
|
+
if (expiresAt <= new Date()) {
|
|
36
|
+
return null; // Token expired
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return auth.linear.accessToken;
|
|
40
|
+
}
|
|
41
|
+
export function setLinearToken(accessToken, expiresAt) {
|
|
42
|
+
const auth = loadAuth();
|
|
43
|
+
auth.linear = {
|
|
44
|
+
accessToken,
|
|
45
|
+
expiresAt: expiresAt?.toISOString(),
|
|
46
|
+
};
|
|
47
|
+
saveAuth(auth);
|
|
48
|
+
}
|
|
49
|
+
export function clearLinearToken() {
|
|
50
|
+
const auth = loadAuth();
|
|
51
|
+
delete auth.linear;
|
|
52
|
+
saveAuth(auth);
|
|
53
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Build a context string to inject into Claude sessions spawned from Yeehaw.
|
|
3
|
+
* Includes project name and wiki section titles (not content) to hint at available context.
|
|
4
|
+
*/
|
|
5
|
+
export declare function buildProjectContext(projectName: string): string | null;
|
|
6
|
+
/**
|
|
7
|
+
* Build context for a livestock-specific session.
|
|
8
|
+
* Includes project context plus livestock details.
|
|
9
|
+
*/
|
|
10
|
+
export declare function buildLivestockContext(projectName: string, livestockName: string): string | null;
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { loadProject } from './config.js';
|
|
2
|
+
/**
|
|
3
|
+
* Build a context string to inject into Claude sessions spawned from Yeehaw.
|
|
4
|
+
* Includes project name and wiki section titles (not content) to hint at available context.
|
|
5
|
+
*/
|
|
6
|
+
export function buildProjectContext(projectName) {
|
|
7
|
+
const project = loadProject(projectName);
|
|
8
|
+
if (!project) {
|
|
9
|
+
return null;
|
|
10
|
+
}
|
|
11
|
+
const lines = [];
|
|
12
|
+
lines.push(`You are working on the "${project.name}" project.`);
|
|
13
|
+
if (project.summary) {
|
|
14
|
+
lines.push(`Project: ${project.summary}`);
|
|
15
|
+
}
|
|
16
|
+
// Add wiki section titles as hints
|
|
17
|
+
const wikiSections = project.wiki || [];
|
|
18
|
+
if (wikiSections.length > 0) {
|
|
19
|
+
lines.push('');
|
|
20
|
+
lines.push('Yeehaw wiki sections available:');
|
|
21
|
+
for (const section of wikiSections) {
|
|
22
|
+
lines.push(`- ${section.title}`);
|
|
23
|
+
}
|
|
24
|
+
lines.push('');
|
|
25
|
+
lines.push('Use mcp__yeehaw__get_wiki_section to fetch relevant context before making architectural decisions.');
|
|
26
|
+
}
|
|
27
|
+
return lines.join('\n');
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Build context for a livestock-specific session.
|
|
31
|
+
* Includes project context plus livestock details.
|
|
32
|
+
*/
|
|
33
|
+
export function buildLivestockContext(projectName, livestockName) {
|
|
34
|
+
const project = loadProject(projectName);
|
|
35
|
+
if (!project) {
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
const livestock = project.livestock?.find(l => l.name === livestockName);
|
|
39
|
+
if (!livestock) {
|
|
40
|
+
return buildProjectContext(projectName);
|
|
41
|
+
}
|
|
42
|
+
const lines = [];
|
|
43
|
+
lines.push(`You are working on the "${project.name}" project, in the "${livestock.name}" environment.`);
|
|
44
|
+
if (project.summary) {
|
|
45
|
+
lines.push(`Project: ${project.summary}`);
|
|
46
|
+
}
|
|
47
|
+
// Add livestock details
|
|
48
|
+
if (livestock.branch) {
|
|
49
|
+
lines.push(`Branch: ${livestock.branch}`);
|
|
50
|
+
}
|
|
51
|
+
// Add wiki section titles as hints
|
|
52
|
+
const wikiSections = project.wiki || [];
|
|
53
|
+
if (wikiSections.length > 0) {
|
|
54
|
+
lines.push('');
|
|
55
|
+
lines.push('Yeehaw wiki sections available:');
|
|
56
|
+
for (const section of wikiSections) {
|
|
57
|
+
lines.push(`- ${section.title}`);
|
|
58
|
+
}
|
|
59
|
+
lines.push('');
|
|
60
|
+
lines.push('Use mcp__yeehaw__get_wiki_section to fetch relevant context before making architectural decisions.');
|
|
61
|
+
}
|
|
62
|
+
return lines.join('\n');
|
|
63
|
+
}
|
package/dist/lib/critters.d.ts
CHANGED
|
@@ -26,3 +26,36 @@ export declare function discoverCritters(barn: Barn): Promise<{
|
|
|
26
26
|
critters: DiscoveredCritter[];
|
|
27
27
|
error?: string;
|
|
28
28
|
}>;
|
|
29
|
+
/**
|
|
30
|
+
* Service info from systemctl
|
|
31
|
+
*/
|
|
32
|
+
export interface SystemService {
|
|
33
|
+
name: string;
|
|
34
|
+
state: 'running' | 'stopped' | 'unknown';
|
|
35
|
+
description?: string;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* List systemd services on a barn
|
|
39
|
+
* @param barn - The barn to query
|
|
40
|
+
* @param activeOnly - If true, only return running services (default: true)
|
|
41
|
+
*/
|
|
42
|
+
export declare function listSystemServices(barn: Barn, activeOnly?: boolean): Promise<{
|
|
43
|
+
services: SystemService[];
|
|
44
|
+
error?: string;
|
|
45
|
+
}>;
|
|
46
|
+
/**
|
|
47
|
+
* Details extracted from a systemd service file
|
|
48
|
+
*/
|
|
49
|
+
export interface ServiceDetails {
|
|
50
|
+
service_path: string;
|
|
51
|
+
config_path?: string;
|
|
52
|
+
log_path?: string;
|
|
53
|
+
use_journald: boolean;
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Get details about a systemd service by parsing its unit file
|
|
57
|
+
*/
|
|
58
|
+
export declare function getServiceDetails(barn: Barn, serviceName: string): Promise<{
|
|
59
|
+
details?: ServiceDetails;
|
|
60
|
+
error?: string;
|
|
61
|
+
}>;
|
package/dist/lib/critters.js
CHANGED
|
@@ -199,3 +199,167 @@ export async function discoverCritters(barn) {
|
|
|
199
199
|
}
|
|
200
200
|
return { critters: discovered };
|
|
201
201
|
}
|
|
202
|
+
/**
|
|
203
|
+
* List systemd services on a barn
|
|
204
|
+
* @param barn - The barn to query
|
|
205
|
+
* @param activeOnly - If true, only return running services (default: true)
|
|
206
|
+
*/
|
|
207
|
+
export async function listSystemServices(barn, activeOnly = true) {
|
|
208
|
+
// For active only: list-units shows running services
|
|
209
|
+
// For all: list-unit-files shows all installed services
|
|
210
|
+
const cmd = activeOnly
|
|
211
|
+
? 'systemctl list-units --type=service --state=running --no-pager --no-legend'
|
|
212
|
+
: 'systemctl list-unit-files --type=service --no-pager --no-legend';
|
|
213
|
+
let output;
|
|
214
|
+
// Local barn
|
|
215
|
+
if (barn.name === 'local') {
|
|
216
|
+
try {
|
|
217
|
+
const result = await execa('sh', ['-c', cmd]);
|
|
218
|
+
output = result.stdout;
|
|
219
|
+
}
|
|
220
|
+
catch (err) {
|
|
221
|
+
return { services: [], error: `Failed to list services: ${getErrorMessage(err)}` };
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
else {
|
|
225
|
+
// Remote barn - SSH
|
|
226
|
+
if (!barn.host || !barn.user) {
|
|
227
|
+
return { services: [], error: `Barn '${barn.name}' is not configured for SSH` };
|
|
228
|
+
}
|
|
229
|
+
try {
|
|
230
|
+
const sshArgs = buildSshCommand(barn);
|
|
231
|
+
const result = await execa(sshArgs[0], [...sshArgs.slice(1), cmd]);
|
|
232
|
+
output = result.stdout;
|
|
233
|
+
}
|
|
234
|
+
catch (err) {
|
|
235
|
+
return { services: [], error: `SSH error: ${getErrorMessage(err)}` };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
const services = [];
|
|
239
|
+
const lines = output.split('\n').filter(line => line.trim());
|
|
240
|
+
for (const line of lines) {
|
|
241
|
+
const parts = line.trim().split(/\s+/);
|
|
242
|
+
if (parts.length < 1)
|
|
243
|
+
continue;
|
|
244
|
+
const serviceName = parts[0];
|
|
245
|
+
if (!serviceName.endsWith('.service'))
|
|
246
|
+
continue;
|
|
247
|
+
if (activeOnly) {
|
|
248
|
+
// list-units format: UNIT LOAD ACTIVE SUB DESCRIPTION...
|
|
249
|
+
services.push({
|
|
250
|
+
name: serviceName,
|
|
251
|
+
state: 'running',
|
|
252
|
+
description: parts.slice(4).join(' ') || undefined,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
// list-unit-files format: UNIT STATE PRESET
|
|
257
|
+
const stateStr = parts[1]?.toLowerCase() || '';
|
|
258
|
+
services.push({
|
|
259
|
+
name: serviceName,
|
|
260
|
+
state: stateStr === 'enabled' ? 'unknown' : 'stopped',
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
return { services };
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Get details about a systemd service by parsing its unit file
|
|
268
|
+
*/
|
|
269
|
+
export async function getServiceDetails(barn, serviceName) {
|
|
270
|
+
// Get the unit file path
|
|
271
|
+
const pathCmd = `systemctl show -p FragmentPath ${shellEscape(serviceName)} --value`;
|
|
272
|
+
let servicePath;
|
|
273
|
+
if (barn.name === 'local') {
|
|
274
|
+
try {
|
|
275
|
+
const result = await execa('sh', ['-c', pathCmd]);
|
|
276
|
+
servicePath = result.stdout.trim();
|
|
277
|
+
}
|
|
278
|
+
catch (err) {
|
|
279
|
+
return { error: `Failed to get service path: ${getErrorMessage(err)}` };
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
else {
|
|
283
|
+
if (!barn.host || !barn.user) {
|
|
284
|
+
return { error: `Barn '${barn.name}' is not configured for SSH` };
|
|
285
|
+
}
|
|
286
|
+
try {
|
|
287
|
+
const sshArgs = buildSshCommand(barn);
|
|
288
|
+
const result = await execa(sshArgs[0], [...sshArgs.slice(1), pathCmd]);
|
|
289
|
+
servicePath = result.stdout.trim();
|
|
290
|
+
}
|
|
291
|
+
catch (err) {
|
|
292
|
+
return { error: `SSH error: ${getErrorMessage(err)}` };
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
if (!servicePath) {
|
|
296
|
+
return { error: 'Could not find service unit file' };
|
|
297
|
+
}
|
|
298
|
+
// Read the service file to extract details
|
|
299
|
+
const catCmd = `cat ${shellEscape(servicePath)}`;
|
|
300
|
+
let serviceContent;
|
|
301
|
+
if (barn.name === 'local') {
|
|
302
|
+
try {
|
|
303
|
+
const result = await execa('sh', ['-c', catCmd]);
|
|
304
|
+
serviceContent = result.stdout;
|
|
305
|
+
}
|
|
306
|
+
catch (err) {
|
|
307
|
+
return { error: `Failed to read service file: ${getErrorMessage(err)}` };
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
else {
|
|
311
|
+
try {
|
|
312
|
+
const sshArgs = buildSshCommand(barn);
|
|
313
|
+
const result = await execa(sshArgs[0], [...sshArgs.slice(1), catCmd]);
|
|
314
|
+
serviceContent = result.stdout;
|
|
315
|
+
}
|
|
316
|
+
catch (err) {
|
|
317
|
+
return { error: `SSH error: ${getErrorMessage(err)}` };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
// Parse the service file
|
|
321
|
+
let config_path;
|
|
322
|
+
let log_path;
|
|
323
|
+
let use_journald = true;
|
|
324
|
+
for (const line of serviceContent.split('\n')) {
|
|
325
|
+
const trimmed = line.trim();
|
|
326
|
+
// Look for ExecStart to find config flags
|
|
327
|
+
if (trimmed.startsWith('ExecStart=')) {
|
|
328
|
+
const execLine = trimmed.slice('ExecStart='.length);
|
|
329
|
+
// Common config flag patterns
|
|
330
|
+
const configPatterns = [
|
|
331
|
+
/--config[=\s]([^\s]+)/,
|
|
332
|
+
/--defaults-file[=\s]([^\s]+)/,
|
|
333
|
+
/-c\s+([^\s]+)/,
|
|
334
|
+
/--conf[=\s]([^\s]+)/,
|
|
335
|
+
];
|
|
336
|
+
for (const pattern of configPatterns) {
|
|
337
|
+
const match = execLine.match(pattern);
|
|
338
|
+
if (match) {
|
|
339
|
+
config_path = match[1];
|
|
340
|
+
break;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
// Check StandardOutput/StandardError for log paths
|
|
345
|
+
if (trimmed.startsWith('StandardOutput=') || trimmed.startsWith('StandardError=')) {
|
|
346
|
+
const value = trimmed.split('=')[1];
|
|
347
|
+
if (value && !value.startsWith('journal') && !value.startsWith('inherit')) {
|
|
348
|
+
// Could be file:path or append:path
|
|
349
|
+
const fileMatch = value.match(/(?:file|append):(.+)/);
|
|
350
|
+
if (fileMatch) {
|
|
351
|
+
log_path = fileMatch[1];
|
|
352
|
+
use_journald = false;
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return {
|
|
358
|
+
details: {
|
|
359
|
+
service_path: servicePath,
|
|
360
|
+
config_path,
|
|
361
|
+
log_path,
|
|
362
|
+
use_journald,
|
|
363
|
+
},
|
|
364
|
+
};
|
|
365
|
+
}
|
package/dist/lib/hotkeys.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export type HotkeyScope = 'global' | 'global-dashboard' | 'project-context' | 'barn-context' | 'wiki-view' | 'issues-view' | 'livestock-detail' | 'logs-view' | 'night-sky' | 'list' | 'content';
|
|
1
|
+
export type HotkeyScope = 'global' | 'global-dashboard' | 'project-context' | 'barn-context' | 'wiki-view' | 'issues-view' | 'livestock-detail' | 'logs-view' | 'critter-detail' | 'critter-logs' | 'night-sky' | 'list' | 'content';
|
|
2
2
|
export type HotkeyCategory = 'navigation' | 'action' | 'system';
|
|
3
3
|
export interface Hotkey {
|
|
4
4
|
key: string;
|
package/dist/lib/hotkeys.js
CHANGED
|
@@ -31,12 +31,16 @@ export const HOTKEYS = [
|
|
|
31
31
|
{ key: 'w', description: 'Open wiki', category: 'action', scopes: ['project-context'] },
|
|
32
32
|
{ key: 'i', description: 'Open issues', category: 'action', scopes: ['project-context'] },
|
|
33
33
|
// === LIVESTOCK DETAIL PAGE-LEVEL ===
|
|
34
|
-
{ key: 'c', description: 'Claude session', category: 'action', scopes: ['livestock-detail'] },
|
|
34
|
+
{ key: 'c', description: 'Claude session (local only)', category: 'action', scopes: ['livestock-detail'] },
|
|
35
35
|
{ key: 's', description: 'Shell session', category: 'action', scopes: ['livestock-detail'] },
|
|
36
36
|
{ key: 'l', description: 'View logs', category: 'action', scopes: ['livestock-detail'] },
|
|
37
|
+
// === CRITTER DETAIL PAGE-LEVEL ===
|
|
38
|
+
{ key: 'l', description: 'View logs', category: 'action', scopes: ['critter-detail'] },
|
|
39
|
+
{ key: 'e', description: 'Edit', category: 'action', scopes: ['critter-detail'] },
|
|
37
40
|
// === ISSUES VIEW ===
|
|
38
|
-
{ key: 'r', description: 'Refresh', category: 'action', scopes: ['issues-view', 'logs-view'] },
|
|
41
|
+
{ key: 'r', description: 'Refresh', category: 'action', scopes: ['issues-view', 'logs-view', 'critter-logs'] },
|
|
39
42
|
{ key: 'o', description: 'Open in browser', category: 'action', scopes: ['issues-view'] },
|
|
43
|
+
{ key: 'c', description: 'Open in Claude', category: 'action', scopes: ['issues-view'] },
|
|
40
44
|
// === NIGHT SKY ===
|
|
41
45
|
{ key: 'v', description: 'Visualizer', category: 'navigation', scopes: ['global-dashboard'] },
|
|
42
46
|
{ key: 'c', description: 'Spawn cloud', category: 'action', scopes: ['night-sky'] },
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { Issue, IssueProvider, FetchIssuesOptions } from './types.js';
|
|
2
|
+
import type { Livestock } from '../../types.js';
|
|
3
|
+
export declare class GitHubProvider implements IssueProvider {
|
|
4
|
+
readonly type: "github";
|
|
5
|
+
private repos;
|
|
6
|
+
constructor(livestock: Livestock[]);
|
|
7
|
+
isAuthenticated(): Promise<boolean>;
|
|
8
|
+
authenticate(): Promise<void>;
|
|
9
|
+
fetchIssues(options?: FetchIssuesOptions): Promise<Issue[]>;
|
|
10
|
+
getIssue(id: string): Promise<Issue>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// src/lib/issues/github.ts
|
|
2
|
+
import { execaSync } from 'execa';
|
|
3
|
+
/**
|
|
4
|
+
* Parse a GitHub URL to extract owner and repo.
|
|
5
|
+
*/
|
|
6
|
+
function parseGitHubUrl(url) {
|
|
7
|
+
// HTTPS format
|
|
8
|
+
const httpsMatch = url.match(/github\.com\/([^/]+)\/([^/.\s]+)/);
|
|
9
|
+
if (httpsMatch) {
|
|
10
|
+
return { owner: httpsMatch[1], repo: httpsMatch[2].replace(/\.git$/, '') };
|
|
11
|
+
}
|
|
12
|
+
// SSH format
|
|
13
|
+
const sshMatch = url.match(/github\.com:([^/]+)\/([^/.\s]+)/);
|
|
14
|
+
if (sshMatch) {
|
|
15
|
+
return { owner: sshMatch[1], repo: sshMatch[2].replace(/\.git$/, '') };
|
|
16
|
+
}
|
|
17
|
+
return null;
|
|
18
|
+
}
|
|
19
|
+
export class GitHubProvider {
|
|
20
|
+
type = 'github';
|
|
21
|
+
repos = [];
|
|
22
|
+
constructor(livestock) {
|
|
23
|
+
// Extract GitHub repos from local livestock only
|
|
24
|
+
const localLivestock = livestock.filter((l) => !l.barn && l.repo);
|
|
25
|
+
const seen = new Set();
|
|
26
|
+
for (const l of localLivestock) {
|
|
27
|
+
if (!l.repo)
|
|
28
|
+
continue;
|
|
29
|
+
const parsed = parseGitHubUrl(l.repo);
|
|
30
|
+
if (parsed) {
|
|
31
|
+
const key = `${parsed.owner}/${parsed.repo}`;
|
|
32
|
+
if (!seen.has(key)) {
|
|
33
|
+
seen.add(key);
|
|
34
|
+
this.repos.push({
|
|
35
|
+
...parsed,
|
|
36
|
+
livestockName: l.name,
|
|
37
|
+
livestockPath: l.path,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
async isAuthenticated() {
|
|
44
|
+
try {
|
|
45
|
+
execaSync('gh', ['auth', 'status']);
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
async authenticate() {
|
|
53
|
+
// GitHub auth is handled externally via `gh auth login`
|
|
54
|
+
// This is a no-op - the view should display instructions
|
|
55
|
+
throw new Error('Run `gh auth login` in your terminal to authenticate with GitHub');
|
|
56
|
+
}
|
|
57
|
+
async fetchIssues(options = {}) {
|
|
58
|
+
const { state = 'open', limit = 50 } = options;
|
|
59
|
+
if (this.repos.length === 0) {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
const allIssues = [];
|
|
63
|
+
for (const repo of this.repos) {
|
|
64
|
+
try {
|
|
65
|
+
const result = execaSync('gh', [
|
|
66
|
+
'issue', 'list',
|
|
67
|
+
'--repo', `${repo.owner}/${repo.repo}`,
|
|
68
|
+
'--state', state,
|
|
69
|
+
'--limit', String(limit),
|
|
70
|
+
'--json', 'number,title,state,author,labels,createdAt,updatedAt,body,url,comments',
|
|
71
|
+
]);
|
|
72
|
+
const issues = JSON.parse(result.stdout);
|
|
73
|
+
for (const issue of issues) {
|
|
74
|
+
allIssues.push({
|
|
75
|
+
id: `${repo.owner}/${repo.repo}#${issue.number}`,
|
|
76
|
+
identifier: `#${issue.number}`,
|
|
77
|
+
title: issue.title,
|
|
78
|
+
state: issue.state.toLowerCase(),
|
|
79
|
+
isOpen: issue.state.toLowerCase() === 'open',
|
|
80
|
+
author: issue.author.login,
|
|
81
|
+
body: issue.body || '',
|
|
82
|
+
labels: issue.labels.map((l) => l.name),
|
|
83
|
+
url: issue.url,
|
|
84
|
+
createdAt: issue.createdAt,
|
|
85
|
+
updatedAt: issue.updatedAt,
|
|
86
|
+
comments: issue.comments.map((c) => ({
|
|
87
|
+
id: `${issue.number}-${c.createdAt}`,
|
|
88
|
+
author: c.author.login,
|
|
89
|
+
body: c.body,
|
|
90
|
+
createdAt: c.createdAt,
|
|
91
|
+
})),
|
|
92
|
+
source: {
|
|
93
|
+
type: 'github',
|
|
94
|
+
repo: `${repo.owner}/${repo.repo}`,
|
|
95
|
+
livestockName: repo.livestockName,
|
|
96
|
+
livestockPath: repo.livestockPath,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
console.error(`[github] Failed to fetch issues for ${repo.owner}/${repo.repo}:`, err);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
// Sort by updated date (most recent first)
|
|
106
|
+
allIssues.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
|
|
107
|
+
return allIssues;
|
|
108
|
+
}
|
|
109
|
+
async getIssue(id) {
|
|
110
|
+
// Parse id format: "owner/repo#number"
|
|
111
|
+
const match = id.match(/^([^/]+)\/([^#]+)#(\d+)$/);
|
|
112
|
+
if (!match) {
|
|
113
|
+
throw new Error(`Invalid issue ID format: ${id}`);
|
|
114
|
+
}
|
|
115
|
+
const [, owner, repo, numberStr] = match;
|
|
116
|
+
const issueNumber = parseInt(numberStr, 10);
|
|
117
|
+
// Find the livestock for this repo
|
|
118
|
+
const repoInfo = this.repos.find((r) => r.owner === owner && r.repo === repo);
|
|
119
|
+
if (!repoInfo) {
|
|
120
|
+
throw new Error(`Repo not found in livestock: ${owner}/${repo}`);
|
|
121
|
+
}
|
|
122
|
+
const result = execaSync('gh', [
|
|
123
|
+
'issue', 'view', String(issueNumber),
|
|
124
|
+
'--repo', `${owner}/${repo}`,
|
|
125
|
+
'--json', 'number,title,state,author,labels,createdAt,updatedAt,body,url,comments',
|
|
126
|
+
]);
|
|
127
|
+
const issue = JSON.parse(result.stdout);
|
|
128
|
+
return {
|
|
129
|
+
id,
|
|
130
|
+
identifier: `#${issue.number}`,
|
|
131
|
+
title: issue.title,
|
|
132
|
+
state: issue.state.toLowerCase(),
|
|
133
|
+
isOpen: issue.state.toLowerCase() === 'open',
|
|
134
|
+
author: issue.author.login,
|
|
135
|
+
body: issue.body || '',
|
|
136
|
+
labels: issue.labels.map((l) => l.name),
|
|
137
|
+
url: issue.url,
|
|
138
|
+
createdAt: issue.createdAt,
|
|
139
|
+
updatedAt: issue.updatedAt,
|
|
140
|
+
comments: issue.comments.map((c) => ({
|
|
141
|
+
id: `${issue.number}-${c.createdAt}`,
|
|
142
|
+
author: c.author.login,
|
|
143
|
+
body: c.body,
|
|
144
|
+
createdAt: c.createdAt,
|
|
145
|
+
})),
|
|
146
|
+
source: {
|
|
147
|
+
type: 'github',
|
|
148
|
+
repo: `${owner}/${repo}`,
|
|
149
|
+
livestockName: repoInfo.livestockName,
|
|
150
|
+
livestockPath: repoInfo.livestockPath,
|
|
151
|
+
},
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Project } from '../../types.js';
|
|
2
|
+
import type { IssueProvider } from './types.js';
|
|
3
|
+
export * from './types.js';
|
|
4
|
+
export { GitHubProvider } from './github.js';
|
|
5
|
+
export { LinearProvider } from './linear.js';
|
|
6
|
+
/**
|
|
7
|
+
* Get the appropriate issue provider for a project.
|
|
8
|
+
* Returns null if issue tracking is disabled.
|
|
9
|
+
*/
|
|
10
|
+
export declare function getProvider(project: Project): IssueProvider | null;
|
|
11
|
+
/**
|
|
12
|
+
* Check if a project has issue tracking enabled.
|
|
13
|
+
*/
|
|
14
|
+
export declare function hasIssueTracking(project: Project): boolean;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { GitHubProvider } from './github.js';
|
|
2
|
+
import { LinearProvider } from './linear.js';
|
|
3
|
+
export * from './types.js';
|
|
4
|
+
export { GitHubProvider } from './github.js';
|
|
5
|
+
export { LinearProvider } from './linear.js';
|
|
6
|
+
/**
|
|
7
|
+
* Get the appropriate issue provider for a project.
|
|
8
|
+
* Returns null if issue tracking is disabled.
|
|
9
|
+
*/
|
|
10
|
+
export function getProvider(project) {
|
|
11
|
+
const config = project.issueProvider ?? { type: 'github' };
|
|
12
|
+
switch (config.type) {
|
|
13
|
+
case 'github':
|
|
14
|
+
return new GitHubProvider(project.livestock ?? []);
|
|
15
|
+
case 'linear':
|
|
16
|
+
return new LinearProvider(config.teamId, config.teamName);
|
|
17
|
+
case 'none':
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* Check if a project has issue tracking enabled.
|
|
23
|
+
*/
|
|
24
|
+
export function hasIssueTracking(project) {
|
|
25
|
+
const config = project.issueProvider ?? { type: 'github' };
|
|
26
|
+
return config.type !== 'none';
|
|
27
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { Issue, FetchIssuesOptions, LinearProviderInterface, LinearTeam, LinearCycle, LinearAssignee } from './types.js';
|
|
2
|
+
export declare class LinearProvider implements LinearProviderInterface {
|
|
3
|
+
readonly type: "linear";
|
|
4
|
+
private teamId?;
|
|
5
|
+
private teamName?;
|
|
6
|
+
private cachedUserId?;
|
|
7
|
+
private cachedActiveCycleId?;
|
|
8
|
+
constructor(teamId?: string, teamName?: string);
|
|
9
|
+
isAuthenticated(): Promise<boolean>;
|
|
10
|
+
authenticate(): Promise<void>;
|
|
11
|
+
needsTeamSelection(): boolean;
|
|
12
|
+
fetchTeams(): Promise<LinearTeam[]>;
|
|
13
|
+
setTeamId(teamId: string): void;
|
|
14
|
+
setTeamName(teamName: string): void;
|
|
15
|
+
getTeamName(): string | undefined;
|
|
16
|
+
fetchTeamName(): Promise<string | undefined>;
|
|
17
|
+
getCurrentUserId(): Promise<string | null>;
|
|
18
|
+
fetchCycles(): Promise<LinearCycle[]>;
|
|
19
|
+
getActiveCycleId(): string | undefined;
|
|
20
|
+
fetchAssignees(): Promise<LinearAssignee[]>;
|
|
21
|
+
fetchIssues(options?: FetchIssuesOptions): Promise<Issue[]>;
|
|
22
|
+
getIssue(id: string): Promise<Issue>;
|
|
23
|
+
private normalizeIssue;
|
|
24
|
+
}
|