jowork 0.2.4 → 0.3.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 (38) hide show
  1. package/dist/{chunk-ROIINI33.js → chunk-4PIT2GZ4.js} +13 -1
  2. package/dist/{chunk-XLYRHKG6.js → chunk-54SD5GBF.js} +1 -1
  3. package/dist/chunk-63AMINQC.js +156 -0
  4. package/dist/{chunk-XAEGXSEO.js → chunk-74AHY7X6.js} +4 -0
  5. package/dist/{chunk-7U3SXINY.js → chunk-ATAUWJYD.js} +320 -50
  6. package/dist/chunk-DQW74UCN.js +671 -0
  7. package/dist/chunk-EYP6WMFF.js +153 -0
  8. package/dist/{chunk-JSTXMDXI.js → chunk-FCFZCZHR.js} +1 -1
  9. package/dist/chunk-FX6Z3QHV.js +34 -0
  10. package/dist/chunk-HENAABEL.js +419 -0
  11. package/dist/chunk-OXWWOKC7.js +201 -0
  12. package/dist/chunk-QGHJ45PL.js +661 -0
  13. package/dist/chunk-RO3KK5RC.js +132 -0
  14. package/dist/{chunk-JE6TOU7W.js → chunk-TFMF3EXE.js} +2 -7
  15. package/dist/{chunk-TN327MDF.js → chunk-VX662YLA.js} +3 -3
  16. package/dist/cli.js +338 -149
  17. package/dist/{config-AI6UIJJN.js → config-FH2XLN7A.js} +2 -2
  18. package/dist/content-reader-VPGTR2SF.js +10 -0
  19. package/dist/context-ZNI3WOB7.js +10 -0
  20. package/dist/{credential-store-ZRZCSRPC.js → credential-store-OS5ZY4OW.js} +2 -2
  21. package/dist/{feishu-A6YVFKEN.js → feishu-XW5T6ER2.js} +8 -3
  22. package/dist/{git-manager-N35XSG4Y.js → git-manager-RVWV2GSV.js} +2 -1
  23. package/dist/github-PQKAYTLO.js +11 -0
  24. package/dist/{paths-JXOMBYIT.js → paths-FFRET6F7.js} +7 -3
  25. package/dist/{server-5GVWN2NB.js → server-WEADPUST.js} +59 -66
  26. package/dist/{setup-IDQDPCEJ.js → setup-S2S2CHB2.js} +91 -32
  27. package/dist/sync-SRLFR5NA.js +21 -0
  28. package/dist/transport.js +6 -4
  29. package/package.json +1 -1
  30. package/src/dashboard/public/app.js +34 -8
  31. package/src/dashboard/public/style.css +14 -0
  32. package/dist/chunk-AIXKXEYS.js +0 -547
  33. package/dist/chunk-L5ZR7TSK.js +0 -82
  34. package/dist/chunk-LS2AJM5A.js +0 -163
  35. package/dist/chunk-QMOFQX7X.js +0 -612
  36. package/dist/chunk-YJWTKFWX.js +0 -451
  37. package/dist/github-SHWUFNYB.js +0 -10
  38. package/dist/sync-7V54N62M.js +0 -18
