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.
- package/dist/{chunk-ROIINI33.js → chunk-4PIT2GZ4.js} +13 -1
- package/dist/{chunk-XLYRHKG6.js → chunk-54SD5GBF.js} +1 -1
- package/dist/chunk-63AMINQC.js +156 -0
- package/dist/{chunk-XAEGXSEO.js → chunk-74AHY7X6.js} +4 -0
- package/dist/{chunk-7U3SXINY.js → chunk-ATAUWJYD.js} +320 -50
- package/dist/chunk-DQW74UCN.js +671 -0
- package/dist/chunk-EYP6WMFF.js +153 -0
- package/dist/{chunk-JSTXMDXI.js → chunk-FCFZCZHR.js} +1 -1
- package/dist/chunk-FX6Z3QHV.js +34 -0
- package/dist/chunk-HENAABEL.js +419 -0
- package/dist/chunk-OXWWOKC7.js +201 -0
- package/dist/chunk-QGHJ45PL.js +661 -0
- package/dist/chunk-RO3KK5RC.js +132 -0
- package/dist/{chunk-JE6TOU7W.js → chunk-TFMF3EXE.js} +2 -7
- package/dist/{chunk-TN327MDF.js → chunk-VX662YLA.js} +3 -3
- package/dist/cli.js +338 -149
- package/dist/{config-AI6UIJJN.js → config-FH2XLN7A.js} +2 -2
- package/dist/content-reader-VPGTR2SF.js +10 -0
- package/dist/context-ZNI3WOB7.js +10 -0
- package/dist/{credential-store-ZRZCSRPC.js → credential-store-OS5ZY4OW.js} +2 -2
- package/dist/{feishu-A6YVFKEN.js → feishu-XW5T6ER2.js} +8 -3
- package/dist/{git-manager-N35XSG4Y.js → git-manager-RVWV2GSV.js} +2 -1
- package/dist/github-PQKAYTLO.js +11 -0
- package/dist/{paths-JXOMBYIT.js → paths-FFRET6F7.js} +7 -3
- package/dist/{server-5GVWN2NB.js → server-WEADPUST.js} +59 -66
- package/dist/{setup-IDQDPCEJ.js → setup-S2S2CHB2.js} +91 -32
- package/dist/sync-SRLFR5NA.js +21 -0
- package/dist/transport.js +6 -4
- package/package.json +1 -1
- package/src/dashboard/public/app.js +34 -8
- package/src/dashboard/public/style.css +14 -0
- package/dist/chunk-AIXKXEYS.js +0 -547
- package/dist/chunk-L5ZR7TSK.js +0 -82
- package/dist/chunk-LS2AJM5A.js +0 -163
- package/dist/chunk-QMOFQX7X.js +0 -612
- package/dist/chunk-YJWTKFWX.js +0 -451
- package/dist/github-SHWUFNYB.js +0 -10
- 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
|
+
};
|
|
@@ -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
|
+
};
|