@aprovan/hardcopy 0.1.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.
Files changed (53) hide show
  1. package/.eslintrc.json +22 -0
  2. package/.github/workflows/publish.yml +41 -0
  3. package/.prettierignore +17 -0
  4. package/LICENSE +21 -0
  5. package/README.md +183 -0
  6. package/dist/cli.d.ts +1 -0
  7. package/dist/cli.js +2950 -0
  8. package/dist/index.d.ts +406 -0
  9. package/dist/index.js +2737 -0
  10. package/dist/mcp-server.d.ts +7 -0
  11. package/dist/mcp-server.js +2665 -0
  12. package/docs/research/crdt.md +777 -0
  13. package/docs/research/github-issues.md +684 -0
  14. package/docs/research/gql.md +876 -0
  15. package/docs/research/index.md +19 -0
  16. package/docs/specs/conflict-resolution.md +1254 -0
  17. package/docs/specs/hardcopy.md +742 -0
  18. package/docs/specs/patchwork-integration.md +227 -0
  19. package/docs/specs/plugin-architecture.md +747 -0
  20. package/mcp.json +8 -0
  21. package/package.json +64 -0
  22. package/scripts/install-graphqlite.ts +156 -0
  23. package/src/cli.ts +356 -0
  24. package/src/config.ts +104 -0
  25. package/src/conflict-store.ts +136 -0
  26. package/src/conflict.ts +147 -0
  27. package/src/crdt.ts +100 -0
  28. package/src/db.ts +600 -0
  29. package/src/env.ts +34 -0
  30. package/src/format.ts +72 -0
  31. package/src/formats/github-issue.ts +55 -0
  32. package/src/hardcopy/core.ts +78 -0
  33. package/src/hardcopy/diff.ts +188 -0
  34. package/src/hardcopy/index.ts +67 -0
  35. package/src/hardcopy/init.ts +24 -0
  36. package/src/hardcopy/push.ts +444 -0
  37. package/src/hardcopy/sync.ts +37 -0
  38. package/src/hardcopy/types.ts +49 -0
  39. package/src/hardcopy/views.ts +199 -0
  40. package/src/hardcopy.ts +1 -0
  41. package/src/index.ts +13 -0
  42. package/src/llm-merge.ts +109 -0
  43. package/src/mcp-server.ts +388 -0
  44. package/src/merge.ts +75 -0
  45. package/src/provider.ts +40 -0
  46. package/src/providers/a2a/index.ts +166 -0
  47. package/src/providers/git/index.ts +212 -0
  48. package/src/providers/github/index.ts +236 -0
  49. package/src/providers/github/issues.ts +66 -0
  50. package/src/providers.ts +7 -0
  51. package/src/types.ts +101 -0
  52. package/tsconfig.json +21 -0
  53. package/tsup.config.ts +10 -0