@@ -0,0 +1,153 @@
1
+ import {
2
+ bareReposDir
3
+ } from "./chunk-4PIT2GZ4.js";
4
+ import {
5
+ logError,
6
+ logInfo
7
+ } from "./chunk-MYDK7MWB.js";
8
+
9
+ // src/sync/git-manager.ts
10
+ import simpleGit from "simple-git";
11
+ import { existsSync, writeFileSync, mkdirSync, readdirSync } from "fs";
12
+ import { join, basename } from "path";
13
+ var GitManager = class _GitManager {
14
+ git;
15
+ repoDir;
16
+ constructor(repoDir) {
17
+ this.repoDir = repoDir;
18
+ this.git = simpleGit(repoDir);
19
+ }
20
+ /** Initialize git repo if not already initialized */
21
+ async init() {
22
+ const gitDir = join(this.repoDir, ".git");
23
+ if (existsSync(gitDir)) return;
24
+ await this.git.init();
25
+ const gitignore = [
26
+ "# JoWork \u2014 auto-generated",
27
+ "*.db",
28
+ "*.db-wal",
29
+ "*.db-shm",
30
+ ".DS_Store",
31
+ "Thumbs.db",
32
+ "*.key",
33
+ "*.pem",
34
+ "*.env",
35
+ "credentials/",
36
+ ""
37
+ ].join("\n");
38
+ writeFileSync(join(this.repoDir, ".gitignore"), gitignore);
39
+ await this.git.add("-A");
40
+ await this.git.commit("init: jowork data repo");
41
+ logInfo("git", "Initialized data repo");
42
+ }
43
+ /** Commit all changes after a sync cycle */
44
+ async commitSync(summary) {
45
+ await this.git.add("-A");
46
+ const status = await this.git.status();
47
+ if (status.staged.length === 0 && status.created.length === 0 && status.modified.length === 0 && status.deleted.length === 0) {
48
+ return null;
49
+ }
50
+ const lines = [`sync: ${summary.timestamp}`, ""];
51
+ for (const s of summary.sources) {
52
+ if (s.newObjects > 0) {
53
+ lines.push(`${s.source}: +${s.newObjects} ${s.label ?? "objects"}`);
54
+ }
55
+ }
56
+ if (lines.length === 2) lines.push("(no new data)");
57
+ const result = await this.git.commit(lines.join("\n"));
58
+ const sha = result.commit;
59
+ logInfo("git", `Committed sync: ${sha}`, {
60
+ files: status.staged.length + status.created.length
61
+ });
62
+ return sha;
63
+ }
64
+ /** Get recent sync log entries */
65
+ async getLog(limit = 20) {
66
+ const log = await this.git.log({ maxCount: limit });
67
+ return log.all.map((entry) => ({
68
+ hash: entry.hash.slice(0, 7),
69
+ date: entry.date,
70
+ message: entry.message.split("\n")[0]
71
+ }));
72
+ }
73
+ /** Get current status (changed files) */
74
+ async getStatus() {
75
+ const status = await this.git.status();
76
+ return {
77
+ modified: status.modified,
78
+ created: status.created,
79
+ deleted: status.deleted
80
+ };
81
+ }
82
+ // ── Bare repo clone/fetch (GitHub/GitLab code repos) ───────────────
83
+ /**
84
+ * Clone a repo as bare (no working tree, minimal disk usage).
85
+ * Uses --filter=blob:limit=1m to skip large binary blobs.
86
+ * Returns the local bare repo path, or null on failure.
87
+ */
88
+ static async cloneBare(repoUrl, source, repoName, token) {
89
+ const dir = join(bareReposDir(), source, `${repoName}.git`);
90
+ if (existsSync(dir)) {
91
+ return _GitManager.fetchBare(dir);
92
+ }
93
+ mkdirSync(join(bareReposDir(), source), { recursive: true });
94
+ let cloneUrl = repoUrl;
95
+ if (token && repoUrl.startsWith("https://")) {
96
+ const url = new URL(repoUrl);
97
+ url.username = source === "gitlab" ? "oauth2" : "x-access-token";
98
+ url.password = token;
99
+ cloneUrl = url.toString();
100
+ }
101
+ try {
102
+ const git = simpleGit();
103
+ await git.clone(cloneUrl, dir, [
104
+ "--bare",
105
+ "--filter=blob:limit=1m",
106
+ "--single-branch"
107
+ ]);
108
+ logInfo("git", `Cloned bare: ${source}/${repoName}`);
109
+ return dir;
110
+ } catch (err) {
111
+ logError("git", `Failed to clone ${source}/${repoName}: ${err}`);
112
+ return null;
113
+ }
114
+ }
115
+ /** Fetch updates for an existing bare repo. Returns path or null on failure. */
116
+ static async fetchBare(bareDir) {
117
+ try {
118
+ const git = simpleGit(bareDir);
119
+ await git.fetch(["--prune"]);
120
+ return bareDir;
121
+ } catch (err) {
122
+ logError("git", `Failed to fetch ${bareDir}: ${err}`);
123
+ return null;
124
+ }
125
+ }
126
+ /**
127
+ * Clone or fetch multiple repos in parallel.
128
+ * Returns results: { repo, path, error? } for each.
129
+ */
130
+ static async syncBareRepos(repos) {
131
+ const results = await Promise.allSettled(
132
+ repos.map(async (repo) => {
133
+ const dir = join(bareReposDir(), repo.source, `${repo.name}.git`);
134
+ const isNew = !existsSync(dir);
135
+ const path = await _GitManager.cloneBare(repo.url, repo.source, repo.name, repo.token);
136
+ return { name: repo.name, path, isNew };
137
+ })
138
+ );
139
+ return results.map(
140
+ (r) => r.status === "fulfilled" ? r.value : { name: "unknown", path: null, isNew: false }
141
+ );
142
+ }
143
+ /** List all bare repos for a source. */
144
+ static listBareRepos(source) {
145
+ const dir = join(bareReposDir(), source);
146
+ if (!existsSync(dir)) return [];
147
+ return readdirSync(dir).filter((f) => f.endsWith(".git")).map((f) => basename(f, ".git"));
148
+ }
149
+ };
150
+
151
+ export {
152
+ GitManager
153
+ };
@@ -1,7 +1,7 @@
1
1
  import {
2
2
  configPath,
3
3
  joworkDir
4
- } from "./chunk-ROIINI33.js";
4
+ } from "./chunk-4PIT2GZ4.js";
5
5
 
