@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,236 @@
1
+ import type { Provider, Tool } from "../../provider";
2
+ import type {
3
+ Node,
4
+ Edge,
5
+ Change,
6
+ FetchRequest,
7
+ FetchResult,
8
+ PushResult,
9
+ } from "../../types";
10
+ import { registerProvider } from "../../provider";
11
+
12
+ export interface GitHubConfig {
13
+ orgs?: string[];
14
+ repos?: string[];
15
+ token?: string;
16
+ }
17
+
18
+ export function createGitHubProvider(config: GitHubConfig): Provider {
19
+ const token = config.token ?? process.env["GITHUB_TOKEN"];
20
+
21
+ async function fetchWithAuth(
22
+ url: string,
23
+ options: RequestInit = {},
24
+ ): Promise<Response> {
25
+ const headers = new Headers(options.headers);
26
+ if (token) {
27
+ headers.set("Authorization", `Bearer ${token}`);
28
+ }
29
+ headers.set("Accept", "application/vnd.github.v3+json");
30
+ return fetch(url, { ...options, headers });
31
+ }
32
+
33
+ function issueToNode(owner: string, name: string, issue: GitHubIssue): Node {
34
+ const nodeId = `github:${owner}/${name}#${issue.number}`;
35
+ return {
36
+ id: nodeId,
37
+ type: "github.Issue",
38
+ attrs: {
39
+ number: issue.number,
40
+ title: issue.title,
41
+ body: issue.body,
42
+ state: issue.state,
43
+ labels: issue.labels.map((l) => l.name),
44
+ assignee: issue.assignee?.login,
45
+ milestone: issue.milestone?.title,
46
+ created_at: issue.created_at,
47
+ updated_at: issue.updated_at,
48
+ url: issue.html_url,
49
+ repository: `${owner}/${name}`,
50
+ },
51
+ };
52
+ }
53
+
54
+ return {
55
+ name: "github",
56
+ nodeTypes: [
57
+ "github.Issue",
58
+ "github.Label",
59
+ "github.User",
60
+ "github.Milestone",
61
+ "github.Repository",
62
+ ],
63
+ edgeTypes: [
64
+ "github.ASSIGNED_TO",
65
+ "github.HAS_LABEL",
66
+ "github.REFERENCES",
67
+ "github.BELONGS_TO",
68
+ ],
69
+
70
+ async fetch(request: FetchRequest): Promise<FetchResult> {
71
+ const nodes: Node[] = [];
72
+ const edges: Edge[] = [];
73
+ let cursor = request.cursor;
74
+ let hasMore = false;
75
+
76
+ const repos = config.repos ?? [];
77
+
78
+ if (config.orgs) {
79
+ for (const org of config.orgs) {
80
+ const response = await fetchWithAuth(
81
+ `https://api.github.com/orgs/${org}/repos`,
82
+ );
83
+ if (response.ok) {
84
+ const data = (await response.json()) as { full_name: string }[];
85
+ repos.push(...data.map((r) => r.full_name));
86
+ }
87
+ }
88
+ }
89
+
90
+ for (const repo of repos) {
91
+ const [owner, name] = repo.split("/");
92
+ if (!owner || !name) continue;
93
+ const issuesUrl = `https://api.github.com/repos/${owner}/${name}/issues?state=all&per_page=100${
94
+ cursor ? `&page=${cursor}` : ""
95
+ }`;
96
+
97
+ const response = await fetchWithAuth(issuesUrl);
98
+ if (!response.ok) continue;
99
+
100
+ const etag = response.headers.get("ETag");
101
+ const data = (await response.json()) as GitHubIssue[];
102
+
103
+ for (const issue of data) {
104
+ if (issue.pull_request) continue;
105
+
106
+ const node = issueToNode(owner, name, issue);
107
+ nodes.push(node);
108
+
109
+ if (issue.assignee) {
110
+ edges.push({
111
+ type: "github.ASSIGNED_TO",
112
+ fromId: node.id,
113
+ toId: `github:user:${issue.assignee.login}`,
114
+ });
115
+ }
116
+
117
+ for (const label of issue.labels) {
118
+ edges.push({
119
+ type: "github.HAS_LABEL",
120
+ fromId: node.id,
121
+ toId: `github:label:${owner}/${name}:${label.name}`,
122
+ });
123
+ }
124
+ }
125
+
126
+ const linkHeader = response.headers.get("Link");
127
+ if (linkHeader?.includes('rel="next"')) {
128
+ hasMore = true;
129
+ const match = linkHeader.match(/page=(\d+)>; rel="next"/);
130
+ if (match) cursor = match[1];
131
+ }
132
+ }
133
+
134
+ return { nodes, edges, cursor, hasMore, versionToken: null };
135
+ },
136
+
137
+ async push(node: Node, changes: Change[]): Promise<PushResult> {
138
+ if (node.type !== "github.Issue") {
139
+ return { success: false, error: "Only issues are pushable" };
140
+ }
141
+
142
+ const match = node.id.match(/^github:([^#]+)#(\d+)$/);
143
+ if (!match) {
144
+ return { success: false, error: "Invalid node ID format" };
145
+ }
146
+
147
+ const [, repo, number] = match;
148
+ const body: Record<string, unknown> = {};
149
+
150
+ for (const change of changes) {
151
+ if (change.field === "title") body.title = change.newValue;
152
+ if (change.field === "body") body.body = change.newValue;
153
+ if (change.field === "state") body.state = change.newValue;
154
+ if (change.field === "labels") body.labels = change.newValue;
155
+ if (change.field === "assignee") body.assignees = [change.newValue];
156
+ if (change.field === "milestone") body.milestone = change.newValue;
157
+ }
158
+
159
+ const response = await fetchWithAuth(
160
+ `https://api.github.com/repos/${repo}/issues/${number}`,
161
+ {
162
+ method: "PATCH",
163
+ body: JSON.stringify(body),
164
+ headers: { "Content-Type": "application/json" },
165
+ },
166
+ );
167
+
168
+ if (!response.ok) {
169
+ const error = await response.text();
170
+ return { success: false, error };
171
+ }
172
+
173
+ return { success: true };
174
+ },
175
+
176
+ async fetchNode(nodeId: string): Promise<Node | null> {
177
+ const match = nodeId.match(/^github:([^/]+)\/([^#]+)#(\d+)$/);
178
+ if (!match) return null;
179
+
180
+ const [, owner, repo, number] = match;
181
+ if (!owner || !repo || !number) return null;
182
+ const response = await fetchWithAuth(
183
+ `https://api.github.com/repos/${owner}/${repo}/issues/${number}`,
184
+ );
185
+
186
+ if (response.status === 404) return null;
187
+ if (!response.ok) {
188
+ const error = await response.text();
189
+ throw new Error(error);
190
+ }
191
+
192
+ const issue = (await response.json()) as GitHubIssue;
193
+ if (issue.pull_request) return null;
194
+ return issueToNode(owner, repo, issue);
195
+ },
196
+
197
+ getTools(): Tool[] {
198
+ return [
199
+ {
200
+ name: "github.updateIssue",
201
+ description: "Update issue title, body, state",
202
+ },
203
+ { name: "github.addLabels", description: "Add labels to an issue" },
204
+ {
205
+ name: "github.removeLabels",
206
+ description: "Remove labels from an issue",
207
+ },
208
+ { name: "github.assignIssue", description: "Assign users to an issue" },
209
+ {
210
+ name: "github.createComment",
211
+ description: "Create a comment on an issue",
212
+ },
213
+ ];
214
+ },
215
+ };
216
+ }
217
+
218
+ interface GitHubIssue {
219
+ number: number;
220
+ title: string;
221
+ body: string | null;
222
+ state: "open" | "closed";
223
+ labels: { name: string }[];
224
+ assignee: { login: string } | null;
225
+ milestone: { title: string } | null;
226
+ created_at: string;
227
+ updated_at: string;
228
+ html_url: string;
229
+ pull_request?: unknown;
230
+ }
231
+
232
+ registerProvider("github", (config) =>
233
+ createGitHubProvider(config as GitHubConfig),
234
+ );
235
+
236
+ export { createGitHubProvider as default };
@@ -0,0 +1,66 @@
1
+ import type { Node } from "../../types";
2
+
3
+ export function parseIssueId(
4
+ nodeId: string,
5
+ ): { owner: string; repo: string; number: number } | null {
6
+ const match = nodeId.match(/^github:([^/]+)\/([^#]+)#(\d+)$/);
7
+ if (!match) return null;
8
+ const [, owner, repo, num] = match;
9
+ return { owner: owner!, repo: repo!, number: parseInt(num!, 10) };
10
+ }
11
+
12
+ export function createIssueId(
13
+ owner: string,
14
+ repo: string,
15
+ number: number,
16
+ ): string {
17
+ return `github:${owner}/${repo}#${number}`;
18
+ }
19
+
20
+ export function issueToNode(
21
+ owner: string,
22
+ repo: string,
23
+ issue: GitHubIssueData,
24
+ ): Node {
25
+ return {
26
+ id: createIssueId(owner, repo, issue.number),
27
+ type: "github.Issue",
28
+ attrs: {
29
+ number: issue.number,
30
+ title: issue.title,
31
+ body: issue.body,
32
+ state: issue.state,
33
+ labels:
34
+ issue.labels?.map((l) => (typeof l === "string" ? l : l.name)) ?? [],
35
+ assignee: issue.assignee?.login,
36
+ assignees: issue.assignees?.map((a) => a.login) ?? [],
37
+ milestone: issue.milestone?.title,
38
+ created_at: issue.created_at,
39
+ updated_at: issue.updated_at,
40
+ closed_at: issue.closed_at,
41
+ url: issue.html_url,
42
+ repository: `${owner}/${repo}`,
43
+ author: issue.user?.login,
44
+ comments: issue.comments,
45
+ locked: issue.locked,
46
+ },
47
+ };
48
+ }
49
+
50
+ export interface GitHubIssueData {
51
+ number: number;
52
+ title: string;
53
+ body: string | null;
54
+ state: "open" | "closed";
55
+ labels?: (string | { name: string })[];
56
+ assignee?: { login: string } | null;
57
+ assignees?: { login: string }[];
58
+ milestone?: { title: string } | null;
59
+ created_at: string;
60
+ updated_at: string;
61
+ closed_at?: string | null;
62
+ html_url: string;
63
+ user?: { login: string };
64
+ comments?: number;
65
+ locked?: boolean;
66
+ }
@@ -0,0 +1,7 @@
1
+ import "./providers/github/index";
2
+ import "./providers/a2a/index";
3
+ import "./providers/git/index";
4
+
5
+ export { createGitHubProvider } from "./providers/github/index";
6
+ export { createA2AProvider } from "./providers/a2a/index";
7
+ export { createGitProvider } from "./providers/git/index";
package/src/types.ts ADDED
@@ -0,0 +1,101 @@
1
+ export interface Node {
2
+ id: string;
3
+ type: string;
4
+ attrs: Record<string, unknown>;
5
+ syncedAt?: number;
6
+ versionToken?: string;
7
+ cursor?: string;
8
+ }
9
+
10
+ export interface Edge {
11
+ id?: number;
12
+ type: string;
13
+ fromId: string;
14
+ toId: string;
15
+ attrs?: Record<string, unknown>;
16
+ }
17
+
18
+ export interface FetchRequest {
19
+ query: NodeQuery;
20
+ cursor?: string;
21
+ pageSize?: number;
22
+ versionToken?: string;
23
+ }
24
+
25
+ export interface FetchResult {
26
+ nodes: Node[];
27
+ edges: Edge[];
28
+ cursor?: string;
29
+ hasMore: boolean;
30
+ versionToken?: string | null;
31
+ cached?: boolean;
32
+ }
33
+
34
+ export interface PushResult {
35
+ success: boolean;
36
+ error?: string;
37
+ versionToken?: string;
38
+ }
39
+
40
+ export interface NodeQuery {
41
+ id?: string;
42
+ type?: string;
43
+ attrs?: Record<string, unknown>;
44
+ }
45
+
46
+ export interface Change {
47
+ field: string;
48
+ oldValue: unknown;
49
+ newValue: unknown;
50
+ }
51
+
52
+ export interface SyncDecision {
53
+ strategy: "auto" | "llm" | "manual";
54
+ reason: string;
55
+ }
56
+
57
+ export interface SyncError {
58
+ resourceId: string;
59
+ strategy: "auto" | "llm";
60
+ error: string;
61
+ llmExplanation?: string;
62
+ suggestedActions?: string[];
63
+ }
64
+
65
+ export interface IndexState {
66
+ cursor?: string;
67
+ total?: number;
68
+ loaded: number;
69
+ pageSize: number;
70
+ lastFetch: string;
71
+ ttl: number;
72
+ }
73
+
74
+ export enum ConflictStatus {
75
+ CLEAN = "clean",
76
+ REMOTE_ONLY = "remote",
77
+ DIVERGED = "diverged",
78
+ }
79
+
80
+ export interface ThreeWayState {
81
+ base: unknown;
82
+ local: unknown;
83
+ remote: unknown;
84
+ }
85
+
86
+ export interface FieldConflict {
87
+ field: string;
88
+ status: ConflictStatus;
89
+ base: unknown;
90
+ local: unknown;
91
+ remote: unknown;
92
+ canAutoMerge: boolean;
93
+ }
94
+
95
+ export interface ConflictInfo {
96
+ nodeId: string;
97
+ nodeType: string;
98
+ filePath: string;
99
+ detectedAt: number;
100
+ fields: FieldConflict[];
101
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "esModuleInterop": true,
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "declaration": true,
10
+ "resolveJsonModule": true,
11
+ "allowSyntheticDefaultImports": true,
12
+ "forceConsistentCasingInFileNames": true,
13
+ "noUncheckedIndexedAccess": true,
14
+ "noImplicitOverride": true,
15
+ "noPropertyAccessFromIndexSignature": false,
16
+ "outDir": "./dist",
17
+ "rootDir": "./src"
18
+ },
19
+ "include": ["src/**/*"],
20
+ "exclude": ["node_modules", "dist"]
21
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from "tsup";
2
+
3
+ export default defineConfig({
4
+ entry: ["src/index.ts", "src/cli.ts", "src/mcp-server.ts"],
5
+ format: ["esm"],
6
+ dts: true,
7
+ splitting: false,
8
+ clean: true,
9
+ shims: true,
10
+ });