@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,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
|
+
```
|