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