tmux-team 2.2.0 → 3.0.0-alpha.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.
@@ -1,842 +0,0 @@
1
- // ─────────────────────────────────────────────────────────────
2
- // GitHub storage adapter for PM (Phase 5)
3
- // Uses GitHub Issues for tasks, GitHub Milestones for milestones
4
- // ─────────────────────────────────────────────────────────────
5
-
6
- import { spawnSync } from 'child_process';
7
- import fs from 'fs';
8
- import path from 'path';
9
- import type { StorageAdapter } from './adapter.js';
10
- import type {
11
- Team,
12
- Milestone,
13
- Task,
14
- AuditEvent,
15
- CreateTaskInput,
16
- UpdateTaskInput,
17
- CreateMilestoneInput,
18
- UpdateMilestoneInput,
19
- ListTasksFilter,
20
- TaskStatus,
21
- } from '../types.js';
22
-
23
- // ─────────────────────────────────────────────────────────────
24
- // Labels used for task status tracking
25
- // ─────────────────────────────────────────────────────────────
26
-
27
- const LABELS = {
28
- // Base label for all tmux-team managed issues
29
- TASK: 'tmux-team:task',
30
- // Status labels
31
- PENDING: 'tmux-team:pending',
32
- IN_PROGRESS: 'tmux-team:in_progress',
33
- DELETED: 'tmux-team:deleted',
34
- // 'done' status uses closed issue state, no label needed
35
- } as const;
36
-
37
- // ─────────────────────────────────────────────────────────────
38
- // GitHub Issue/Milestone JSON types (from gh CLI)
39
- // ─────────────────────────────────────────────────────────────
40
-
41
- interface GHIssue {
42
- number: number;
43
- title: string;
44
- body: string;
45
- state: 'OPEN' | 'CLOSED';
46
- labels: Array<{ name: string }>;
47
- milestone?: { number: number; title: string } | null;
48
- assignees: Array<{ login: string }>;
49
- createdAt: string;
50
- updatedAt: string;
51
- }
52
-
53
- interface GHMilestone {
54
- number: number;
55
- title: string;
56
- description: string | null;
57
- state: 'open' | 'closed'; // REST API uses lowercase (unlike GraphQL)
58
- createdAt: string;
59
- }
60
-
61
- // ─────────────────────────────────────────────────────────────
62
- // Local cache for ID mapping (task ID -> issue number)
63
- // ─────────────────────────────────────────────────────────────
64
-
65
- const CACHE_VERSION = 3; // Bumped: milestone state now cached
66
-
67
- interface IdCache {
68
- version: number; // Cache format version for migrations
69
- repo: string; // Associated repo to detect cross-repo drift
70
- tasks: Record<string, number>; // task ID -> issue number
71
- milestones: Record<string, { number: number; name: string; state?: 'open' | 'closed' }>; // milestone ID -> {number, name, state}
72
- nextTaskId: number;
73
- nextMilestoneId: number;
74
- }
75
-
76
- // ─────────────────────────────────────────────────────────────
77
- // GitHub Adapter Implementation
78
- // ─────────────────────────────────────────────────────────────
79
-
80
- export class GitHubAdapter implements StorageAdapter {
81
- private teamDir: string;
82
- private repo: string;
83
-
84
- constructor(teamDir: string, repo: string) {
85
- this.teamDir = teamDir;
86
- this.repo = repo;
87
- }
88
-
89
- // ─────────────────────────────────────────────────────────────
90
- // Helper: Execute gh CLI command safely (no shell injection)
91
- // ─────────────────────────────────────────────────────────────
92
-
93
- private gh(args: string[], options?: { skipRepo?: boolean; input?: string }): string {
94
- const fullArgs = [...args];
95
- // gh api doesn't accept --repo flag (repo is in the endpoint path)
96
- // Other commands like 'gh issue' do accept --repo
97
- const isApiCommand = args[0] === 'api';
98
- if (this.repo && !isApiCommand && !options?.skipRepo) {
99
- fullArgs.push('--repo', this.repo);
100
- }
101
- // Use spawnSync with array args to avoid shell injection
102
- const result = spawnSync('gh', fullArgs, {
103
- encoding: 'utf-8',
104
- stdio: ['pipe', 'pipe', 'pipe'],
105
- input: options?.input,
106
- });
107
-
108
- if (result.error) {
109
- throw new Error(`gh command failed: ${result.error.message}`);
110
- }
111
-
112
- if (result.status !== 0) {
113
- const stderr = result.stderr?.trim() || 'Unknown error';
114
- // Surface auth errors clearly
115
- if (stderr.includes('gh auth login') || stderr.includes('not logged in')) {
116
- throw new Error(`GitHub authentication required. Run: gh auth login`);
117
- }
118
- throw new Error(`gh command failed: ${stderr}`);
119
- }
120
-
121
- return (result.stdout || '').trim();
122
- }
123
-
124
- private ghJson<T>(args: string[]): T {
125
- const result = this.gh(args);
126
- return JSON.parse(result) as T;
127
- }
128
-
129
- // ─────────────────────────────────────────────────────────────
130
- // Helper: Local ID cache management
131
- // ─────────────────────────────────────────────────────────────
132
-
133
- private get cacheFile(): string {
134
- return path.join(this.teamDir, 'github-cache.json');
135
- }
136
-
137
- private loadCache(): IdCache {
138
- if (fs.existsSync(this.cacheFile)) {
139
- try {
140
- const data = JSON.parse(fs.readFileSync(this.cacheFile, 'utf-8')) as IdCache;
141
-
142
- // Migrate from version 2 to 3: add milestone state field
143
- if (data.version === 2) {
144
- // Preserve all existing data, just update version
145
- // Milestone states will be populated on next listMilestones() call
146
- data.version = 3;
147
- this.saveCache(data);
148
- }
149
-
150
- // Validate cache integrity
151
- if (data.version !== CACHE_VERSION) {
152
- // Unknown version - reset cache
153
- console.error(`[tmux-team] Cache version mismatch, resetting cache`);
154
- return this.createEmptyCache();
155
- }
156
-
157
- if (data.repo !== this.repo) {
158
- // Repo mismatch - cache is for different repo, reset
159
- console.error(
160
- `[tmux-team] Cache repo mismatch (${data.repo} vs ${this.repo}), resetting cache`
161
- );
162
- return this.createEmptyCache();
163
- }
164
-
165
- // Validate nextId counters are consistent
166
- const maxTaskId = Math.max(0, ...Object.keys(data.tasks).map((k) => parseInt(k, 10) || 0));
167
- const maxMilestoneId = Math.max(
168
- 0,
169
- ...Object.keys(data.milestones).map((k) => parseInt(k, 10) || 0)
170
- );
171
-
172
- if (data.nextTaskId <= maxTaskId) {
173
- data.nextTaskId = maxTaskId + 1;
174
- }
175
- if (data.nextMilestoneId <= maxMilestoneId) {
176
- data.nextMilestoneId = maxMilestoneId + 1;
177
- }
178
-
179
- return data;
180
- } catch {
181
- // Corrupted cache, reset
182
- console.error(`[tmux-team] Cache corrupted, resetting`);
183
- }
184
- }
185
- return this.createEmptyCache();
186
- }
187
-
188
- private createEmptyCache(): IdCache {
189
- return {
190
- version: CACHE_VERSION,
191
- repo: this.repo,
192
- tasks: {},
193
- milestones: {},
194
- nextTaskId: 1,
195
- nextMilestoneId: 1,
196
- };
197
- }
198
-
199
- private saveCache(cache: IdCache): void {
200
- fs.mkdirSync(path.dirname(this.cacheFile), { recursive: true });
201
- fs.writeFileSync(this.cacheFile, JSON.stringify(cache, null, 2) + '\n');
202
- }
203
-
204
- private getIssueNumber(taskId: string): number | null {
205
- const cache = this.loadCache();
206
- return cache.tasks[taskId] ?? null;
207
- }
208
-
209
- private getMilestoneNumber(milestoneId: string): number | null {
210
- const cache = this.loadCache();
211
- return cache.milestones[milestoneId]?.number ?? null;
212
- }
213
-
214
- private getMilestoneName(milestoneId: string): string | null {
215
- const cache = this.loadCache();
216
- return cache.milestones[milestoneId]?.name ?? null;
217
- }
218
-
219
- // ─────────────────────────────────────────────────────────────
220
- // Helper: Status <-> Label conversion
221
- // ─────────────────────────────────────────────────────────────
222
-
223
- private statusToLabel(status: TaskStatus): string | null {
224
- switch (status) {
225
- case 'pending':
226
- return LABELS.PENDING;
227
- case 'in_progress':
228
- return LABELS.IN_PROGRESS;
229
- case 'done':
230
- return null; // Use closed state
231
- }
232
- }
233
-
234
- private issueToTask(issue: GHIssue, taskId: string, cache?: IdCache): Task {
235
- let status: TaskStatus = 'pending';
236
- if (issue.state === 'CLOSED') {
237
- status = 'done';
238
- } else if (issue.labels.some((l) => l.name === LABELS.IN_PROGRESS)) {
239
- status = 'in_progress';
240
- }
241
-
242
- // Look up local milestone ID from GitHub milestone number
243
- let milestoneId: string | undefined;
244
- if (issue.milestone?.number) {
245
- const c = cache || this.loadCache();
246
- const ghMilestoneNum = issue.milestone.number;
247
- milestoneId = Object.entries(c.milestones).find(([, m]) => m.number === ghMilestoneNum)?.[0];
248
- }
249
-
250
- return {
251
- id: taskId,
252
- title: issue.title,
253
- milestone: milestoneId,
254
- status,
255
- assignee: issue.assignees[0]?.login,
256
- docPath: `github:issue/${issue.number}`,
257
- createdAt: issue.createdAt,
258
- updatedAt: issue.updatedAt,
259
- };
260
- }
261
-
262
- private milestoneToMilestone(ghMilestone: GHMilestone, id: string): Milestone {
263
- return {
264
- id,
265
- name: ghMilestone.title,
266
- status: ghMilestone.state === 'closed' ? 'done' : 'pending',
267
- description: ghMilestone.description || undefined,
268
- docPath: `github:milestone/${ghMilestone.number}`,
269
- createdAt: ghMilestone.createdAt,
270
- updatedAt: ghMilestone.createdAt, // GH milestones don't have updatedAt
271
- };
272
- }
273
-
274
- // ─────────────────────────────────────────────────────────────
275
- // Helper: Ensure labels exist
276
- // ─────────────────────────────────────────────────────────────
277
-
278
- private async ensureLabels(): Promise<void> {
279
- const labels = [
280
- { name: LABELS.TASK, color: '0366d6', desc: 'tmux-team managed task' },
281
- { name: LABELS.PENDING, color: 'fbca04', desc: 'Task pending' },
282
- { name: LABELS.IN_PROGRESS, color: '1d76db', desc: 'Task in progress' },
283
- { name: LABELS.DELETED, color: 'b60205', desc: 'Task deleted' },
284
- ];
285
- for (const label of labels) {
286
- try {
287
- this.gh([
288
- 'label',
289
- 'create',
290
- label.name,
291
- '--force',
292
- '--color',
293
- label.color,
294
- '--description',
295
- label.desc,
296
- ]);
297
- } catch {
298
- // Label might already exist, ignore
299
- }
300
- }
301
- }
302
-
303
- // ─────────────────────────────────────────────────────────────
304
- // Team operations
305
- // ─────────────────────────────────────────────────────────────
306
-
307
- private get teamFile(): string {
308
- return path.join(this.teamDir, 'team.json');
309
- }
310
-
311
- private get eventsFile(): string {
312
- return path.join(this.teamDir, 'events.jsonl');
313
- }
314
-
315
- private now(): string {
316
- return new Date().toISOString();
317
- }
318
-
319
- async initTeam(name: string, windowId?: string): Promise<Team> {
320
- fs.mkdirSync(this.teamDir, { recursive: true });
321
-
322
- // Ensure labels exist in the repo
323
- await this.ensureLabels();
324
-
325
- const team: Team = {
326
- id: path.basename(this.teamDir),
327
- name,
328
- windowId,
329
- createdAt: this.now(),
330
- };
331
-
332
- fs.writeFileSync(this.teamFile, JSON.stringify(team, null, 2) + '\n');
333
-
334
- // Initialize empty cache with version and repo
335
- this.saveCache(this.createEmptyCache());
336
-
337
- return team;
338
- }
339
-
340
- async getTeam(): Promise<Team | null> {
341
- if (!fs.existsSync(this.teamFile)) return null;
342
- try {
343
- return JSON.parse(fs.readFileSync(this.teamFile, 'utf-8')) as Team;
344
- } catch {
345
- return null;
346
- }
347
- }
348
-
349
- async updateTeam(updates: Partial<Team>): Promise<Team> {
350
- const team = await this.getTeam();
351
- if (!team) throw new Error('Team not initialized');
352
- const updated = { ...team, ...updates };
353
- fs.writeFileSync(this.teamFile, JSON.stringify(updated, null, 2) + '\n');
354
- return updated;
355
- }
356
-
357
- // ─────────────────────────────────────────────────────────────
358
- // Milestone operations (GitHub Milestones)
359
- // ─────────────────────────────────────────────────────────────
360
-
361
- async createMilestone(input: CreateMilestoneInput): Promise<Milestone> {
362
- // Create milestone in GitHub
363
- const args = [
364
- 'api',
365
- `repos/${this.repo}/milestones`,
366
- '-X',
367
- 'POST',
368
- '-f',
369
- `title=${input.name}`,
370
- '-f',
371
- 'state=open',
372
- ];
373
- if (input.description) {
374
- args.push('-f', `description=${input.description}`);
375
- }
376
- const result = this.gh(args);
377
- const ghMilestone = JSON.parse(result) as {
378
- number: number;
379
- title: string;
380
- description: string | null;
381
- created_at: string;
382
- };
383
-
384
- // Cache the ID mapping (store number, name, and state)
385
- const cache = this.loadCache();
386
- const id = String(cache.nextMilestoneId++);
387
- cache.milestones[id] = { number: ghMilestone.number, name: ghMilestone.title, state: 'open' };
388
- this.saveCache(cache);
389
-
390
- return {
391
- id,
392
- name: ghMilestone.title,
393
- status: 'pending',
394
- description: ghMilestone.description || undefined,
395
- docPath: `github:milestone/${ghMilestone.number}`,
396
- createdAt: ghMilestone.created_at,
397
- updatedAt: ghMilestone.created_at,
398
- };
399
- }
400
-
401
- async getMilestone(id: string): Promise<Milestone | null> {
402
- const number = this.getMilestoneNumber(id);
403
- if (!number) return null;
404
-
405
- try {
406
- const result = this.gh(['api', `repos/${this.repo}/milestones/${number}`]);
407
- const ghMilestone = JSON.parse(result) as GHMilestone;
408
- return this.milestoneToMilestone(ghMilestone, id);
409
- } catch (error) {
410
- const err = error as Error;
411
- if (err.message.includes('auth')) {
412
- throw err; // Re-throw auth errors
413
- }
414
- return null; // Not found or other error
415
- }
416
- }
417
-
418
- async listMilestones(): Promise<Milestone[]> {
419
- const cache = this.loadCache();
420
- const milestones: Milestone[] = [];
421
-
422
- try {
423
- // Get all milestones (open and closed)
424
- const result = this.gh([
425
- 'api',
426
- `repos/${this.repo}/milestones`,
427
- '-X',
428
- 'GET',
429
- '--jq',
430
- '.',
431
- '-f',
432
- 'state=all',
433
- ]);
434
- const ghMilestones = JSON.parse(result) as GHMilestone[];
435
-
436
- // Match with cached IDs, or create new mappings
437
- for (const ghm of ghMilestones) {
438
- let id = Object.entries(cache.milestones).find(([, m]) => m.number === ghm.number)?.[0];
439
- if (!id) {
440
- // New milestone from GitHub, assign ID
441
- id = String(cache.nextMilestoneId++);
442
- }
443
- // Always update cache with current state
444
- cache.milestones[id] = { number: ghm.number, name: ghm.title, state: ghm.state };
445
- milestones.push(this.milestoneToMilestone(ghm, id));
446
- }
447
-
448
- this.saveCache(cache);
449
- } catch (error) {
450
- // Surface auth errors instead of silently returning empty array
451
- const err = error as Error;
452
- if (err.message.includes('auth')) {
453
- throw err; // Re-throw auth errors
454
- }
455
- // Other errors (no milestones, network issues) return empty array
456
- }
457
-
458
- return milestones.sort((a, b) => parseInt(a.id) - parseInt(b.id));
459
- }
460
-
461
- async updateMilestone(id: string, input: UpdateMilestoneInput): Promise<Milestone> {
462
- const number = this.getMilestoneNumber(id);
463
- if (!number) throw new Error(`Milestone ${id} not found`);
464
-
465
- const args = ['api', `repos/${this.repo}/milestones/${number}`, '-X', 'PATCH'];
466
- if (input.name) args.push('-f', `title=${input.name}`);
467
- if (input.status) {
468
- args.push('-f', `state=${input.status === 'done' ? 'closed' : 'open'}`);
469
- }
470
- if (input.description !== undefined) {
471
- args.push('-f', `description=${input.description}`);
472
- }
473
-
474
- const result = this.gh(args);
475
- const ghMilestone = JSON.parse(result) as GHMilestone;
476
-
477
- // Update cache with new name/state
478
- const cache = this.loadCache();
479
- if (cache.milestones[id]) {
480
- cache.milestones[id] = {
481
- ...cache.milestones[id],
482
- name: ghMilestone.title,
483
- state: ghMilestone.state,
484
- };
485
- this.saveCache(cache);
486
- }
487
-
488
- return this.milestoneToMilestone(ghMilestone, id);
489
- }
490
-
491
- async deleteMilestone(id: string): Promise<void> {
492
- const number = this.getMilestoneNumber(id);
493
- if (!number) return;
494
-
495
- // Note: We intentionally don't delete the milestone from GitHub.
496
- // GitHub milestones may be used by other tools, and deletion is destructive.
497
- // Instead, we only remove it from the local cache (soft delete).
498
- // To permanently delete, use the GitHub web UI or `gh api` directly.
499
-
500
- // Remove from cache only
501
- const cache = this.loadCache();
502
- delete cache.milestones[id];
503
- this.saveCache(cache);
504
- }
505
-
506
- // ─────────────────────────────────────────────────────────────
507
- // Task operations (GitHub Issues)
508
- // ─────────────────────────────────────────────────────────────
509
-
510
- async createTask(input: CreateTaskInput): Promise<Task> {
511
- // Always add both TASK (base) and PENDING (status) labels
512
- const args = [
513
- 'issue',
514
- 'create',
515
- '--title',
516
- input.title,
517
- '--body',
518
- input.body || '', // Required for non-interactive mode
519
- '--label',
520
- LABELS.TASK,
521
- '--label',
522
- LABELS.PENDING,
523
- ];
524
-
525
- // Add milestone if specified (gh issue create expects milestone name, not number)
526
- if (input.milestone) {
527
- const milestoneName = this.getMilestoneName(input.milestone);
528
- if (milestoneName) {
529
- args.push('--milestone', milestoneName);
530
- }
531
- }
532
-
533
- // NOTE: Assignee is intentionally NOT supported for GitHub backend.
534
- // Agent names (e.g., "codex") don't map to GitHub usernames, and passing
535
- // them could accidentally notify unrelated GitHub users or fail silently.
536
- // Use labels or comments for agent attribution instead.
537
-
538
- // Create issue and get its number
539
- const url = this.gh(args);
540
- const issueNumber = parseInt(url.split('/').pop() || '0', 10);
541
-
542
- // Cache the ID mapping
543
- const cache = this.loadCache();
544
- const id = String(cache.nextTaskId++);
545
- cache.tasks[id] = issueNumber;
546
- this.saveCache(cache);
547
-
548
- // Fetch the created issue for full details
549
- const issue = this.ghJson<GHIssue>([
550
- 'issue',
551
- 'view',
552
- String(issueNumber),
553
- '--json',
554
- 'number,title,body,state,labels,milestone,assignees,createdAt,updatedAt',
555
- ]);
556
-
557
- return this.issueToTask(issue, id);
558
- }
559
-
560
- async getTask(id: string): Promise<Task | null> {
561
- const number = this.getIssueNumber(id);
562
- if (!number) return null;
563
-
564
- try {
565
- const issue = this.ghJson<GHIssue>([
566
- 'issue',
567
- 'view',
568
- String(number),
569
- '--json',
570
- 'number,title,body,state,labels,milestone,assignees,createdAt,updatedAt',
571
- ]);
572
- return this.issueToTask(issue, id);
573
- } catch {
574
- return null;
575
- }
576
- }
577
-
578
- async listTasks(filter?: ListTasksFilter): Promise<Task[]> {
579
- let cache = this.loadCache();
580
-
581
- // Rebuild milestone cache if empty (e.g., after cache reset)
582
- if (Object.keys(cache.milestones).length === 0) {
583
- await this.listMilestones(); // This populates the cache
584
- cache = this.loadCache(); // Reload updated cache
585
- }
586
-
587
- const args = [
588
- 'issue',
589
- 'list',
590
- '--json',
591
- 'number,title,body,state,labels,milestone,assignees,createdAt,updatedAt',
592
- '--limit',
593
- '1000',
594
- ];
595
-
596
- // Filter by status
597
- if (filter?.status === 'done') {
598
- args.push('--state', 'closed');
599
- } else if (filter?.status) {
600
- args.push('--state', 'open');
601
- args.push('--label', filter.status === 'in_progress' ? LABELS.IN_PROGRESS : LABELS.PENDING);
602
- } else {
603
- args.push('--state', 'all');
604
- }
605
-
606
- // Filter by milestone (gh issue list expects milestone name)
607
- if (filter?.milestone) {
608
- const milestoneName = this.getMilestoneName(filter.milestone);
609
- if (milestoneName) {
610
- args.push('--milestone', milestoneName);
611
- }
612
- }
613
-
614
- // NOTE: Assignee filter not supported for GitHub backend (security risk)
615
-
616
- // Only get tmux-team managed issues (all have TASK label)
617
- args.push('--label', LABELS.TASK);
618
-
619
- try {
620
- const issues = this.ghJson<GHIssue[]>(args);
621
- let tasks: Task[] = [];
622
-
623
- for (const issue of issues) {
624
- // Find or create ID mapping
625
- let id = Object.entries(cache.tasks).find(([, num]) => num === issue.number)?.[0];
626
- if (!id) {
627
- id = String(cache.nextTaskId++);
628
- cache.tasks[id] = issue.number;
629
- }
630
- // Pass cache for efficient milestone lookup
631
- tasks.push(this.issueToTask(issue, id, cache));
632
- }
633
-
634
- // Exclude tasks in completed milestones (default: true)
635
- const excludeCompleted = filter?.excludeCompletedMilestones ?? true;
636
- if (excludeCompleted) {
637
- // Build set of closed milestone IDs from cache
638
- const closedMilestoneIds = new Set(
639
- Object.entries(cache.milestones)
640
- .filter(([, m]) => m.state === 'closed')
641
- .map(([id]) => id)
642
- );
643
- tasks = tasks.filter((t) => !t.milestone || !closedMilestoneIds.has(t.milestone));
644
- }
645
-
646
- // Hide tasks without milestone if configured
647
- if (filter?.hideOrphanTasks) {
648
- tasks = tasks.filter((t) => t.milestone);
649
- }
650
-
651
- this.saveCache(cache);
652
- return tasks.sort((a, b) => parseInt(a.id) - parseInt(b.id));
653
- } catch (error) {
654
- // Surface errors instead of silently returning empty array
655
- const err = error as Error;
656
- if (err.message.includes('auth')) {
657
- throw err; // Re-throw auth errors
658
- }
659
- return [];
660
- }
661
- }
662
-
663
- async updateTask(id: string, input: UpdateTaskInput): Promise<Task> {
664
- const number = this.getIssueNumber(id);
665
- if (!number) throw new Error(`Task ${id} not found`);
666
-
667
- const args = ['issue', 'edit', String(number)];
668
-
669
- if (input.title) {
670
- args.push('--title', input.title);
671
- }
672
-
673
- // NOTE: Assignee updates are not supported for GitHub backend (security risk)
674
-
675
- if (input.milestone) {
676
- const milestoneName = this.getMilestoneName(input.milestone);
677
- if (milestoneName) {
678
- args.push('--milestone', milestoneName);
679
- }
680
- }
681
-
682
- // Handle status change via labels
683
- if (input.status) {
684
- if (input.status === 'done') {
685
- // Close the issue
686
- this.gh(['issue', 'close', String(number)]);
687
- // Remove status labels
688
- this.gh(['issue', 'edit', String(number), '--remove-label', LABELS.PENDING]);
689
- this.gh(['issue', 'edit', String(number), '--remove-label', LABELS.IN_PROGRESS]);
690
- } else {
691
- // Reopen if needed
692
- this.gh(['issue', 'reopen', String(number)]);
693
- // Update labels
694
- const newLabel = this.statusToLabel(input.status);
695
- const oldLabel = input.status === 'pending' ? LABELS.IN_PROGRESS : LABELS.PENDING;
696
- if (newLabel) {
697
- this.gh([
698
- 'issue',
699
- 'edit',
700
- String(number),
701
- '--add-label',
702
- newLabel,
703
- '--remove-label',
704
- oldLabel,
705
- ]);
706
- }
707
- }
708
- }
709
-
710
- // Apply other edits
711
- if (args.length > 3) {
712
- this.gh(args);
713
- }
714
-
715
- // Return updated task
716
- const task = await this.getTask(id);
717
- if (!task) throw new Error(`Task ${id} not found after update`);
718
- return task;
719
- }
720
-
721
- async deleteTask(id: string): Promise<void> {
722
- const number = this.getIssueNumber(id);
723
- if (!number) return;
724
-
725
- // Close the issue (GitHub doesn't allow deleting issues via API)
726
- try {
727
- this.gh(['issue', 'close', String(number)]);
728
- // Add a label to indicate it was deleted
729
- this.gh(['issue', 'edit', String(number), '--add-label', LABELS.DELETED]);
730
- } catch {
731
- // Already closed or not found
732
- }
733
-
734
- // Remove from cache
735
- const cache = this.loadCache();
736
- delete cache.tasks[id];
737
- this.saveCache(cache);
738
- }
739
-
740
- // ─────────────────────────────────────────────────────────────
741
- // Documentation (Issue body)
742
- // ─────────────────────────────────────────────────────────────
743
-
744
- async getTaskDoc(id: string): Promise<string | null> {
745
- const number = this.getIssueNumber(id);
746
- if (!number) return null;
747
-
748
- try {
749
- const issue = this.ghJson<GHIssue>(['issue', 'view', String(number), '--json', 'body']);
750
- return issue.body || null;
751
- } catch {
752
- return null;
753
- }
754
- }
755
-
756
- async setTaskDoc(id: string, content: string): Promise<void> {
757
- const number = this.getIssueNumber(id);
758
- if (!number) throw new Error(`Task ${id} not found`);
759
-
760
- this.gh(['issue', 'edit', String(number), '--body', content]);
761
- }
762
-
763
- async getMilestoneDoc(id: string): Promise<string | null> {
764
- const number = this.getMilestoneNumber(id);
765
- if (!number) return null;
766
-
767
- try {
768
- const result = this.gh(['api', `repos/${this.repo}/milestones/${number}`]);
769
- const ghMilestone = JSON.parse(result) as GHMilestone;
770
- return ghMilestone.description || null;
771
- } catch {
772
- return null;
773
- }
774
- }
775
-
776
- async setMilestoneDoc(id: string, content: string): Promise<void> {
777
- const number = this.getMilestoneNumber(id);
778
- if (!number) throw new Error(`Milestone ${id} not found`);
779
-
780
- // Use -F with @- to read from stdin (handles multiline content properly)
781
- this.gh(
782
- ['api', `repos/${this.repo}/milestones/${number}`, '-X', 'PATCH', '-F', 'description=@-'],
783
- { input: content }
784
- );
785
- }
786
-
787
- // ─────────────────────────────────────────────────────────────
788
- // Audit log (Issue Comments)
789
- // ─────────────────────────────────────────────────────────────
790
-
791
- async appendEvent(event: AuditEvent): Promise<void> {
792
- // Also append to local JSONL for offline access
793
- fs.mkdirSync(this.teamDir, { recursive: true });
794
- fs.appendFileSync(this.eventsFile, JSON.stringify(event) + '\n', { flag: 'a' });
795
-
796
- // If event is related to a task, add comment to the issue
797
- // Events are named: task_created, task_updated, etc.
798
- if (event.id && event.event.startsWith('task_')) {
799
- const number = this.getIssueNumber(event.id);
800
- if (number) {
801
- // Format a readable comment
802
- let comment = `[tmux-team] **${event.actor}** - \`${event.event}\``;
803
- if (event.field && event.from !== undefined && event.to !== undefined) {
804
- comment += `\n\n${event.field}: ${event.from} → ${event.to}`;
805
- }
806
- try {
807
- this.gh(['issue', 'comment', String(number), '--body', comment]);
808
- } catch {
809
- // Ignore comment failures (might be rate limited)
810
- }
811
- }
812
- }
813
- }
814
-
815
- async getEvents(limit?: number): Promise<AuditEvent[]> {
816
- // Read from local JSONL (primary source)
817
- if (!fs.existsSync(this.eventsFile)) return [];
818
- const lines = fs.readFileSync(this.eventsFile, 'utf-8').trim().split('\n');
819
- const events: AuditEvent[] = [];
820
- for (const line of lines) {
821
- if (line.trim()) {
822
- try {
823
- events.push(JSON.parse(line) as AuditEvent);
824
- } catch {
825
- // Skip malformed lines
826
- }
827
- }
828
- }
829
- if (limit) {
830
- return events.slice(-limit);
831
- }
832
- return events;
833
- }
834
- }
835
-
836
- // ─────────────────────────────────────────────────────────────
837
- // Factory function
838
- // ─────────────────────────────────────────────────────────────
839
-
840
- export function createGitHubAdapter(teamDir: string, repo: string): StorageAdapter {
841
- return new GitHubAdapter(teamDir, repo);
842
- }