@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,876 @@
1
+ # GQL Query Language Research
2
+
3
+ ## Overview
4
+
5
+ This document covers using GQL (Graph Query Language) / openCypher syntax for querying synced data. The goal is to provide a declarative query interface for:
6
+ - Loading project views with filters
7
+ - Traversing issue relationships (linked issues, dependencies, cross-repo references)
8
+ - Building custom views and aggregations
9
+ - Expressing graph queries over local data
10
+
11
+ ---
12
+
13
+ ## GQL / openCypher Background
14
+
15
+ ### What is GQL?
16
+
17
+ **GQL** (ISO/IEC 39075) is the upcoming international standard for property graph query languages. It's heavily influenced by:
18
+ - **openCypher**: Neo4j's declarative graph query language
19
+ - **PGQL**: Oracle's property graph query language
20
+ - **GSQL**: TigerGraph's query language
21
+
22
+ For practical purposes, we'll use openCypher syntax since it's:
23
+ 1. Widely adopted and well-documented
24
+ 2. The basis for GQL standard
25
+ 3. Has existing TypeScript tooling
26
+
27
+ ### Property Graph Model
28
+
29
+ Data is modeled as:
30
+ - **Nodes**: Entities with labels and properties
31
+ - **Edges**: Relationships between nodes with types and properties
32
+ - **Properties**: Key-value pairs on nodes and edges
33
+
34
+ ```
35
+ assignee has_label
36
+ (User:alice) <--------- (Issue:42) ----------> (Label:bug)
37
+ |
38
+ | blocks
39
+ v
40
+ (Issue:43)
41
+ ```
42
+
43
+ ---
44
+
45
+ ## Data Model for Synced Content
46
+
47
+ ### Node Types
48
+
49
+ ```typescript
50
+ // Issue node
51
+ interface IssueNode {
52
+ _type: 'Issue';
53
+ _id: string; // e.g., "github:owner/repo#42"
54
+ number: number;
55
+ title: string;
56
+ body: string;
57
+ state: 'open' | 'closed';
58
+ state_reason: string | null;
59
+ created_at: string;
60
+ updated_at: string;
61
+ url: string;
62
+ source: string; // "github", "gitlab", "jira"
63
+ }
64
+
65
+ // User node
66
+ interface UserNode {
67
+ _type: 'User';
68
+ _id: string; // e.g., "github:username"
69
+ login: string;
70
+ name: string | null;
71
+ avatar_url: string;
72
+ source: string;
73
+ }
74
+
75
+ // Label node
76
+ interface LabelNode {
77
+ _type: 'Label';
78
+ _id: string; // e.g., "github:owner/repo:bug"
79
+ name: string;
80
+ color: string;
81
+ description: string | null;
82
+ }
83
+
84
+ // Milestone node
85
+ interface MilestoneNode {
86
+ _type: 'Milestone';
87
+ _id: string;
88
+ title: string;
89
+ description: string | null;
90
+ due_on: string | null;
91
+ state: 'open' | 'closed';
92
+ }
93
+
94
+ // Project node
95
+ interface ProjectNode {
96
+ _type: 'Project';
97
+ _id: string; // e.g., "github:project:123"
98
+ title: string;
99
+ description: string | null;
100
+ url: string;
101
+ }
102
+
103
+ // ProjectView node
104
+ interface ProjectViewNode {
105
+ _type: 'ProjectView';
106
+ _id: string;
107
+ name: string;
108
+ layout: 'TABLE' | 'BOARD' | 'ROADMAP';
109
+ filter: string | null;
110
+ }
111
+
112
+ // FieldDefinition node
113
+ interface FieldDefinitionNode {
114
+ _type: 'FieldDefinition';
115
+ _id: string;
116
+ name: string;
117
+ data_type: 'TEXT' | 'NUMBER' | 'DATE' | 'SINGLE_SELECT' | 'ITERATION';
118
+ options?: { id: string; name: string; color: string }[];
119
+ }
120
+ ```
121
+
122
+ ### Edge Types
123
+
124
+ ```typescript
125
+ interface Edge {
126
+ _type: string;
127
+ _from: string; // Node ID
128
+ _to: string; // Node ID
129
+ properties?: Record<string, any>;
130
+ }
131
+
132
+ // Edge types:
133
+ // - CREATED_BY: Issue -> User
134
+ // - ASSIGNED_TO: Issue -> User
135
+ // - HAS_LABEL: Issue -> Label
136
+ // - HAS_MILESTONE: Issue -> Milestone
137
+ // - BELONGS_TO_PROJECT: Issue -> Project
138
+ // - BLOCKS: Issue -> Issue
139
+ // - BLOCKED_BY: Issue -> Issue
140
+ // - REFERENCES: Issue -> Issue
141
+ // - CHILD_OF: Issue -> Issue (sub-issues)
142
+ // - HAS_VIEW: Project -> ProjectView
143
+ // - HAS_FIELD: Project -> FieldDefinition
144
+ // - FIELD_VALUE: Issue -> FieldDefinition (with value property)
145
+ ```
146
+
147
+ ---
148
+
149
+ ## Query Syntax
150
+
151
+ ### Basic Pattern Matching
152
+
153
+ ```cypher
154
+ // Find all open issues
155
+ MATCH (i:Issue {state: 'open'})
156
+ RETURN i.number, i.title
157
+
158
+ // Find issues assigned to a user
159
+ MATCH (i:Issue)-[:ASSIGNED_TO]->(u:User {login: 'alice'})
160
+ RETURN i.number, i.title
161
+
162
+ // Find issues with a specific label
163
+ MATCH (i:Issue)-[:HAS_LABEL]->(l:Label {name: 'bug'})
164
+ RETURN i.number, i.title, i.state
165
+ ```
166
+
167
+ ### Relationship Traversal
168
+
169
+ ```cypher
170
+ // Find issues blocking other issues
171
+ MATCH (blocker:Issue)-[:BLOCKS]->(blocked:Issue)
172
+ RETURN blocker.number AS blocking, blocked.number AS blocked_by
173
+
174
+ // Find all issues in the dependency chain
175
+ MATCH path = (i:Issue {number: 42})-[:BLOCKS*1..5]->(blocked:Issue)
176
+ RETURN path
177
+
178
+ // Find issues with no blockers (ready to work)
179
+ MATCH (i:Issue {state: 'open'})
180
+ WHERE NOT (i)-[:BLOCKED_BY]->(:Issue {state: 'open'})
181
+ RETURN i.number, i.title
182
+ ```
183
+
184
+ ### Project Views
185
+
186
+ ```cypher
187
+ // Get issues in a board view grouped by status
188
+ MATCH (i:Issue)-[:BELONGS_TO_PROJECT]->(p:Project {title: 'Sprint Board'})
189
+ MATCH (i)-[fv:FIELD_VALUE]->(f:FieldDefinition {name: 'Status'})
190
+ RETURN f.name AS field, fv.value AS status, collect(i) AS issues
191
+
192
+ // Get roadmap items with dates
193
+ MATCH (i:Issue)-[:BELONGS_TO_PROJECT]->(p:Project)
194
+ MATCH (i)-[iter:FIELD_VALUE]->(f:FieldDefinition {name: 'Iteration'})
195
+ RETURN i.number, i.title, iter.value AS iteration, iter.start_date, iter.end_date
196
+ ORDER BY iter.start_date
197
+ ```
198
+
199
+ ### Aggregations
200
+
201
+ ```cypher
202
+ // Count issues by label
203
+ MATCH (i:Issue)-[:HAS_LABEL]->(l:Label)
204
+ RETURN l.name, count(i) AS issue_count
205
+ ORDER BY issue_count DESC
206
+
207
+ // Count issues by assignee
208
+ MATCH (i:Issue {state: 'open'})-[:ASSIGNED_TO]->(u:User)
209
+ RETURN u.login, count(i) AS assigned_count
210
+ ORDER BY assigned_count DESC
211
+
212
+ // Issues per milestone progress
213
+ MATCH (i:Issue)-[:HAS_MILESTONE]->(m:Milestone)
214
+ RETURN m.title,
215
+ count(CASE WHEN i.state = 'closed' THEN 1 END) AS closed,
216
+ count(i) AS total,
217
+ round(count(CASE WHEN i.state = 'closed' THEN 1 END) * 100.0 / count(i)) AS percent_complete
218
+ ```
219
+
220
+ ### Cross-Repository Queries
221
+
222
+ ```cypher
223
+ // Find issues referencing other repos
224
+ MATCH (i:Issue)-[:REFERENCES]->(ref:Issue)
225
+ WHERE i.source_repo <> ref.source_repo
226
+ RETURN i.source_repo, i.number, ref.source_repo, ref.number
227
+
228
+ // Find shared assignees across repos
229
+ MATCH (i1:Issue)-[:ASSIGNED_TO]->(u:User)<-[:ASSIGNED_TO]-(i2:Issue)
230
+ WHERE i1.source_repo <> i2.source_repo
231
+ RETURN u.login, collect(DISTINCT i1.source_repo) AS repos
232
+ ```
233
+
234
+ ---
235
+
236
+ ## Query Engine Implementation
237
+
238
+ ### In-Memory Graph Store
239
+
240
+ ```typescript
241
+ class GraphStore {
242
+ private nodes = new Map<string, Node>();
243
+ private edges: Edge[] = [];
244
+ private nodesByType = new Map<string, Set<string>>();
245
+ private edgeIndex = new Map<string, Edge[]>(); // from_id -> edges
246
+ private reverseEdgeIndex = new Map<string, Edge[]>(); // to_id -> edges
247
+
248
+ addNode(node: Node): void {
249
+ this.nodes.set(node._id, node);
250
+
251
+ if (!this.nodesByType.has(node._type)) {
252
+ this.nodesByType.set(node._type, new Set());
253
+ }
254
+ this.nodesByType.get(node._type)!.add(node._id);
255
+ }
256
+
257
+ addEdge(edge: Edge): void {
258
+ this.edges.push(edge);
259
+
260
+ if (!this.edgeIndex.has(edge._from)) {
261
+ this.edgeIndex.set(edge._from, []);
262
+ }
263
+ this.edgeIndex.get(edge._from)!.push(edge);
264
+
265
+ if (!this.reverseEdgeIndex.has(edge._to)) {
266
+ this.reverseEdgeIndex.set(edge._to, []);
267
+ }
268
+ this.reverseEdgeIndex.get(edge._to)!.push(edge);
269
+ }
270
+
271
+ getNode(id: string): Node | undefined {
272
+ return this.nodes.get(id);
273
+ }
274
+
275
+ getNodesByType(type: string): Node[] {
276
+ const ids = this.nodesByType.get(type) || new Set();
277
+ return Array.from(ids).map(id => this.nodes.get(id)!);
278
+ }
279
+
280
+ getOutgoingEdges(nodeId: string, edgeType?: string): Edge[] {
281
+ const edges = this.edgeIndex.get(nodeId) || [];
282
+ if (edgeType) {
283
+ return edges.filter(e => e._type === edgeType);
284
+ }
285
+ return edges;
286
+ }
287
+
288
+ getIncomingEdges(nodeId: string, edgeType?: string): Edge[] {
289
+ const edges = this.reverseEdgeIndex.get(nodeId) || [];
290
+ if (edgeType) {
291
+ return edges.filter(e => e._type === edgeType);
292
+ }
293
+ return edges;
294
+ }
295
+ }
296
+ ```
297
+
298
+ ### Simple Query Executor
299
+
300
+ ```typescript
301
+ interface QueryResult {
302
+ columns: string[];
303
+ rows: Record<string, any>[];
304
+ }
305
+
306
+ class QueryExecutor {
307
+ constructor(private store: GraphStore) {}
308
+
309
+ // Simplified query execution for common patterns
310
+ findIssues(filters: {
311
+ state?: 'open' | 'closed' | 'all';
312
+ labels?: string[];
313
+ assignees?: string[];
314
+ milestone?: string;
315
+ project?: string;
316
+ blocked_by?: boolean;
317
+ }): IssueNode[] {
318
+ let issues = this.store.getNodesByType('Issue') as IssueNode[];
319
+
320
+ if (filters.state && filters.state !== 'all') {
321
+ issues = issues.filter(i => i.state === filters.state);
322
+ }
323
+
324
+ if (filters.labels?.length) {
325
+ issues = issues.filter(i => {
326
+ const labelEdges = this.store.getOutgoingEdges(i._id, 'HAS_LABEL');
327
+ const issueLabels = labelEdges.map(e => {
328
+ const label = this.store.getNode(e._to) as LabelNode;
329
+ return label?.name;
330
+ });
331
+ return filters.labels!.every(l => issueLabels.includes(l));
332
+ });
333
+ }
334
+
335
+ if (filters.assignees?.length) {
336
+ issues = issues.filter(i => {
337
+ const assigneeEdges = this.store.getOutgoingEdges(i._id, 'ASSIGNED_TO');
338
+ const assignees = assigneeEdges.map(e => {
339
+ const user = this.store.getNode(e._to) as UserNode;
340
+ return user?.login;
341
+ });
342
+ return filters.assignees!.some(a => assignees.includes(a));
343
+ });
344
+ }
345
+
346
+ if (filters.blocked_by === false) {
347
+ // Only issues with no open blockers
348
+ issues = issues.filter(i => {
349
+ const blockerEdges = this.store.getOutgoingEdges(i._id, 'BLOCKED_BY');
350
+ const openBlockers = blockerEdges.filter(e => {
351
+ const blocker = this.store.getNode(e._to) as IssueNode;
352
+ return blocker?.state === 'open';
353
+ });
354
+ return openBlockers.length === 0;
355
+ });
356
+ }
357
+
358
+ return issues;
359
+ }
360
+
361
+ // Get dependency graph for an issue
362
+ getDependencyGraph(issueId: string, maxDepth = 5): {
363
+ nodes: IssueNode[];
364
+ edges: { from: number; to: number; type: string }[];
365
+ } {
366
+ const visited = new Set<string>();
367
+ const nodes: IssueNode[] = [];
368
+ const edges: { from: number; to: number; type: string }[] = [];
369
+
370
+ const traverse = (id: string, depth: number) => {
371
+ if (visited.has(id) || depth > maxDepth) return;
372
+ visited.add(id);
373
+
374
+ const issue = this.store.getNode(id) as IssueNode;
375
+ if (!issue) return;
376
+
377
+ const nodeIndex = nodes.length;
378
+ nodes.push(issue);
379
+
380
+ // Traverse BLOCKS relationships
381
+ const blocksEdges = this.store.getOutgoingEdges(id, 'BLOCKS');
382
+ for (const edge of blocksEdges) {
383
+ const targetIndex = nodes.findIndex(n => n._id === edge._to);
384
+ if (targetIndex === -1) {
385
+ traverse(edge._to, depth + 1);
386
+ const newIndex = nodes.length - 1;
387
+ edges.push({ from: nodeIndex, to: newIndex, type: 'BLOCKS' });
388
+ } else {
389
+ edges.push({ from: nodeIndex, to: targetIndex, type: 'BLOCKS' });
390
+ }
391
+ }
392
+ };
393
+
394
+ traverse(issueId, 0);
395
+ return { nodes, edges };
396
+ }
397
+ }
398
+ ```
399
+
400
+ ---
401
+
402
+ ## View Definitions
403
+
404
+ ### Declarative View Configuration
405
+
406
+ ```yaml
407
+ # views/kanban.yaml
408
+ name: Sprint Board
409
+ type: board
410
+ source: github:owner/repo
411
+
412
+ # What to query
413
+ query: |
414
+ MATCH (i:Issue {state: 'open'})-[:BELONGS_TO_PROJECT]->(p:Project {title: 'Sprint'})
415
+ MATCH (i)-[sv:FIELD_VALUE]->(sf:FieldDefinition {name: 'Status'})
416
+ RETURN i, sv.value AS status
417
+
418
+ # How to group
419
+ group_by:
420
+ field: status
421
+ columns:
422
+ - value: "To Do"
423
+ label: "To Do"
424
+ color: "#e0e0e0"
425
+ - value: "In Progress"
426
+ label: "In Progress"
427
+ color: "#ffd700"
428
+ - value: "Done"
429
+ label: "Done"
430
+ color: "#00ff00"
431
+
432
+ # How to sort within columns
433
+ sort_by:
434
+ - field: priority
435
+ direction: desc
436
+ - field: updated_at
437
+ direction: desc
438
+
439
+ # What to display on cards
440
+ card_template:
441
+ title: "{{ i.title }}"
442
+ subtitle: "#{{ i.number }}"
443
+ labels: true
444
+ assignees: true
445
+ fields:
446
+ - Priority
447
+ - Estimate
448
+ ```
449
+
450
+ ### Roadmap View
451
+
452
+ ```yaml
453
+ # views/roadmap.yaml
454
+ name: Product Roadmap
455
+ type: roadmap
456
+ source: github:owner/repo
457
+
458
+ query: |
459
+ MATCH (i:Issue)-[:BELONGS_TO_PROJECT]->(p:Project {title: 'Roadmap'})
460
+ MATCH (i)-[iv:FIELD_VALUE]->(f:FieldDefinition {name: 'Iteration'})
461
+ RETURN i, iv.value AS iteration, iv.start_date, iv.end_date
462
+
463
+ # Time axis configuration
464
+ time_axis:
465
+ field: iteration
466
+ start: start_date
467
+ end: end_date
468
+ granularity: week
469
+
470
+ # Swimlanes (optional)
471
+ group_by:
472
+ field: team
473
+
474
+ # Item rendering
475
+ item_template:
476
+ title: "{{ i.title }}"
477
+ progress: "{{ closed_subtasks / total_subtasks * 100 }}%"
478
+ ```
479
+
480
+ ### Filter View
481
+
482
+ ```yaml
483
+ # views/my-issues.yaml
484
+ name: My Issues
485
+ type: list
486
+ source: github:owner/repo
487
+
488
+ query: |
489
+ MATCH (i:Issue {state: 'open'})-[:ASSIGNED_TO]->(u:User {login: '{{ current_user }}'})
490
+ OPTIONAL MATCH (i)-[:HAS_LABEL]->(l:Label)
491
+ OPTIONAL MATCH (i)-[:BLOCKED_BY]->(blocker:Issue {state: 'open'})
492
+ RETURN i, collect(l.name) AS labels, count(blocker) AS open_blockers
493
+ ORDER BY i.updated_at DESC
494
+
495
+ filters:
496
+ - field: labels
497
+ type: multi-select
498
+ label: "Labels"
499
+ - field: open_blockers
500
+ type: number
501
+ label: "Has blockers"
502
+ operators: [eq, gt, lt]
503
+
504
+ columns:
505
+ - field: number
506
+ width: 60
507
+ align: right
508
+ - field: title
509
+ width: auto
510
+ link: true
511
+ - field: labels
512
+ width: 200
513
+ render: tags
514
+ - field: open_blockers
515
+ width: 80
516
+ label: "Blocked"
517
+ ```
518
+
519
+ ---
520
+
521
+ ## Query Language Parser
522
+
523
+ ### Using Existing Libraries
524
+
525
+ For a full Cypher implementation, consider:
526
+
527
+ ```typescript
528
+ // Option 1: cypher-query-builder (simpler, builder pattern)
529
+ import { Query } from 'cypher-query-builder';
530
+
531
+ const query = new Query()
532
+ .match([{ identifier: 'i', labels: ['Issue'] }])
533
+ .where({ 'i.state': 'open' })
534
+ .return(['i.number', 'i.title']);
535
+
536
+ // Option 2: openCypher parser (full parser)
537
+ // https://github.com/opencypher/openCypher
538
+ ```
539
+
540
+ ### Simple Filter Parser
541
+
542
+ ```typescript
543
+ // Parse simple filter syntax like: "state:open labels:bug,priority assignee:alice"
544
+ interface ParsedFilter {
545
+ state?: 'open' | 'closed' | 'all';
546
+ labels?: string[];
547
+ assignees?: string[];
548
+ milestone?: string;
549
+ project?: string;
550
+ is?: ('blocked' | 'blocking' | 'unassigned')[];
551
+ }
552
+
553
+ function parseFilterString(filter: string): ParsedFilter {
554
+ const result: ParsedFilter = {};
555
+ const parts = filter.split(/\s+/);
556
+
557
+ for (const part of parts) {
558
+ const [key, value] = part.split(':');
559
+
560
+ switch (key) {
561
+ case 'state':
562
+ result.state = value as any;
563
+ break;
564
+ case 'labels':
565
+ case 'label':
566
+ result.labels = value.split(',');
567
+ break;
568
+ case 'assignee':
569
+ case 'assignees':
570
+ result.assignees = value.split(',');
571
+ break;
572
+ case 'milestone':
573
+ result.milestone = value;
574
+ break;
575
+ case 'project':
576
+ result.project = value;
577
+ break;
578
+ case 'is':
579
+ result.is = value.split(',') as any;
580
+ break;
581
+ }
582
+ }
583
+
584
+ return result;
585
+ }
586
+
587
+ // Usage
588
+ const filter = parseFilterString('state:open labels:bug,priority is:blocking');
589
+ // { state: 'open', labels: ['bug', 'priority'], is: ['blocking'] }
590
+ ```
591
+
592
+ ---
593
+
594
+ ## Graph Serialization
595
+
596
+ ### Export Graph to File
597
+
598
+ ```typescript
599
+ // Export as GraphML (standard format)
600
+ function exportToGraphML(store: GraphStore): string {
601
+ const nodes = Array.from(store.getAllNodes());
602
+ const edges = store.getAllEdges();
603
+
604
+ return `<?xml version="1.0" encoding="UTF-8"?>
605
+ <graphml xmlns="http://graphml.graphdrawing.org/xmlns">
606
+ <graph id="G" edgedefault="directed">
607
+ ${nodes.map(n => `
608
+ <node id="${n._id}">
609
+ <data key="type">${n._type}</data>
610
+ ${Object.entries(n).filter(([k]) => !k.startsWith('_')).map(([k, v]) =>
611
+ `<data key="${k}">${escapeXml(String(v))}</data>`
612
+ ).join('\n ')}
613
+ </node>`).join('')}
614
+ ${edges.map((e, i) => `
615
+ <edge id="e${i}" source="${e._from}" target="${e._to}">
616
+ <data key="type">${e._type}</data>
617
+ </edge>`).join('')}
618
+ </graph>
619
+ </graphml>`;
620
+ }
621
+
622
+ // Export as JSON (simpler)
623
+ function exportToJSON(store: GraphStore): string {
624
+ return JSON.stringify({
625
+ nodes: Array.from(store.getAllNodes()),
626
+ edges: store.getAllEdges(),
627
+ }, null, 2);
628
+ }
629
+ ```
630
+
631
+ ### Import Graph from Synced Data
632
+
633
+ ```typescript
634
+ async function buildGraphFromSync(
635
+ issuesDir: string,
636
+ projectsDir: string
637
+ ): Promise<GraphStore> {
638
+ const store = new GraphStore();
639
+
640
+ // Load issues
641
+ const issueFiles = await glob(`${issuesDir}/*.md`);
642
+ for (const file of issueFiles) {
643
+ const { frontmatter, body } = parseMarkdownFile(file);
644
+
645
+ // Add issue node
646
+ store.addNode({
647
+ _type: 'Issue',
648
+ _id: `github:${frontmatter.url}`,
649
+ number: frontmatter.number,
650
+ title: extractTitle(body),
651
+ body: extractBody(body),
652
+ state: frontmatter.state,
653
+ created_at: frontmatter.created_at,
654
+ updated_at: frontmatter.updated_at,
655
+ url: frontmatter.url,
656
+ source: 'github',
657
+ });
658
+
659
+ // Add label edges
660
+ for (const label of frontmatter.labels || []) {
661
+ const labelId = `github:${frontmatter.owner}/${frontmatter.repo}:${label}`;
662
+ store.addNode({
663
+ _type: 'Label',
664
+ _id: labelId,
665
+ name: label,
666
+ color: '', // Would need to load from metadata
667
+ description: null,
668
+ });
669
+ store.addEdge({
670
+ _type: 'HAS_LABEL',
671
+ _from: `github:${frontmatter.url}`,
672
+ _to: labelId,
673
+ });
674
+ }
675
+
676
+ // Add assignee edges
677
+ for (const assignee of frontmatter.assignees || []) {
678
+ const userId = `github:${assignee}`;
679
+ store.addNode({
680
+ _type: 'User',
681
+ _id: userId,
682
+ login: assignee,
683
+ name: null,
684
+ avatar_url: '',
685
+ source: 'github',
686
+ });
687
+ store.addEdge({
688
+ _type: 'ASSIGNED_TO',
689
+ _from: `github:${frontmatter.url}`,
690
+ _to: userId,
691
+ });
692
+ }
693
+
694
+ // Parse body for issue references
695
+ const references = extractIssueReferences(body);
696
+ for (const ref of references) {
697
+ store.addEdge({
698
+ _type: 'REFERENCES',
699
+ _from: `github:${frontmatter.url}`,
700
+ _to: `github:${ref}`,
701
+ });
702
+ }
703
+ }
704
+
705
+ return store;
706
+ }
707
+
708
+ // Extract issue references from markdown body
709
+ function extractIssueReferences(body: string): string[] {
710
+ const patterns = [
711
+ /#(\d+)/g, // #123
712
+ /([a-z0-9-]+\/[a-z0-9-]+)#(\d+)/gi, // owner/repo#123
713
+ /https:\/\/github\.com\/([^\/]+)\/([^\/]+)\/issues\/(\d+)/g, // full URL
714
+ ];
715
+
716
+ const refs: string[] = [];
717
+ for (const pattern of patterns) {
718
+ let match;
719
+ while ((match = pattern.exec(body)) !== null) {
720
+ if (match[3]) {
721
+ // Full URL
722
+ refs.push(`${match[1]}/${match[2]}/issues/${match[3]}`);
723
+ } else if (match[2]) {
724
+ // owner/repo#123
725
+ refs.push(`${match[1]}/issues/${match[2]}`);
726
+ } else {
727
+ // #123 - same repo reference
728
+ refs.push(`issues/${match[1]}`);
729
+ }
730
+ }
731
+ }
732
+
733
+ return [...new Set(refs)];
734
+ }
735
+ ```
736
+
737
+ ---
738
+
739
+ ## CLI Query Interface
740
+
741
+ ```typescript
742
+ #!/usr/bin/env node
743
+ import { Command } from 'commander';
744
+
745
+ const program = new Command();
746
+
747
+ program
748
+ .name('hardcopy')
749
+ .description('Query synced project data');
750
+
751
+ program
752
+ .command('query')
753
+ .argument('<filter>', 'Filter expression')
754
+ .option('-f, --format <format>', 'Output format', 'table')
755
+ .option('-o, --output <file>', 'Output file')
756
+ .action(async (filter, options) => {
757
+ const store = await loadGraphStore();
758
+ const executor = new QueryExecutor(store);
759
+
760
+ const parsed = parseFilterString(filter);
761
+ const results = executor.findIssues(parsed);
762
+
763
+ switch (options.format) {
764
+ case 'table':
765
+ console.table(results.map(r => ({
766
+ '#': r.number,
767
+ title: r.title.slice(0, 50),
768
+ state: r.state,
769
+ })));
770
+ break;
771
+ case 'json':
772
+ console.log(JSON.stringify(results, null, 2));
773
+ break;
774
+ case 'csv':
775
+ // ... csv output
776
+ break;
777
+ }
778
+ });
779
+
780
+ program
781
+ .command('deps')
782
+ .argument('<issue>', 'Issue number or URL')
783
+ .option('-d, --depth <depth>', 'Max depth', '5')
784
+ .action(async (issue, options) => {
785
+ const store = await loadGraphStore();
786
+ const executor = new QueryExecutor(store);
787
+
788
+ const graph = executor.getDependencyGraph(
789
+ resolveIssueId(issue),
790
+ parseInt(options.depth)
791
+ );
792
+
793
+ // ASCII tree output
794
+ printDependencyTree(graph);
795
+ });
796
+
797
+ program.parse();
798
+ ```
799
+
800
+ ---
801
+
802
+ ## Integration with Views
803
+
804
+ ### Loading Views with Queries
805
+
806
+ ```typescript
807
+ interface ViewLoader {
808
+ loadView(viewConfig: ViewConfig): Promise<ViewData>;
809
+ }
810
+
811
+ class ViewLoaderImpl implements ViewLoader {
812
+ constructor(
813
+ private store: GraphStore,
814
+ private executor: QueryExecutor
815
+ ) {}
816
+
817
+ async loadView(config: ViewConfig): Promise<ViewData> {
818
+ // Execute the query
819
+ const issues = this.executor.findIssues(config.filters);
820
+
821
+ // Apply grouping
822
+ const grouped = this.groupBy(issues, config.group_by);
823
+
824
+ // Apply sorting
825
+ for (const column of Object.values(grouped)) {
826
+ this.sortBy(column, config.sort_by);
827
+ }
828
+
829
+ return {
830
+ name: config.name,
831
+ type: config.type,
832
+ columns: grouped,
833
+ total: issues.length,
834
+ };
835
+ }
836
+
837
+ private groupBy(
838
+ issues: IssueNode[],
839
+ groupConfig?: GroupConfig
840
+ ): Record<string, IssueNode[]> {
841
+ if (!groupConfig) {
842
+ return { default: issues };
843
+ }
844
+
845
+ const groups: Record<string, IssueNode[]> = {};
846
+
847
+ for (const column of groupConfig.columns) {
848
+ groups[column.value] = [];
849
+ }
850
+
851
+ for (const issue of issues) {
852
+ const fieldValue = this.getFieldValue(issue, groupConfig.field);
853
+ const group = groups[fieldValue] || groups['_other'];
854
+ if (group) {
855
+ group.push(issue);
856
+ }
857
+ }
858
+
859
+ return groups;
860
+ }
861
+
862
+ private getFieldValue(issue: IssueNode, field: string): any {
863
+ // Check if it's a custom field
864
+ const edges = this.store.getOutgoingEdges(issue._id, 'FIELD_VALUE');
865
+ for (const edge of edges) {
866
+ const fieldDef = this.store.getNode(edge._to) as FieldDefinitionNode;
867
+ if (fieldDef?.name === field) {
868
+ return edge.properties?.value;
869
+ }
870
+ }
871
+
872
+ // Check standard fields
873
+ return (issue as any)[field];
874
+ }
875
+ }
876
+ ```