@@ -0,0 +1,684 @@
1
+ # GitHub Issues API Research
2
+
3
+ ## Overview
4
+
5
+ This document covers the GitHub APIs for syncing issues and projects to local storage. The sync plugin needs to:
6
+ 1. Pull/sync GitHub issues to local Markdown files
7
+ 2. Sync local edits back to GitHub using CRDT conflict resolution
8
+ 3. Load GitHub Projects views (Kanban, roadmap, priority boards)
9
+ 4. Store metadata locally (YAML/JSON/Markdown)
10
+ 5. Track sync state per issue and for project metadata
11
+
12
+ ---
13
+
14
+ ## REST API: Issues
15
+
16
+ ### Base Configuration
17
+ - **API Version**: `2022-11-28` (specified via `X-GitHub-Api-Version` header)
18
+ - **Base URL**: `https://api.github.com`
19
+ - **Authentication**: Bearer token (PAT or OAuth token)
20
+ - **Rate Limits**:
21
+ - Authenticated: 5,000 requests/hour
22
+ - Unauthenticated: 60 requests/hour
23
+ - GitHub App installations: 5,000-15,000 requests/hour (scales with repos/users)
24
+
25
+ ### Key Endpoints
26
+
27
+ #### List Issues for a Repository
28
+ ```
29
+ GET /repos/{owner}/{repo}/issues
30
+ ```
31
+
32
+ Query Parameters:
33
+ - `state`: `open`, `closed`, `all`
34
+ - `labels`: Comma-separated label names
35
+ - `sort`: `created`, `updated`, `comments`
36
+ - `direction`: `asc`, `desc`
37
+ - `since`: ISO 8601 timestamp (for incremental sync)
38
+ - `per_page`: 1-100 (default 30)
39
+ - `page`: Page number
40
+
41
+ #### Get a Single Issue
42
+ ```
43
+ GET /repos/{owner}/{repo}/issues/{issue_number}
44
+ ```
45
+
46
+ #### Create an Issue
47
+ ```
48
+ POST /repos/{owner}/{repo}/issues
49
+ ```
50
+
51
+ Request Body:
52
+ ```json
53
+ {
54
+ "title": "Issue title",
55
+ "body": "Issue body in Markdown",
56
+ "labels": ["bug", "priority-high"],
57
+ "assignees": ["username"],
58
+ "milestone": 1
59
+ }
60
+ ```
61
+
62
+ #### Update an Issue
63
+ ```
64
+ PATCH /repos/{owner}/{repo}/issues/{issue_number}
65
+ ```
66
+
67
+ Request Body (any subset):
68
+ ```json
69
+ {
70
+ "title": "Updated title",
71
+ "body": "Updated body",
72
+ "state": "closed",
73
+ "state_reason": "completed",
74
+ "labels": ["bug"],
75
+ "assignees": ["username"]
76
+ }
77
+ ```
78
+
79
+ ### Issue Object Schema
80
+
81
+ ```typescript
82
+ interface GitHubIssue {
83
+ id: number; // Unique ID across GitHub
84
+ node_id: string; // GraphQL node ID
85
+ url: string; // API URL
86
+ html_url: string; // Web URL
87
+ number: number; // Issue number (per repo)
88
+ state: 'open' | 'closed';
89
+ state_reason: 'completed' | 'not_planned' | 'reopened' | null;
90
+ title: string;
91
+ body: string | null; // Markdown content
92
+ user: GitHubUser;
93
+ labels: GitHubLabel[];
94
+ assignees: GitHubUser[];
95
+ milestone: GitHubMilestone | null;
96
+ locked: boolean;
97
+ comments: number;
98
+ created_at: string; // ISO 8601
99
+ updated_at: string; // ISO 8601
100
+ closed_at: string | null; // ISO 8601
101
+ closed_by: GitHubUser | null;
102
+ reactions: ReactionRollup;
103
+ }
104
+
105
+ interface GitHubLabel {
106
+ id: number;
107
+ node_id: string;
108
+ name: string;
109
+ description: string | null;
110
+ color: string; // Hex without #
111
+ default: boolean;
112
+ }
113
+
114
+ interface GitHubUser {
115
+ login: string;
116
+ id: number;
117
+ node_id: string;
118
+ avatar_url: string;
119
+ html_url: string;
120
+ type: 'User' | 'Organization' | 'Bot';
121
+ }
122
+
123
+ interface GitHubMilestone {
124
+ id: number;
125
+ node_id: string;
126
+ number: number;
127
+ title: string;
128
+ description: string | null;
129
+ state: 'open' | 'closed';
130
+ due_on: string | null; // ISO 8601
131
+ }
132
+ ```
133
+
134
+ ### Rate Limit Headers
135
+
136
+ Every response includes:
137
+ ```
138
+ x-ratelimit-limit: 5000
139
+ x-ratelimit-remaining: 4999
140
+ x-ratelimit-used: 1
141
+ x-ratelimit-reset: 1701234567
142
+ x-ratelimit-resource: core
143
+ ```
144
+
145
+ ### Conditional Requests (ETags)
146
+
147
+ Use ETags to avoid counting against rate limit for unchanged data:
148
+
149
+ ```
150
+ GET /repos/{owner}/{repo}/issues
151
+ If-None-Match: "abc123"
152
+ ```
153
+
154
+ Response: `304 Not Modified` if unchanged (no body, doesn't count against limit)
155
+
156
+ ---
157
+
158
+ ## GraphQL API: ProjectsV2
159
+
160
+ ### Why GraphQL for Projects?
161
+
162
+ Projects (new) are only fully accessible via GraphQL. The REST API for classic projects is deprecated (April 2025).
163
+
164
+ ### Key Types
165
+
166
+ ```graphql
167
+ type ProjectV2 {
168
+ id: ID!
169
+ title: String!
170
+ shortDescription: String
171
+ public: Boolean!
172
+ closed: Boolean!
173
+ closedAt: DateTime
174
+ createdAt: DateTime!
175
+ updatedAt: DateTime!
176
+ number: Int!
177
+ url: URI!
178
+
179
+ # Relationships
180
+ fields(first: Int, after: String): ProjectV2FieldConfigurationConnection!
181
+ items(first: Int, after: String): ProjectV2ItemConnection!
182
+ views(first: Int, after: String): ProjectV2ViewConnection!
183
+ owner: ProjectV2Owner!
184
+ }
185
+
186
+ type ProjectV2Item {
187
+ id: ID!
188
+ type: ProjectV2ItemType! # ISSUE, PULL_REQUEST, DRAFT_ISSUE, REDACTED
189
+ isArchived: Boolean!
190
+ content: ProjectV2ItemContent # Issue or PullRequest
191
+ createdAt: DateTime!
192
+ updatedAt: DateTime!
193
+
194
+ # Field values
195
+ fieldValues(first: Int): ProjectV2ItemFieldValueConnection!
196
+ }
197
+
198
+ type ProjectV2View {
199
+ id: ID!
200
+ name: String!
201
+ number: Int!
202
+ layout: ProjectV2ViewLayout! # TABLE_LAYOUT, BOARD_LAYOUT, ROADMAP_LAYOUT
203
+
204
+ # Filtering and grouping
205
+ filter: String
206
+ sortBy: [ProjectV2SortBy!]
207
+ groupBy: [ProjectV2FieldConfiguration!]
208
+ verticalGroupBy: [ProjectV2FieldConfiguration!]
209
+ visibleFields(first: Int): ProjectV2FieldConfigurationConnection
210
+ }
211
+
212
+ union ProjectV2FieldConfiguration =
213
+ | ProjectV2Field
214
+ | ProjectV2IterationField
215
+ | ProjectV2SingleSelectField
216
+
217
+ type ProjectV2SingleSelectField {
218
+ id: ID!
219
+ name: String!
220
+ dataType: ProjectV2FieldType! # SINGLE_SELECT
221
+ options: [ProjectV2SingleSelectFieldOption!]!
222
+ }
223
+
224
+ type ProjectV2SingleSelectFieldOption {
225
+ id: String!
226
+ name: String!
227
+ nameHTML: String!
228
+ color: ProjectV2SingleSelectFieldOptionColor!
229
+ description: String
230
+ descriptionHTML: String
231
+ }
232
+
233
+ type ProjectV2IterationField {
234
+ id: ID!
235
+ name: String!
236
+ configuration: ProjectV2IterationFieldConfiguration!
237
+ }
238
+
239
+ type ProjectV2IterationFieldConfiguration {
240
+ duration: Int! # Days
241
+ startDay: Int! # 0=Monday
242
+ iterations: [ProjectV2IterationFieldIteration!]!
243
+ completedIterations: [ProjectV2IterationFieldIteration!]!
244
+ }
245
+ ```
246
+
247
+ ### Key Queries
248
+
249
+ #### Fetch Project with Views and Fields
250
+ ```graphql
251
+ query GetProject($projectId: ID!) {
252
+ node(id: $projectId) {
253
+ ... on ProjectV2 {
254
+ id
255
+ title
256
+ shortDescription
257
+ public
258
+ closed
259
+ updatedAt
260
+
261
+ # Get all field definitions
262
+ fields(first: 50) {
263
+ nodes {
264
+ ... on ProjectV2Field {
265
+ id
266
+ name
267
+ dataType
268
+ }
269
+ ... on ProjectV2SingleSelectField {
270
+ id
271
+ name
272
+ dataType
273
+ options {
274
+ id
275
+ name
276
+ color
277
+ description
278
+ }
279
+ }
280
+ ... on ProjectV2IterationField {
281
+ id
282
+ name
283
+ configuration {
284
+ duration
285
+ startDay
286
+ iterations {
287
+ id
288
+ title
289
+ startDate
290
+ duration
291
+ }
292
+ }
293
+ }
294
+ }
295
+ }
296
+
297
+ # Get all views (boards, roadmaps, tables)
298
+ views(first: 20) {
299
+ nodes {
300
+ id
301
+ name
302
+ number
303
+ layout
304
+ filter
305
+ }
306
+ }
307
+ }
308
+ }
309
+ }
310
+ ```
311
+
312
+ #### Fetch Project Items (Issues in Project)
313
+ ```graphql
314
+ query GetProjectItems($projectId: ID!, $cursor: String) {
315
+ node(id: $projectId) {
316
+ ... on ProjectV2 {
317
+ items(first: 100, after: $cursor) {
318
+ pageInfo {
319
+ hasNextPage
320
+ endCursor
321
+ }
322
+ nodes {
323
+ id
324
+ type
325
+ isArchived
326
+
327
+ # Get the issue/PR content
328
+ content {
329
+ ... on Issue {
330
+ id
331
+ number
332
+ title
333
+ body
334
+ state
335
+ url
336
+ labels(first: 20) {
337
+ nodes { name color }
338
+ }
339
+ assignees(first: 10) {
340
+ nodes { login }
341
+ }
342
+ }
343
+ ... on PullRequest {
344
+ id
345
+ number
346
+ title
347
+ state
348
+ url
349
+ }
350
+ }
351
+
352
+ # Get custom field values
353
+ fieldValues(first: 20) {
354
+ nodes {
355
+ ... on ProjectV2ItemFieldTextValue {
356
+ text
357
+ field { ... on ProjectV2Field { name } }
358
+ }
359
+ ... on ProjectV2ItemFieldNumberValue {
360
+ number
361
+ field { ... on ProjectV2Field { name } }
362
+ }
363
+ ... on ProjectV2ItemFieldDateValue {
364
+ date
365
+ field { ... on ProjectV2Field { name } }
366
+ }
367
+ ... on ProjectV2ItemFieldSingleSelectValue {
368
+ name
369
+ optionId
370
+ field { ... on ProjectV2SingleSelectField { name } }
371
+ }
372
+ ... on ProjectV2ItemFieldIterationValue {
373
+ title
374
+ iterationId
375
+ field { ... on ProjectV2IterationField { name } }
376
+ }
377
+ }
378
+ }
379
+ }
380
+ }
381
+ }
382
+ }
383
+ }
384
+ ```
385
+
386
+ ### Key Mutations
387
+
388
+ #### Update Item Field Value
389
+ ```graphql
390
+ mutation UpdateItemField(
391
+ $projectId: ID!,
392
+ $itemId: ID!,
393
+ $fieldId: ID!,
394
+ $value: ProjectV2FieldValue!
395
+ ) {
396
+ updateProjectV2ItemFieldValue(input: {
397
+ projectId: $projectId
398
+ itemId: $itemId
399
+ fieldId: $fieldId
400
+ value: $value
401
+ }) {
402
+ projectV2Item {
403
+ id
404
+ updatedAt
405
+ }
406
+ }
407
+ }
408
+ ```
409
+
410
+ #### Add Item to Project
411
+ ```graphql
412
+ mutation AddItemToProject($projectId: ID!, $contentId: ID!) {
413
+ addProjectV2ItemById(input: {
414
+ projectId: $projectId
415
+ contentId: $contentId
416
+ }) {
417
+ item {
418
+ id
419
+ }
420
+ }
421
+ }
422
+ ```
423
+
424
+ ---
425
+
426
+ ## Webhooks for Real-Time Sync
427
+
428
+ ### Relevant Events
429
+
430
+ | Event | Actions | Description |
431
+ |-------|---------|-------------|
432
+ | `issues` | opened, edited, deleted, closed, reopened, assigned, unassigned, labeled, unlabeled, locked, unlocked, transferred, milestoned, demilestoned | Issue changes |
433
+ | `issue_comment` | created, edited, deleted | Comments on issues |
434
+ | `projects_v2` | created, edited, closed, reopened, deleted | Project changes |
435
+ | `projects_v2_item` | created, edited, archived, restored, deleted, reordered, converted | Project item changes |
436
+ | `label` | created, edited, deleted | Label definitions |
437
+ | `milestone` | created, closed, opened, edited, deleted | Milestone changes |
438
+
439
+ ### Webhook Payload Structure
440
+
441
+ ```typescript
442
+ interface IssuesWebhookPayload {
443
+ action: string;
444
+ issue: GitHubIssue;
445
+ changes?: {
446
+ title?: { from: string };
447
+ body?: { from: string };
448
+ };
449
+ repository: GitHubRepository;
450
+ sender: GitHubUser;
451
+ }
452
+
453
+ interface ProjectsV2ItemWebhookPayload {
454
+ action: 'created' | 'edited' | 'archived' | 'restored' | 'deleted' | 'reordered' | 'converted';
455
+ changes?: {
456
+ field_value?: {
457
+ field_node_id: string;
458
+ field_type: string;
459
+ };
460
+ };
461
+ projects_v2_item: {
462
+ id: number;
463
+ node_id: string;
464
+ project_node_id: string;
465
+ content_node_id: string;
466
+ content_type: 'Issue' | 'PullRequest' | 'DraftIssue';
467
+ creator: GitHubUser;
468
+ created_at: string;
469
+ updated_at: string;
470
+ archived_at: string | null;
471
+ };
472
+ sender: GitHubUser;
473
+ organization: GitHubOrganization;
474
+ }
475
+ ```
476
+
477
+ ### Webhook Security
478
+
479
+ Verify webhooks using HMAC:
480
+ ```typescript
481
+ import { createHmac } from 'crypto';
482
+
483
+ function verifyWebhook(payload: string, signature: string, secret: string): boolean {
484
+ const expected = 'sha256=' + createHmac('sha256', secret)
485
+ .update(payload, 'utf8')
486
+ .digest('hex');
487
+ return timingSafeEqual(Buffer.from(signature), Buffer.from(expected));
488
+ }
489
+ ```
490
+
491
+ ---
492
+
493
+ ## Repository Metadata
494
+
495
+ ### Labels
496
+ ```
497
+ GET /repos/{owner}/{repo}/labels
498
+ ```
499
+
500
+ ### Milestones
501
+ ```
502
+ GET /repos/{owner}/{repo}/milestones
503
+ ```
504
+
505
+ ### Assignees (collaborators)
506
+ ```
507
+ GET /repos/{owner}/{repo}/assignees
508
+ ```
509
+
510
+ ---
511
+
512
+ ## Local File Format
513
+
514
+ ### Issue File Structure
515
+ ```
516
+ .github-sync/
517
+ ├── issues/
518
+ │ ├── 001-first-issue.md
519
+ │ ├── 002-second-issue.md
520
+ │ └── ...
521
+ ├── projects/
522
+ │ ├── project-1/
523
+ │ │ ├── metadata.yaml
524
+ │ │ ├── views/
525
+ │ │ │ ├── board.yaml
526
+ │ │ │ ├── roadmap.yaml
527
+ │ │ │ └── backlog.yaml
528
+ │ │ └── items.yaml
529
+ │ └── ...
530
+ ├── metadata/
531
+ │ ├── labels.yaml
532
+ │ ├── milestones.yaml
533
+ │ └── assignees.yaml
534
+ └── sync-state.yaml
535
+ ```
536
+
537
+ ### Issue Markdown Format
538
+ ```markdown
539
+ ---
540
+ id: 12345
541
+ node_id: "I_kwDOABC123"
542
+ number: 42
543
+ url: "https://github.com/owner/repo/issues/42"
544
+ state: open
545
+ state_reason: null
546
+ labels:
547
+ - bug
548
+ - priority-high
549
+ assignees:
550
+ - username
551
+ milestone: "v2.0"
552
+ created_at: "2024-01-15T10:30:00Z"
553
+ updated_at: "2024-01-20T14:45:00Z"
554
+ sync_version: "abc123def456" # CRDT version for conflict resolution
555
+ ---
556
+
557
+ # Issue Title
558
+
559
+ Issue body content in Markdown...
560
+ ```
561
+
562
+ ### Project View YAML Format
563
+ ```yaml
564
+ # views/board.yaml
565
+ id: "PVT_kwDOABC123"
566
+ name: "Sprint Board"
567
+ layout: BOARD_LAYOUT
568
+ filter: "status:open"
569
+ group_by:
570
+ field: "Status"
571
+ options:
572
+ - id: "opt_todo"
573
+ name: "To Do"
574
+ color: "GRAY"
575
+ - id: "opt_in_progress"
576
+ name: "In Progress"
577
+ color: "YELLOW"
578
+ - id: "opt_done"
579
+ name: "Done"
580
+ color: "GREEN"
581
+ items:
582
+ - issue_number: 42
583
+ status: "In Progress"
584
+ priority: "High"
585
+ iteration: "Sprint 5"
586
+ - issue_number: 43
587
+ status: "To Do"
588
+ priority: "Medium"
589
+ ```
590
+
591
+ ---
592
+
593
+ ## Sync State Tracking
594
+
595
+ ### Per-Issue Sync State
596
+ ```yaml
597
+ # sync-state.yaml
598
+ issues:
599
+ 42:
600
+ github_updated_at: "2024-01-20T14:45:00Z"
601
+ local_updated_at: "2024-01-20T14:50:00Z"
602
+ crdt_version: "base64-encoded-loro-version"
603
+ sync_status: "synced" | "local_ahead" | "remote_ahead" | "conflict"
604
+ last_sync: "2024-01-20T14:50:00Z"
605
+ etag: "\"abc123\""
606
+
607
+ projects:
608
+ "PVT_abc123":
609
+ github_updated_at: "2024-01-20T14:45:00Z"
610
+ local_updated_at: "2024-01-20T14:50:00Z"
611
+ crdt_version: "base64-encoded-loro-version"
612
+ sync_status: "synced"
613
+
614
+ metadata:
615
+ labels_etag: "\"def456\""
616
+ milestones_etag: "\"ghi789\""
617
+ last_full_sync: "2024-01-20T14:50:00Z"
618
+ ```
619
+
620
+ ---
621
+
622
+ ## Sync Algorithm
623
+
624
+ ### Initial Sync (Pull)
625
+ 1. Fetch all issues with `GET /repos/{owner}/{repo}/issues?state=all`
626
+ 2. Fetch all projects via GraphQL
627
+ 3. Store ETags and `updated_at` timestamps
628
+ 4. Initialize CRDT documents for each issue
629
+ 5. Write local files
630
+
631
+ ### Incremental Sync (Pull)
632
+ 1. Use `since` parameter: `GET /repos/{owner}/{repo}/issues?since={last_sync}`
633
+ 2. Use ETags for conditional requests
634
+ 3. Merge remote changes into CRDT documents
635
+ 4. Update local files only if changed
636
+
637
+ ### Push Sync
638
+ 1. Detect local changes (file modification time vs CRDT version)
639
+ 2. Generate CRDT diff
640
+ 3. Apply changes via `PATCH /repos/{owner}/{repo}/issues/{issue_number}`
641
+ 4. Or via GraphQL mutations for project fields
642
+ 5. Update sync state
643
+
644
+ ### Conflict Resolution
645
+ 1. Both local and remote have changes since last sync
646
+ 2. Merge using CRDT operations (automatic for text/lists)
647
+ 3. For single-value fields: last-write-wins with user confirmation
648
+ 4. Log conflicts for review
649
+
650
+ ---
651
+
652
+ ## Error Handling
653
+
654
+ ### Rate Limiting
655
+ ```typescript
656
+ async function fetchWithRateLimit(url: string): Promise<Response> {
657
+ const response = await fetch(url);
658
+
659
+ if (response.status === 403 || response.status === 429) {
660
+ const resetTime = response.headers.get('x-ratelimit-reset');
661
+ const waitMs = (parseInt(resetTime!) * 1000) - Date.now();
662
+ await sleep(Math.max(waitMs, 60000));
663
+ return fetchWithRateLimit(url);
664
+ }
665
+
666
+ return response;
667
+ }
668
+ ```
669
+
670
+ ### Pagination
671
+ ```typescript
672
+ async function* fetchAllIssues(owner: string, repo: string) {
673
+ let page = 1;
674
+ while (true) {
675
+ const response = await fetch(
676
+ `https://api.github.com/repos/${owner}/${repo}/issues?page=${page}&per_page=100`
677
+ );
678
+ const issues = await response.json();
679
+ if (issues.length === 0) break;
680
+ yield* issues;
681
+ page++;
682
+ }
683
+ }
684
+ ```