@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.
- package/.eslintrc.json +22 -0
- package/.github/workflows/publish.yml +41 -0
- package/.prettierignore +17 -0
- package/LICENSE +21 -0
- package/README.md +183 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +2950 -0
- package/dist/index.d.ts +406 -0
- package/dist/index.js +2737 -0
- package/dist/mcp-server.d.ts +7 -0
- package/dist/mcp-server.js +2665 -0
- package/docs/research/crdt.md +777 -0
- package/docs/research/github-issues.md +684 -0
- package/docs/research/gql.md +876 -0
- package/docs/research/index.md +19 -0
- package/docs/specs/conflict-resolution.md +1254 -0
- package/docs/specs/hardcopy.md +742 -0
- package/docs/specs/patchwork-integration.md +227 -0
- package/docs/specs/plugin-architecture.md +747 -0
- package/mcp.json +8 -0
- package/package.json +64 -0
- package/scripts/install-graphqlite.ts +156 -0
- package/src/cli.ts +356 -0
- package/src/config.ts +104 -0
- package/src/conflict-store.ts +136 -0
- package/src/conflict.ts +147 -0
- package/src/crdt.ts +100 -0
- package/src/db.ts +600 -0
- package/src/env.ts +34 -0
- package/src/format.ts +72 -0
- package/src/formats/github-issue.ts +55 -0
- package/src/hardcopy/core.ts +78 -0
- package/src/hardcopy/diff.ts +188 -0
- package/src/hardcopy/index.ts +67 -0
- package/src/hardcopy/init.ts +24 -0
- package/src/hardcopy/push.ts +444 -0
- package/src/hardcopy/sync.ts +37 -0
- package/src/hardcopy/types.ts +49 -0
- package/src/hardcopy/views.ts +199 -0
- package/src/hardcopy.ts +1 -0
- package/src/index.ts +13 -0
- package/src/llm-merge.ts +109 -0
- package/src/mcp-server.ts +388 -0
- package/src/merge.ts +75 -0
- package/src/provider.ts +40 -0
- package/src/providers/a2a/index.ts +166 -0
- package/src/providers/git/index.ts +212 -0
- package/src/providers/github/index.ts +236 -0
- package/src/providers/github/issues.ts +66 -0
- package/src/providers.ts +7 -0
- package/src/types.ts +101 -0
- package/tsconfig.json +21 -0
- 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
|
+
```
|