6
6
  // src/utils/config.ts
7
7
  import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
@@ -0,0 +1,34 @@
1
+ import {
2
+ fileRepoDir
3
+ } from "./chunk-4PIT2GZ4.js";
4
+
5
+ // src/utils/content-reader.ts
6
+ import { readFileSync } from "fs";
7
+ import { join } from "path";
8
+ function readObjectContent(_sqlite, _objectId, filePath) {
9
+ if (!filePath) return null;
10
+ try {
11
+ return readFileSync(join(fileRepoDir(), filePath), "utf-8");
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+ function readObjectContents(_sqlite, objects) {
17
+ const result = /* @__PURE__ */ new Map();
18
+ const repoDir = fileRepoDir();
19
+ for (const obj of objects) {
20
+ if (obj.filePath) {
21
+ try {
22
+ const content = readFileSync(join(repoDir, obj.filePath), "utf-8");
23
+ result.set(obj.id, content);
24
+ } catch {
25
+ }
26
+ }
27
+ }
28
+ return result;
29
+ }
30
+
31
+ export {
32
+ readObjectContent,
33
+ readObjectContents
34
+ };
@@ -0,0 +1,419 @@
1
+ import {
2
+ readObjectContent
3
+ } from "./chunk-FX6Z3QHV.js";
4
+ import {
5
+ formatIssue,
6
+ formatPullRequest
7
+ } from "./chunk-RO3KK5RC.js";
8
+ import {
9
+ logError,
10
+ logInfo
11
+ } from "./chunk-MYDK7MWB.js";
12
+
13
+ // src/sync/linker.ts
14
+ var PATTERNS = [
15
+ // GitHub/GitLab PR/Issue references
16
+ { type: "pr", regex: /(?:PR|pr|Pull Request|pull request)\s*#?(\d+)/g, confidence: "high" },
17
+ { type: "issue", regex: /(?:issue|Issue|ISSUE)\s*#?(\d+)/g, confidence: "high" },
18
+ { type: "issue", regex: /#(\d{2,6})\b/g, confidence: "medium" },
19
+ // bare #123
20
+ // Linear-style issue keys (e.g. LIN-234, PROJ-56)
21
+ // Requires 2+ digit number to reduce false positives (GPT-5, GLP-1 are NOT issues)
22
+ { type: "issue", regex: /\b([A-Z]{2,10}-\d{2,6})\b/g, confidence: "high" },
23
+ // Git commit SHA
24
+ { type: "commit", regex: /\b([0-9a-f]{7,40})\b/g, confidence: "low" },
25
+ // URLs
26
+ { type: "url", regex: /https?:\/\/[^\s<>"{}|\\^`\[\]]+/g, confidence: "high" },
27
+ // @mentions (feishu user_id format)
28
+ { type: "mention", regex: /@([a-zA-Z0-9_]+)/g, confidence: "medium" },
29
+ // Action items (Chinese + English)
30
+ { type: "action_item", regex: /(?:需要|TODO|FIXME|待办|截止|deadline|action item|任务)[::\s]+([^\n。.]{5,80})/gi, confidence: "medium" }
31
+ ];
32
+ function extractLinks(content) {
33
+ const links = [];
34
+ const seen = /* @__PURE__ */ new Set();
35
+ for (const pattern of PATTERNS) {
36
+ if (pattern.type === "commit" && content.length < 100) continue;
37
+ const regex = new RegExp(pattern.regex.source, pattern.regex.flags);
38
+ let match;
39
+ while ((match = regex.exec(content)) !== null) {
40
+ const identifier = match[1] ?? match[0];
41
+ const key = `${pattern.type}:${identifier}`;
42
+ if (seen.has(key)) continue;
43
+ seen.add(key);
44
+ if (identifier.length < 3 && pattern.type !== "pr") continue;
45
+ if (pattern.type === "commit" && identifier.length < 7) continue;
46
+ let metadata;
47
+ if (pattern.type === "action_item") {
48
+ const surroundingText = content.slice(
49
+ Math.max(0, (match.index ?? 0) - 50),
50
+ Math.min(content.length, (match.index ?? 0) + match[0].length + 50)
51
+ );
52
+ const mentionMatch = surroundingText.match(/@([a-zA-Z0-9_]+)/);
53
+ const dateMatch = surroundingText.match(
54
+ /(\d{4}[-/]\d{1,2}[-/]\d{1,2}|\d{1,2}月\d{1,2}[日号]|\d{1,2}\/\d{1,2})/
55
+ );
56
+ if (mentionMatch || dateMatch) {
57
+ metadata = {};
58
+ if (mentionMatch) metadata.assignee = mentionMatch[1];
59
+ if (dateMatch) metadata.dueDate = dateMatch[1];
60
+ }
61
+ }
62
+ links.push({
63
+ linkType: pattern.type,
64
+ identifier,
65
+ confidence: pattern.confidence,
66
+ ...metadata ? { metadata } : {}
67
+ });
68
+ }
69
+ }
70
+ return links;
71
+ }
72
+ function processObjectLinks(sqlite, objectId, content) {
73
+ const links = extractLinks(content);
74
+ if (links.length === 0) return 0;
75
+ const insert = sqlite.prepare(`
76
+ INSERT OR IGNORE INTO object_links (id, source_object_id, target_object_id, link_type, identifier, metadata, confidence, created_at)
77
+ VALUES (?, ?, NULL, ?, ?, ?, ?, ?)
78
+ `);
79
+ const now = Date.now();
80
+ let count = 0;
81
+ const batch = sqlite.transaction(() => {
82
+ for (const link of links) {
83
+ const id = `${objectId}:${link.linkType}:${link.identifier}`.slice(0, 64);
84
+ insert.run(
85
+ id,
86
+ objectId,
87
+ link.linkType,
88
+ link.identifier,
89
+ link.metadata ? JSON.stringify(link.metadata) : null,
90
+ link.confidence,
91
+ now
92
+ );
93
+ count++;
94
+ }
95
+ });
96
+ batch();
97
+ return count;
98
+ }
99
+ function linkAllUnprocessed(sqlite) {
100
+ const unprocessed = sqlite.prepare(`
101
+ SELECT o.id, o.file_path FROM objects o
102
+ WHERE o.links_processed = 0
103
+ LIMIT 1000
104
+ `).all();
105
+ if (unprocessed.length === 0) return { processed: 0, linksCreated: 0 };
106
+ let linksCreated = 0;
107
+ const markProcessed = sqlite.prepare("UPDATE objects SET links_processed = 1 WHERE id = ?");
108
+ const batch = sqlite.transaction(() => {
109
+ for (const obj of unprocessed) {
110
+ const content = readObjectContent(sqlite, obj.id, obj.file_path);
111
+ if (!content) {
112
+ markProcessed.run(obj.id);
113
+ continue;
114
+ }
115
+ linksCreated += processObjectLinks(sqlite, obj.id, content);
116
+ markProcessed.run(obj.id);
117
+ }
118
+ });
119
+ batch();
120
+ logInfo("linker", `Processed ${unprocessed.length} objects, created ${linksCreated} links`);
121
+ return { processed: unprocessed.length, linksCreated };
122
+ }
123
+
124
+ // src/sync/gitlab.ts
125
+ var defaultLogger = {
126
+ info: (msg, ctx) => logInfo("sync", msg, ctx),
127
+ warn: (msg, ctx) => logError("sync", msg, ctx),
128
+ error: (msg, ctx) => logError("sync", msg, ctx)
129
+ };
130
+ var RATE_LIMIT_DELAY_MS = 200;
131
+ async function fetchAllPages(url, headers, logger) {
132
+ const results = [];
133
+ let nextUrl = url;
134
+ while (nextUrl) {
135
+ const res = await fetch(nextUrl, { headers });
136
+ if (!res.ok) {
137
+ if (res.status === 429) {
138
+ const retryAfter = res.headers.get("retry-after");
139
+ const waitMs = retryAfter ? parseInt(retryAfter) * 1e3 : 5e3;
140
+ logger.warn(`Rate limited, waiting ${Math.ceil(waitMs / 1e3)}s`);
141
+ await new Promise((r) => setTimeout(r, waitMs));
142
+ continue;
143
+ }
144
+ break;
145
+ }
146
+ const data = await res.json();
147
+ results.push(...data);
148
+ const nextPage = res.headers.get("x-next-page");
149
+ if (nextPage && nextPage !== "") {
150
+ const currentUrl = nextUrl;
151
+ const parsed = new URL(currentUrl);
152
+ parsed.searchParams.set("page", nextPage);
153
+ nextUrl = parsed.toString();
154
+ } else {
155
+ nextUrl = null;
156
+ }
157
+ if (nextUrl) {
158
+ await new Promise((r) => setTimeout(r, RATE_LIMIT_DELAY_MS));
159
+ }
160
+ }
161
+ return results;
162
+ }
163
+ async function syncGitLab(ctx, data, logger = defaultLogger) {
164
+ const token = data.token;
165
+ if (!token) throw new Error("Missing GitLab token");
166
+ const baseUrl = data.apiUrl || "https://gitlab.com";
167
+ const headers = {
168
+ "PRIVATE-TOKEN": token
169
+ };
170
+ let projects = 0;
171
+ let issues = 0;
172
+ let mrs = 0;
173
+ let newObjects = 0;
174
+ let updatedObjects = 0;
175
+ const projectList = await fetchAllPages(
176
+ `${baseUrl}/api/v4/projects?membership=true&per_page=100&order_by=last_activity_at`,
177
+ headers,
178
+ logger
179
+ );
180
+ projects = projectList.length;
181
+ logger.info(`Found ${projects} GitLab projects`);
182
+ const issuesSince = ctx.getUpdatedSince("gitlab:issues");
183
+ const mrsSince = ctx.getUpdatedSince("gitlab:mrs");
184
+ for (const project of projectList) {
185
+ const encodedPath = encodeURIComponent(project.path_with_namespace);
186
+ try {
187
+ let issueUrl = `${baseUrl}/api/v4/projects/${encodedPath}/issues?state=all&per_page=100&order_by=updated_at`;
188
+ if (issuesSince) {
189
+ issueUrl += `&updated_after=${issuesSince}`;
190
+ }
191
+ const issueList = await fetchAllPages(issueUrl, headers, logger);
192
+ const items = [];
193
+ for (const item of issueList) {
194
+ const uri = `gitlab://${project.path_with_namespace}/issue/${item.iid}`;
195
+ const title = `${project.path_with_namespace}#${item.iid}: ${item.title}`;
196
+ const body = formatGitLabIssueBody(item, project.path_with_namespace);
197
+ const fileContent = formatIssue({
198
+ source: "gitlab",
199
+ repo: project.path_with_namespace,
200
+ number: item.iid,
201
+ title: item.title,
202
+ state: item.state,
203
+ author: item.author?.username ?? "unknown",
204
+ labels: item.labels,
205
+ created: item.created_at,
206
+ uri,
207
+ body: item.description ?? ""
208
+ });
209
+ items.push({
210
+ source: "gitlab",
211
+ sourceType: "issue",
212
+ uri,
213
+ title,
214
+ summary: item.description ? item.description.length > 200 ? item.description.slice(0, 200) + "..." : item.description : item.title,
215
+ tags: ["gitlab", "issue", item.state, ...item.labels],
216
+ content: body,
217
+ contentType: "text/plain",
218
+ createdAt: new Date(item.created_at).getTime(),
219
+ fileContent,
220
+ fileMeta: { repo: project.path_with_namespace, number: item.iid }
221
+ });
222
+ }
223
+ const result = ctx.batchUpsert(items);
224
+ newObjects += result.inserted;
225
+ updatedObjects += result.updated;
226
+ issues += items.length;
227
+ } catch (err) {
228
+ logger.warn(`Error fetching issues for ${project.path_with_namespace}: ${err}`);
229
+ }
230
+ try {
231
+ let mrUrl = `${baseUrl}/api/v4/projects/${encodedPath}/merge_requests?state=all&per_page=100&order_by=updated_at`;
232
+ if (mrsSince) {
233
+ mrUrl += `&updated_after=${mrsSince}`;
234
+ }
235
+ const mrList = await fetchAllPages(mrUrl, headers, logger);
236
+ const items = [];
237
+ for (const item of mrList) {
238
+ const uri = `gitlab://${project.path_with_namespace}/merge_request/${item.iid}`;
239
+ const title = `${project.path_with_namespace}!${item.iid}: ${item.title}`;
240
+ const body = formatGitLabMRBody(item, project.path_with_namespace);
241
+ const fileContent = formatPullRequest({
242
+ source: "gitlab",
243
+ repo: project.path_with_namespace,
244
+ number: item.iid,
245
+ title: item.title,
246
+ state: item.state,
247
+ author: item.author?.username ?? "unknown",
248
+ labels: item.labels,
249
+ created: item.created_at,
250
+ uri,
251
+ body: item.description ?? "",
252
+ sourceBranch: item.source_branch,
253
+ targetBranch: item.target_branch
254
+ });
255
+ items.push({
256
+ source: "gitlab",
257
+ sourceType: "merge_request",
258
+ uri,
259
+ title,
260
+ summary: item.description ? item.description.length > 200 ? item.description.slice(0, 200) + "..." : item.description : item.title,
261
+ tags: ["gitlab", "merge_request", item.state, ...item.labels],
262
+ content: body,
263
+ contentType: "text/plain",
264
+ createdAt: new Date(item.created_at).getTime(),
265
+ fileContent,
266
+ fileMeta: { repo: project.path_with_namespace, number: item.iid }
267
+ });
268
+ }
269
+ const result = ctx.batchUpsert(items);
270
+ newObjects += result.inserted;
271
+ updatedObjects += result.updated;
272
+ mrs += items.length;
273
+ } catch (err) {
274
+ logger.warn(`Error fetching MRs for ${project.path_with_namespace}: ${err}`);
275
+ }
276
+ await new Promise((r) => setTimeout(r, RATE_LIMIT_DELAY_MS));
277
+ }
278
+ ctx.saveTimestampCursor("gitlab:issues");
279
+ ctx.saveTimestampCursor("gitlab:mrs");
280
+ logger.info("GitLab sync complete", { projects, issues, mrs, newObjects, updatedObjects });
281
+ return { projects, issues, mrs, newObjects, updatedObjects };
282
+ }
283
+ function formatGitLabIssueBody(item, project) {
284
+ return [
285
+ `${project}#${item.iid}: ${item.title}`,
286
+ `State: ${item.state} | Author: ${item.author?.username ?? "unknown"} | Created: ${item.created_at}`,
287
+ `Labels: ${item.labels.join(", ") || "none"}`,
288
+ "",
289
+ item.description ?? "(no description)"
290
+ ].join("\n");
291
+ }
292
+ function formatGitLabMRBody(item, project) {
293
+ return [
294
+ `${project}!${item.iid}: ${item.title}`,
295
+ `State: ${item.state} | Author: ${item.author?.username ?? "unknown"} | Created: ${item.created_at}`,
296
+ `Branch: ${item.source_branch} \u2192 ${item.target_branch}`,
297
+ `Labels: ${item.labels.join(", ") || "none"}`,
298
+ "",
299
+ item.description ?? "(no description)"
300
+ ].join("\n");
301
+ }
302
+
303
+ // src/sync/linear.ts
304
+ var defaultLogger2 = {
305
+ info: (msg, ctx) => logInfo("sync", msg, ctx),
306
+ warn: (msg, ctx) => logError("sync", msg, ctx),
307
+ error: (msg, ctx) => logError("sync", msg, ctx)
308
+ };
309
+ var LINEAR_API = "https://api.linear.app/graphql";
310
+ function issuesQuery(afterCursor, updatedSince) {
311
+ const afterClause = afterCursor ? `, after: "${afterCursor}"` : "";
312
+ const filterClause = updatedSince ? `, filter: { updatedAt: { gte: "${updatedSince}" } }` : "";
313
+ return `
314
+ query {
315
+ issues(first: 50${afterClause}, orderBy: updatedAt${filterClause}) {
316
+ pageInfo { hasNextPage endCursor }
317
+ nodes {
318
+ id
319
+ identifier
320
+ title
321
+ description
322
+ url
323
+ state { name }
324
+ assignee { name }
325
+ labels { nodes { name } }
326
+ createdAt
327
+ updatedAt
328
+ }
329
+ }
330
+ }
331
+ `;
332
+ }
333
+ async function syncLinear(ctx, data, logger = defaultLogger2) {
334
+ const apiKey = data.apiKey;
335
+ if (!apiKey) throw new Error("Missing Linear API key");
336
+ const headers = {
337
+ "Content-Type": "application/json",
338
+ Authorization: apiKey
339
+ };
340
+ let issues = 0;
341
+ let newObjects = 0;
342
+ let updatedObjects = 0;
343
+ const since = ctx.getUpdatedSince("linear:issues");
344
+ let hasNextPage = true;
345
+ let endCursor = null;
346
+ while (hasNextPage) {
347
+ const res = await fetch(LINEAR_API, {
348
+ method: "POST",
349
+ headers,
350
+ body: JSON.stringify({ query: issuesQuery(endCursor, since) })
351
+ });
352
+ if (!res.ok) {
353
+ if (res.status === 401) throw new Error("Linear API key expired or invalid");
354
+ throw new Error(`Linear API error: ${res.status}`);
355
+ }
356
+ const body = await res.json();
357
+ if (body.errors?.length) {
358
+ throw new Error(`Linear GraphQL error: ${body.errors[0].message}`);
359
+ }
360
+ const issueList = body.data?.issues?.nodes ?? [];
361
+ issues += issueList.length;
362
+ const items = [];
363
+ for (const item of issueList) {
364
+ const uri = `linear://${item.identifier}`;
365
+ const title = `${item.identifier}: ${item.title}`;
366
+ const labelNames = item.labels.nodes.map((l) => l.name);
367
+ const bodyText = formatLinearIssueBody(item);
368
+ const fileContent = formatIssue({
369
+ source: "linear",
370
+ repo: item.identifier.split("-")[0] ?? "linear",
371
+ number: parseInt(item.identifier.split("-")[1] ?? "0"),
372
+ title: item.title,
373
+ state: item.state.name,
374
+ author: item.assignee?.name ?? "unassigned",
375
+ labels: labelNames,
376
+ created: item.createdAt,
377
+ uri,
378
+ body: item.description ?? ""
379
+ });
380
+ items.push({
381
+ source: "linear",
382
+ sourceType: "issue",
383
+ uri,
384
+ title,
385
+ summary: item.description ? item.description.length > 200 ? item.description.slice(0, 200) + "..." : item.description : item.title,
386
+ tags: ["linear", "issue", item.state.name, ...labelNames],
387
+ content: bodyText,
388
+ contentType: "text/plain",
389
+ createdAt: new Date(item.createdAt).getTime(),
390
+ fileContent,
391
+ fileMeta: { identifier: item.identifier }
392
+ });
393
+ }
394
+ const result = ctx.batchUpsert(items);
395
+ newObjects += result.inserted;
396
+ updatedObjects += result.updated;
397
+ hasNextPage = body.data?.issues?.pageInfo?.hasNextPage ?? false;
398
+ endCursor = body.data?.issues?.pageInfo?.endCursor ?? null;
399
+ }
400
+ logger.info(`Found ${issues} Linear issues`);
401
+ ctx.saveTimestampCursor("linear:issues");
402
+ logger.info("Linear sync complete", { issues, newObjects, updatedObjects });
403
+ return { issues, newObjects, updatedObjects };
404
+ }
405
+ function formatLinearIssueBody(item) {
406
+ return [
407
+ `${item.identifier}: ${item.title}`,
408
+ `State: ${item.state.name} | Assignee: ${item.assignee?.name ?? "unassigned"} | Created: ${item.createdAt}`,
409
+ `Labels: ${item.labels.nodes.map((l) => l.name).join(", ") || "none"}`,
410
+ "",
411
+ item.description ?? "(no description)"
412
+ ].join("\n");
413
+ }
414
+
415
+ export {
416
+ linkAllUnprocessed,
417
+ syncGitLab,
418
+ syncLinear
419
+ };