ework-web 0.10.71 → 0.10.72
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/package.json +1 -1
- package/src/db.ts +43 -15
- package/src/index.ts +34 -2
- package/src/schema-mysql.sql +23 -0
- package/src/schema.sql +26 -1
- package/src/store.ts +107 -4
- package/src/upstream-sync.ts +270 -0
- package/src/views/projectUpstreams.ts +98 -0
package/package.json
CHANGED
package/src/db.ts
CHANGED
|
@@ -134,6 +134,17 @@ function migrateIssuesTable(db: Database): void {
|
|
|
134
134
|
if (!have.has("model")) {
|
|
135
135
|
db.exec(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN model TEXT NOT NULL DEFAULT ''"));
|
|
136
136
|
}
|
|
137
|
+
if (!have.has("upstream_issue_number")) {
|
|
138
|
+
db.exec(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN upstream_issue_number INTEGER"));
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function migrateCommentsTable(db: Database): void {
|
|
143
|
+
const have = tableColumns(db, "comments");
|
|
144
|
+
if (have.size === 0) return;
|
|
145
|
+
if (!have.has("upstream_comment_id")) {
|
|
146
|
+
db.exec(applyPrefix("ALTER TABLE {{comments}} ADD COLUMN upstream_comment_id INTEGER"));
|
|
147
|
+
}
|
|
137
148
|
}
|
|
138
149
|
|
|
139
150
|
function migrateLabelsTable(db: Database): void {
|
|
@@ -242,6 +253,7 @@ class SqliteDriver implements AsyncDatabase {
|
|
|
242
253
|
migratePatTable(db);
|
|
243
254
|
migrateProjectsTable(db);
|
|
244
255
|
migrateIssuesTable(db);
|
|
256
|
+
migrateCommentsTable(db);
|
|
245
257
|
migrateLabelsTable(db);
|
|
246
258
|
migrateAddSurrogateId(db);
|
|
247
259
|
db.exec(applyPrefix(readFileSync(join(import.meta.dir, "schema.sql"), "utf8")));
|
|
@@ -360,25 +372,41 @@ async function migrateMysqlProjectsVisibility(pool: Pool): Promise<void> {
|
|
|
360
372
|
}
|
|
361
373
|
}
|
|
362
374
|
|
|
363
|
-
async function
|
|
364
|
-
const [cols] = await pool.query(
|
|
365
|
-
applyPrefix("SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{{issues}}' AND COLUMN_NAME = 'ai_status'")
|
|
366
|
-
);
|
|
367
|
-
if (Array.isArray(cols) && cols.length > 0) return;
|
|
375
|
+
async function migrateMysqlColumn(pool: Pool, table: string, column: string, ddl: string): Promise<void> {
|
|
368
376
|
try {
|
|
369
|
-
await pool.query(
|
|
377
|
+
const [cols] = await pool.query(
|
|
378
|
+
`SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COLUMN_NAME = ?`,
|
|
379
|
+
[DB_PREFIX + table, column]
|
|
380
|
+
);
|
|
381
|
+
if (Array.isArray(cols) && cols.length > 0) return;
|
|
382
|
+
await pool.query(applyPrefix(`ALTER TABLE {{${table}}} ADD COLUMN ${ddl}`));
|
|
370
383
|
} catch (e) {
|
|
371
|
-
console.warn(
|
|
384
|
+
console.warn(`[db] MySQL ${table}.${column} column add failed:`, (e as Error).message);
|
|
372
385
|
}
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
async function migrateMysqlIssuesAiStatus(pool: Pool): Promise<void> {
|
|
389
|
+
await migrateMysqlColumn(pool, "issues", "ai_status", "ai_status VARCHAR(32) NOT NULL DEFAULT ''");
|
|
390
|
+
await migrateMysqlColumn(pool, "issues", "model", "model VARCHAR(128) NOT NULL DEFAULT ''");
|
|
391
|
+
await migrateMysqlColumn(pool, "issues", "upstream_issue_number", "upstream_issue_number INT DEFAULT NULL");
|
|
392
|
+
await migrateMysqlColumn(pool, "comments", "upstream_comment_id", "upstream_comment_id BIGINT DEFAULT NULL");
|
|
393
|
+
const indexes: Array<[string, string]> = [
|
|
394
|
+
["uq_issues_project_upstream", applyPrefix("CREATE UNIQUE INDEX uq_issues_project_upstream ON {{issues}} (project_id, upstream_issue_number)")],
|
|
395
|
+
["uq_comments_upstream", applyPrefix("CREATE UNIQUE INDEX uq_comments_upstream ON {{comments}} (upstream_comment_id)")],
|
|
396
|
+
];
|
|
397
|
+
for (const [name, sql] of indexes) {
|
|
398
|
+
try {
|
|
399
|
+
const [rows] = await pool.query(
|
|
400
|
+
`SELECT INDEX_NAME FROM INFORMATION_SCHEMA.STATISTICS WHERE TABLE_SCHEMA = DATABASE() AND INDEX_NAME = ? LIMIT 1`,
|
|
401
|
+
[name]
|
|
402
|
+
);
|
|
403
|
+
if (Array.isArray(rows) && rows.length > 0) continue;
|
|
404
|
+
await pool.query(sql);
|
|
405
|
+
} catch (e) {
|
|
406
|
+
const errno = (e as { errno?: number }).errno;
|
|
407
|
+
if (errno === 1061 || errno === 30000) continue;
|
|
408
|
+
console.warn(`[db] MySQL index ${name} create failed:`, (e as Error).message);
|
|
379
409
|
}
|
|
380
|
-
} catch (e) {
|
|
381
|
-
console.warn("[db] MySQL issues model column add failed:", (e as Error).message);
|
|
382
410
|
}
|
|
383
411
|
}
|
|
384
412
|
|
package/src/index.ts
CHANGED
|
@@ -75,9 +75,11 @@ import {
|
|
|
75
75
|
deleteLabel,
|
|
76
76
|
setIssueLabels,
|
|
77
77
|
listAllProjectIds,
|
|
78
|
+
getUpstreamSync,
|
|
78
79
|
type ProjectRole,
|
|
79
80
|
type UserRow,
|
|
80
81
|
} from "./store";
|
|
82
|
+
import { startUpstreamSyncPoller } from "./upstream-sync";
|
|
81
83
|
import {
|
|
82
84
|
newAttachmentUUID,
|
|
83
85
|
saveAttachmentBlob,
|
|
@@ -106,7 +108,12 @@ import { buildWebhooksPage } from "./views/webhooks";
|
|
|
106
108
|
import { buildWebhookDeliveriesPage } from "./views/webhookDeliveries";
|
|
107
109
|
import { browseRemoteFile, proxyFileSince, RemoteFileError } from "./remote-file";
|
|
108
110
|
import { buildProjectMembersPage } from "./views/projectMembers";
|
|
109
|
-
import {
|
|
111
|
+
import {
|
|
112
|
+
buildProjectUpstreamsPage,
|
|
113
|
+
parseUpstreamSyncForm,
|
|
114
|
+
trySetUpstreamSync,
|
|
115
|
+
trySetUpstreamUrls,
|
|
116
|
+
} from "./views/projectUpstreams";
|
|
110
117
|
import { buildProjectLabelsPage } from "./views/projectLabels";
|
|
111
118
|
import { buildProjectModelPage } from "./views/projectModel";
|
|
112
119
|
import { buildProjectAiPage } from "./views/projectAi";
|
|
@@ -137,6 +144,8 @@ async function refreshOpencodeClient(): Promise<void> {
|
|
|
137
144
|
await refreshOpencodeClient();
|
|
138
145
|
setInterval(refreshOpencodeClient, 10_000);
|
|
139
146
|
|
|
147
|
+
startUpstreamSyncPoller(cfg);
|
|
148
|
+
|
|
140
149
|
async function autoWireDaemon(projectId: number, origin: string): Promise<void> {
|
|
141
150
|
if (!cfg.autowireActive) {
|
|
142
151
|
log.info("autoWireDaemon: skipped (WORK_AUTOWIRE_ACTIVE=false)", { projectId });
|
|
@@ -380,6 +389,7 @@ const REPO_DISPATCH_RE = /^\/([^/]+)\/([^/]+)\/settings\/dispatch$/;
|
|
|
380
389
|
const REPO_HALT_ALL_RE = /^\/([^/]+)\/([^/]+)\/settings\/ai\/halt-all$/;
|
|
381
390
|
const SETTINGS_DISPATCH_RE = /^\/settings\/dispatch$/;
|
|
382
391
|
const REPO_UPSTREAMS_RE = /^\/([^/]+)\/([^/]+)\/settings\/upstreams$/;
|
|
392
|
+
const REPO_UPSTREAM_SYNC_RE = /^\/([^/]+)\/([^/]+)\/settings\/upstream-sync$/;
|
|
383
393
|
const REPO_MODEL_RE = /^\/([^/]+)\/([^/]+)\/settings\/model$/;
|
|
384
394
|
const REPO_AI_RE = /^\/([^/]+)\/([^/]+)\/settings\/ai$/;
|
|
385
395
|
const REPO_LABELS_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels$/;
|
|
@@ -2105,6 +2115,28 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
2105
2115
|
return Response.redirect(`${back}?err=${encodeURIComponent(result.msg)}`, 303);
|
|
2106
2116
|
}
|
|
2107
2117
|
|
|
2118
|
+
const syncSave = url.pathname.match(REPO_UPSTREAM_SYNC_RE);
|
|
2119
|
+
if (syncSave) {
|
|
2120
|
+
const [, owner, repo] = syncSave;
|
|
2121
|
+
if (!(owner && repo)) return html(errorPage("bad path", ""), 400);
|
|
2122
|
+
const project = await getProject(owner, repo);
|
|
2123
|
+
if (!project) return html(errorPage("项目不存在", ""), 404);
|
|
2124
|
+
const back = `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/settings/upstreams`;
|
|
2125
|
+
if (!(await canAdminProject(project.id, ctx.user))) {
|
|
2126
|
+
return Response.redirect(`${back}?err=${encodeURIComponent("无权限")}`, 303);
|
|
2127
|
+
}
|
|
2128
|
+
const form = await req.formData().catch(() => new FormData());
|
|
2129
|
+
const input = parseUpstreamSyncForm(form);
|
|
2130
|
+
const result = await trySetUpstreamSync(project.id, input);
|
|
2131
|
+
if (result.ok) {
|
|
2132
|
+
return Response.redirect(
|
|
2133
|
+
`${back}?ok=1&ok_msg=${encodeURIComponent(input.enabled ? "同步配置已保存并启用" : "同步配置已保存(未启用)")}`,
|
|
2134
|
+
303,
|
|
2135
|
+
);
|
|
2136
|
+
}
|
|
2137
|
+
return Response.redirect(`${back}?err=${encodeURIComponent(result.msg)}`, 303);
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2108
2140
|
const modelSave = url.pathname.match(REPO_MODEL_RE);
|
|
2109
2141
|
if (modelSave) {
|
|
2110
2142
|
const [, owner, repo] = modelSave;
|
|
@@ -2320,7 +2352,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
|
|
|
2320
2352
|
const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
|
|
2321
2353
|
const flashMsg = flashKind === "ok" ? (url.searchParams.get("ok_msg") ?? "") : (url.searchParams.get("err") ?? "");
|
|
2322
2354
|
const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg } : null;
|
|
2323
|
-
return html(buildProjectUpstreamsPage(ctx.user!, project, flash));
|
|
2355
|
+
return html(buildProjectUpstreamsPage(ctx.user!, project, flash, await getUpstreamSync(project.id)));
|
|
2324
2356
|
}
|
|
2325
2357
|
|
|
2326
2358
|
const labelsPage = url.pathname.match(REPO_LABELS_RE);
|
package/src/schema-mysql.sql
CHANGED
|
@@ -55,7 +55,9 @@ CREATE TABLE IF NOT EXISTS {{issues}} (
|
|
|
55
55
|
closed_at VARCHAR(40) DEFAULT NULL,
|
|
56
56
|
ai_status VARCHAR(32) NOT NULL DEFAULT '',
|
|
57
57
|
model VARCHAR(128) NOT NULL DEFAULT '',
|
|
58
|
+
upstream_issue_number INT DEFAULT NULL,
|
|
58
59
|
UNIQUE (project_id, number),
|
|
60
|
+
UNIQUE uq_issues_project_upstream (project_id, upstream_issue_number),
|
|
59
61
|
CONSTRAINT {{fk_issues_project}} FOREIGN KEY (project_id) REFERENCES {{projects}}(id) ON DELETE CASCADE,
|
|
60
62
|
CONSTRAINT {{fk_issues_author}} FOREIGN KEY (author) REFERENCES {{users}}(login)
|
|
61
63
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
@@ -72,12 +74,33 @@ CREATE TABLE IF NOT EXISTS {{comments}} (
|
|
|
72
74
|
body TEXT NOT NULL,
|
|
73
75
|
created_at VARCHAR(40) NOT NULL,
|
|
74
76
|
updated_at VARCHAR(40) NOT NULL DEFAULT '',
|
|
77
|
+
upstream_comment_id BIGINT DEFAULT NULL,
|
|
78
|
+
UNIQUE uq_comments_upstream (upstream_comment_id),
|
|
75
79
|
CONSTRAINT {{fk_comments_issue}} FOREIGN KEY (issue_id) REFERENCES {{issues}}(id) ON DELETE CASCADE,
|
|
76
80
|
CONSTRAINT {{fk_comments_author}} FOREIGN KEY (author) REFERENCES {{users}}(login)
|
|
77
81
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
78
82
|
CREATE INDEX comments_issue_created ON {{comments}} (issue_id, created_at);
|
|
79
83
|
CREATE INDEX comments_author ON {{comments}} (author);
|
|
80
84
|
|
|
85
|
+
CREATE TABLE IF NOT EXISTS {{upstream_sync}} (
|
|
86
|
+
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
87
|
+
project_id BIGINT NOT NULL,
|
|
88
|
+
base_url VARCHAR(512) NOT NULL,
|
|
89
|
+
upstream_owner VARCHAR(255) NOT NULL,
|
|
90
|
+
upstream_repo VARCHAR(255) NOT NULL,
|
|
91
|
+
token VARCHAR(512) NOT NULL DEFAULT '',
|
|
92
|
+
enabled TINYINT NOT NULL DEFAULT 0,
|
|
93
|
+
poll_interval_ms INT NOT NULL DEFAULT 60000,
|
|
94
|
+
issue_cursor VARCHAR(40) DEFAULT NULL,
|
|
95
|
+
comment_cursor VARCHAR(40) DEFAULT NULL,
|
|
96
|
+
last_poll_at VARCHAR(40) DEFAULT NULL,
|
|
97
|
+
last_error TEXT,
|
|
98
|
+
created_at VARCHAR(40) NOT NULL,
|
|
99
|
+
updated_at VARCHAR(40) NOT NULL,
|
|
100
|
+
UNIQUE (project_id),
|
|
101
|
+
CONSTRAINT {{fk_upsync_project}} FOREIGN KEY (project_id) REFERENCES {{projects}}(id) ON DELETE CASCADE
|
|
102
|
+
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
|
103
|
+
|
|
81
104
|
CREATE TABLE IF NOT EXISTS {{labels}} (
|
|
82
105
|
id BIGINT AUTO_INCREMENT PRIMARY KEY,
|
|
83
106
|
project_id BIGINT NOT NULL,
|
package/src/schema.sql
CHANGED
|
@@ -67,12 +67,16 @@ CREATE TABLE IF NOT EXISTS {{issues}} (
|
|
|
67
67
|
ai_status TEXT NOT NULL DEFAULT '',
|
|
68
68
|
-- Resolved "provider/model" for this issue. Empty = inherit project/global default.
|
|
69
69
|
model TEXT NOT NULL DEFAULT '',
|
|
70
|
+
-- Upstream Gitea issue number this row was imported from (NULL = native).
|
|
71
|
+
upstream_issue_number INTEGER,
|
|
70
72
|
UNIQUE (project_id, number)
|
|
71
73
|
);
|
|
72
74
|
CREATE INDEX IF NOT EXISTS issues_project_state_updated
|
|
73
75
|
ON {{issues}} (project_id, state, updated_at DESC);
|
|
74
76
|
CREATE INDEX IF NOT EXISTS issues_state_updated
|
|
75
77
|
ON {{issues}} (state, updated_at DESC);
|
|
78
|
+
CREATE UNIQUE INDEX IF NOT EXISTS issues_project_upstream
|
|
79
|
+
ON {{issues}} (project_id, upstream_issue_number) WHERE upstream_issue_number IS NOT NULL;
|
|
76
80
|
|
|
77
81
|
CREATE TABLE IF NOT EXISTS {{comments}} (
|
|
78
82
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
@@ -80,10 +84,31 @@ CREATE TABLE IF NOT EXISTS {{comments}} (
|
|
|
80
84
|
author TEXT NOT NULL REFERENCES {{users}}(login),
|
|
81
85
|
body TEXT NOT NULL,
|
|
82
86
|
created_at TEXT NOT NULL,
|
|
83
|
-
updated_at TEXT NOT NULL DEFAULT ''
|
|
87
|
+
updated_at TEXT NOT NULL DEFAULT '',
|
|
88
|
+
upstream_comment_id INTEGER
|
|
84
89
|
);
|
|
85
90
|
CREATE INDEX IF NOT EXISTS comments_issue_created
|
|
86
91
|
ON {{comments}} (issue_id, created_at);
|
|
92
|
+
CREATE UNIQUE INDEX IF NOT EXISTS comments_upstream
|
|
93
|
+
ON {{comments}} (upstream_comment_id) WHERE upstream_comment_id IS NOT NULL;
|
|
94
|
+
|
|
95
|
+
CREATE TABLE IF NOT EXISTS {{upstream_sync}} (
|
|
96
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
97
|
+
project_id INTEGER NOT NULL REFERENCES {{projects}}(id) ON DELETE CASCADE,
|
|
98
|
+
base_url TEXT NOT NULL,
|
|
99
|
+
upstream_owner TEXT NOT NULL,
|
|
100
|
+
upstream_repo TEXT NOT NULL,
|
|
101
|
+
token TEXT NOT NULL DEFAULT '',
|
|
102
|
+
enabled INTEGER NOT NULL DEFAULT 0,
|
|
103
|
+
poll_interval_ms INTEGER NOT NULL DEFAULT 60000,
|
|
104
|
+
issue_cursor TEXT,
|
|
105
|
+
comment_cursor TEXT,
|
|
106
|
+
last_poll_at TEXT,
|
|
107
|
+
last_error TEXT,
|
|
108
|
+
created_at TEXT NOT NULL,
|
|
109
|
+
updated_at TEXT NOT NULL,
|
|
110
|
+
UNIQUE (project_id)
|
|
111
|
+
);
|
|
87
112
|
|
|
88
113
|
CREATE TABLE IF NOT EXISTS {{labels}} (
|
|
89
114
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
package/src/store.ts
CHANGED
|
@@ -452,6 +452,7 @@ export interface CreateIssueOpts {
|
|
|
452
452
|
state?: "open" | "closed";
|
|
453
453
|
closedAt?: string | null;
|
|
454
454
|
model?: string;
|
|
455
|
+
upstreamIssueNumber?: number;
|
|
455
456
|
}
|
|
456
457
|
|
|
457
458
|
function isoOr(value: string | undefined, fallback: string): string {
|
|
@@ -482,8 +483,8 @@ export async function createIssue(
|
|
|
482
483
|
"SELECT COALESCE(MAX(number), 0) + 1 AS n FROM {{issues}} WHERE project_id = ?", [projectId]
|
|
483
484
|
))!;
|
|
484
485
|
const info = await getDB().run(
|
|
485
|
-
"INSERT INTO {{issues}} (project_id, number, title, body, state, author, created_at, updated_at, closed_at, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
486
|
-
[projectId, next.n, title, body, state, author, createdAt, updatedAt, closedAt, opts.model ?? ""]
|
|
486
|
+
"INSERT INTO {{issues}} (project_id, number, title, body, state, author, created_at, updated_at, closed_at, model, upstream_issue_number) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
487
|
+
[projectId, next.n, title, body, state, author, createdAt, updatedAt, closedAt, opts.model ?? "", opts.upstreamIssueNumber ?? null]
|
|
487
488
|
);
|
|
488
489
|
await getDB().run("UPDATE {{projects}} SET updated_at = ? WHERE id = ?", [updatedAt, projectId]);
|
|
489
490
|
return (await getIssueById(info.insertId))!;
|
|
@@ -523,6 +524,107 @@ export async function updateIssueModel(issueId: number, model: string): Promise<
|
|
|
523
524
|
await getDB().run("UPDATE {{issues}} SET model = ?, updated_at = ? WHERE id = ?", [clean, now(), issueId]);
|
|
524
525
|
}
|
|
525
526
|
|
|
527
|
+
export interface UpstreamSyncRow {
|
|
528
|
+
id: number;
|
|
529
|
+
project_id: number;
|
|
530
|
+
base_url: string;
|
|
531
|
+
upstream_owner: string;
|
|
532
|
+
upstream_repo: string;
|
|
533
|
+
token: string;
|
|
534
|
+
enabled: number;
|
|
535
|
+
poll_interval_ms: number;
|
|
536
|
+
issue_cursor: string | null;
|
|
537
|
+
comment_cursor: string | null;
|
|
538
|
+
last_poll_at: string | null;
|
|
539
|
+
last_error: string | null;
|
|
540
|
+
created_at: string;
|
|
541
|
+
updated_at: string;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
export interface UpsertUpstreamSyncOpts {
|
|
545
|
+
baseUrl: string;
|
|
546
|
+
upstreamOwner: string;
|
|
547
|
+
upstreamRepo: string;
|
|
548
|
+
token?: string;
|
|
549
|
+
enabled?: boolean;
|
|
550
|
+
pollIntervalMs?: number;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
export async function getUpstreamSync(projectId: number): Promise<UpstreamSyncRow | null> {
|
|
554
|
+
return await getDB().get<UpstreamSyncRow>("SELECT * FROM {{upstream_sync}} WHERE project_id = ?", [projectId]);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
export async function listEnabledUpstreamSyncs(): Promise<UpstreamSyncRow[]> {
|
|
558
|
+
return await getDB().all<UpstreamSyncRow>("SELECT * FROM {{upstream_sync}} WHERE enabled = 1");
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
export async function upsertUpstreamSync(projectId: number, opts: UpsertUpstreamSyncOpts): Promise<UpstreamSyncRow> {
|
|
562
|
+
const baseUrl = opts.baseUrl
|
|
563
|
+
.trim()
|
|
564
|
+
.replace(/\/+$/, "")
|
|
565
|
+
.replace(/\/api\/v1$/i, "");
|
|
566
|
+
if (!/^https?:\/\//.test(baseUrl)) throw new StoreError(400, "上游地址必须是 http(s) URL");
|
|
567
|
+
const owner = opts.upstreamOwner.trim();
|
|
568
|
+
const repo = opts.upstreamRepo.trim();
|
|
569
|
+
if (!owner || !repo) throw new StoreError(400, "上游 owner/repo 不能为空");
|
|
570
|
+
const interval = opts.pollIntervalMs ?? 60_000;
|
|
571
|
+
if (!Number.isFinite(interval) || interval < 10_000) throw new StoreError(400, "轮询间隔不能小于 10 秒");
|
|
572
|
+
const existing = await getUpstreamSync(projectId);
|
|
573
|
+
const token = opts.token !== undefined ? opts.token.trim() : existing?.token ?? "";
|
|
574
|
+
const enabled = opts.enabled ?? (existing?.enabled === 1);
|
|
575
|
+
const ts = now();
|
|
576
|
+
if (existing) {
|
|
577
|
+
await getDB().run(
|
|
578
|
+
"UPDATE {{upstream_sync}} SET base_url = ?, upstream_owner = ?, upstream_repo = ?, token = ?, enabled = ?, poll_interval_ms = ?, updated_at = ? WHERE project_id = ?",
|
|
579
|
+
[baseUrl, owner, repo, token, enabled ? 1 : 0, Math.floor(interval), ts, projectId]
|
|
580
|
+
);
|
|
581
|
+
} else {
|
|
582
|
+
await getDB().run(
|
|
583
|
+
"INSERT INTO {{upstream_sync}} (project_id, base_url, upstream_owner, upstream_repo, token, enabled, poll_interval_ms, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
|
584
|
+
[projectId, baseUrl, owner, repo, token, enabled ? 1 : 0, Math.floor(interval), ts, ts]
|
|
585
|
+
);
|
|
586
|
+
}
|
|
587
|
+
return (await getUpstreamSync(projectId))!;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
export async function updateUpstreamSyncState(
|
|
591
|
+
projectId: number,
|
|
592
|
+
patch: { issueCursor?: string | null; commentCursor?: string | null; lastError?: string | null }
|
|
593
|
+
): Promise<void> {
|
|
594
|
+
const sets: string[] = [];
|
|
595
|
+
const args: (string | number | null)[] = [];
|
|
596
|
+
if (patch.issueCursor !== undefined) {
|
|
597
|
+
sets.push("issue_cursor = ?");
|
|
598
|
+
args.push(patch.issueCursor);
|
|
599
|
+
}
|
|
600
|
+
if (patch.commentCursor !== undefined) {
|
|
601
|
+
sets.push("comment_cursor = ?");
|
|
602
|
+
args.push(patch.commentCursor);
|
|
603
|
+
}
|
|
604
|
+
if (patch.lastError !== undefined) {
|
|
605
|
+
sets.push("last_error = ?");
|
|
606
|
+
args.push(patch.lastError);
|
|
607
|
+
}
|
|
608
|
+
if (sets.length === 0) return;
|
|
609
|
+
sets.push("last_poll_at = ?", "updated_at = ?");
|
|
610
|
+
args.push(now(), now(), projectId);
|
|
611
|
+
await getDB().run(`UPDATE {{upstream_sync}} SET ${sets.join(", ")} WHERE project_id = ?`, args);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
export async function getIssueByUpstreamNumber(projectId: number, upstreamNumber: number): Promise<IssueRow | null> {
|
|
615
|
+
return await getDB().get<IssueRow>(
|
|
616
|
+
"SELECT * FROM {{issues}} WHERE project_id = ? AND upstream_issue_number = ?",
|
|
617
|
+
[projectId, upstreamNumber]
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
export async function getCommentByUpstreamId(upstreamCommentId: number): Promise<CommentRow | null> {
|
|
622
|
+
return await getDB().get<CommentRow>(
|
|
623
|
+
"SELECT * FROM {{comments}} WHERE upstream_comment_id = ?",
|
|
624
|
+
[upstreamCommentId]
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
526
628
|
export interface IssuePatch {
|
|
527
629
|
title?: string;
|
|
528
630
|
body?: string;
|
|
@@ -621,6 +723,7 @@ export async function listCommentsForIssue(issueId: number): Promise<CommentRow[
|
|
|
621
723
|
export interface CreateCommentOpts {
|
|
622
724
|
createdAt?: string;
|
|
623
725
|
updatedAt?: string;
|
|
726
|
+
upstreamCommentId?: number;
|
|
624
727
|
}
|
|
625
728
|
|
|
626
729
|
export async function postComment(
|
|
@@ -636,8 +739,8 @@ export async function postComment(
|
|
|
636
739
|
const updatedAt = opts.updatedAt ? isoOr(opts.updatedAt, createdAt) : createdAt;
|
|
637
740
|
return await getDB().transaction(async () => {
|
|
638
741
|
const info = await getDB().run(
|
|
639
|
-
"INSERT INTO {{comments}} (issue_id, author, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
|
|
640
|
-
[issueId, author, body, createdAt, updatedAt]
|
|
742
|
+
"INSERT INTO {{comments}} (issue_id, author, body, created_at, updated_at, upstream_comment_id) VALUES (?, ?, ?, ?, ?, ?)",
|
|
743
|
+
[issueId, author, body, createdAt, updatedAt, opts.upstreamCommentId ?? null]
|
|
641
744
|
);
|
|
642
745
|
await getDB().run("UPDATE {{issues}} SET updated_at = ? WHERE id = ?", [updatedAt, issueId]);
|
|
643
746
|
const row = await getDB().get<{ project_id: number }>("SELECT project_id FROM {{issues}} WHERE id = ?", [issueId]);
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type UpstreamSyncRow,
|
|
3
|
+
type ProjectRow,
|
|
4
|
+
type IssueRow,
|
|
5
|
+
getProjectById,
|
|
6
|
+
getIssueByUpstreamNumber,
|
|
7
|
+
getCommentByUpstreamId,
|
|
8
|
+
createIssue,
|
|
9
|
+
postComment,
|
|
10
|
+
editIssue,
|
|
11
|
+
updateUpstreamSyncState,
|
|
12
|
+
listEnabledUpstreamSyncs,
|
|
13
|
+
} from "./store";
|
|
14
|
+
import { emitIssueEvent, emitCommentEvent } from "./webhooks";
|
|
15
|
+
import { log } from "./logger";
|
|
16
|
+
import type { Config } from "./config";
|
|
17
|
+
|
|
18
|
+
interface GiteaIssue {
|
|
19
|
+
number: number;
|
|
20
|
+
title: string;
|
|
21
|
+
body: string | null;
|
|
22
|
+
state: string;
|
|
23
|
+
user: { login: string } | null;
|
|
24
|
+
created_at: string;
|
|
25
|
+
updated_at: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
interface GiteaComment {
|
|
29
|
+
id: number;
|
|
30
|
+
body: string | null;
|
|
31
|
+
user: { login: string } | null;
|
|
32
|
+
created_at: string;
|
|
33
|
+
updated_at: string;
|
|
34
|
+
issue_url?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface UpstreamSyncPollResult {
|
|
38
|
+
issuesImported: number;
|
|
39
|
+
issuesUpdated: number;
|
|
40
|
+
commentsImported: number;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const GITEA_COMMENT_ISSUE_RE = /\/issues\/(\d+)$/;
|
|
44
|
+
|
|
45
|
+
function upstreamIssueNumberFromComment(gc: GiteaComment): number | null {
|
|
46
|
+
const m = (gc.issue_url ?? "").match(GITEA_COMMENT_ISSUE_RE);
|
|
47
|
+
return m ? Number(m[1]) : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function syncOrigin(cfg: Config): string {
|
|
51
|
+
const first = cfg.publicOrigins[0];
|
|
52
|
+
if (first) return first.replace(/\/+$/, "");
|
|
53
|
+
return `http://127.0.0.1:${cfg.port}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export class UpstreamSync {
|
|
57
|
+
private readonly sync: UpstreamSyncRow;
|
|
58
|
+
private readonly project: ProjectRow;
|
|
59
|
+
private readonly origin: string;
|
|
60
|
+
|
|
61
|
+
constructor(sync: UpstreamSyncRow, project: ProjectRow, origin: string) {
|
|
62
|
+
this.sync = sync;
|
|
63
|
+
this.project = project;
|
|
64
|
+
this.origin = origin;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
private headers(): Record<string, string> {
|
|
68
|
+
const h: Record<string, string> = { Accept: "application/json" };
|
|
69
|
+
if (this.sync.token) h.Authorization = `token ${this.sync.token}`;
|
|
70
|
+
return h;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private api(path: string): string {
|
|
74
|
+
return `${this.sync.base_url}/api/v1/repos/${this.sync.upstream_owner}/${this.sync.upstream_repo}${path}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async fetchJson<T>(path: string): Promise<T | null> {
|
|
78
|
+
const resp = await fetch(this.api(path), { headers: this.headers(), signal: AbortSignal.timeout(15_000) });
|
|
79
|
+
if (resp.status === 404) return null;
|
|
80
|
+
if (!resp.ok) throw new Error(`upstream ${path} -> ${resp.status}`);
|
|
81
|
+
return (await resp.json()) as T;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// First run (null issue cursor) is a backfill: import open issues + their
|
|
85
|
+
// comments silently so the AI isn't woken by a flood of historical entries.
|
|
86
|
+
// Live polls (cursor set) emit webhooks so new upstream activity reaches
|
|
87
|
+
// the daemon exactly like locally-created content.
|
|
88
|
+
private get isBackfill(): boolean {
|
|
89
|
+
return this.sync.issue_cursor === null;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
private async importIssue(gi: GiteaIssue, emit: boolean): Promise<void> {
|
|
93
|
+
await createIssue(
|
|
94
|
+
this.project.id,
|
|
95
|
+
gi.title || `#${gi.number}`,
|
|
96
|
+
gi.body ?? "",
|
|
97
|
+
gi.user?.login ?? "upstream",
|
|
98
|
+
{
|
|
99
|
+
createdAt: gi.created_at,
|
|
100
|
+
updatedAt: gi.updated_at,
|
|
101
|
+
state: gi.state === "closed" ? "closed" : "open",
|
|
102
|
+
upstreamIssueNumber: gi.number,
|
|
103
|
+
}
|
|
104
|
+
);
|
|
105
|
+
if (emit) {
|
|
106
|
+
const created = await getIssueByUpstreamNumber(this.project.id, gi.number);
|
|
107
|
+
if (created) void emitIssueEvent(this.project.id, created.id, "opened", this.origin);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private async syncIssueState(existing: IssueRow, gi: GiteaIssue, emit: boolean): Promise<boolean> {
|
|
112
|
+
const target: "open" | "closed" = gi.state === "closed" ? "closed" : "open";
|
|
113
|
+
if (existing.state === target) return false;
|
|
114
|
+
await editIssue(existing.id, { state: target });
|
|
115
|
+
if (emit) {
|
|
116
|
+
void emitIssueEvent(this.project.id, existing.id, target === "closed" ? "closed" : "reopened", this.origin);
|
|
117
|
+
}
|
|
118
|
+
return true;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private async importIssueComments(gi: GiteaIssue, emit: boolean): Promise<number> {
|
|
122
|
+
const comments = await this.fetchJson<GiteaComment[]>(`/issues/${gi.number}/comments?limit=50&order=asc`);
|
|
123
|
+
if (!comments) return 0;
|
|
124
|
+
const local = await getIssueByUpstreamNumber(this.project.id, gi.number);
|
|
125
|
+
if (!local) return 0;
|
|
126
|
+
let n = 0;
|
|
127
|
+
for (const gc of comments) {
|
|
128
|
+
if (await getCommentByUpstreamId(gc.id)) continue;
|
|
129
|
+
const row = await postComment(local.id, gc.body ?? "", gc.user?.login ?? "upstream", {
|
|
130
|
+
createdAt: gc.created_at,
|
|
131
|
+
updatedAt: gc.updated_at,
|
|
132
|
+
upstreamCommentId: gc.id,
|
|
133
|
+
});
|
|
134
|
+
if (emit) void emitCommentEvent(this.project.id, local.id, row.id, this.origin);
|
|
135
|
+
n++;
|
|
136
|
+
}
|
|
137
|
+
return n;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
private async backfillOnce(): Promise<UpstreamSyncPollResult> {
|
|
141
|
+
const result: UpstreamSyncPollResult = { issuesImported: 0, issuesUpdated: 0, commentsImported: 0 };
|
|
142
|
+
let cursor: string | null = null;
|
|
143
|
+
for (let page = 1; page <= 20; page++) {
|
|
144
|
+
const issues = await this.fetchJson<GiteaIssue[]>(
|
|
145
|
+
`/issues?state=open&type=issues&limit=50&page=${page}&sort=created&order=asc`
|
|
146
|
+
);
|
|
147
|
+
if (!issues || issues.length === 0) break;
|
|
148
|
+
for (const gi of issues) {
|
|
149
|
+
if (!(await getIssueByUpstreamNumber(this.project.id, gi.number))) {
|
|
150
|
+
await this.importIssue(gi, false);
|
|
151
|
+
result.issuesImported++;
|
|
152
|
+
}
|
|
153
|
+
result.commentsImported += await this.importIssueComments(gi, false);
|
|
154
|
+
if (gi.updated_at && (!cursor || gi.updated_at > cursor)) cursor = gi.updated_at;
|
|
155
|
+
}
|
|
156
|
+
if (issues.length < 50) break;
|
|
157
|
+
}
|
|
158
|
+
await updateUpstreamSyncState(this.project.id, {
|
|
159
|
+
issueCursor: cursor ?? new Date().toISOString(),
|
|
160
|
+
commentCursor: cursor ?? new Date().toISOString(),
|
|
161
|
+
lastError: null,
|
|
162
|
+
});
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
private async liveOnce(): Promise<UpstreamSyncPollResult> {
|
|
167
|
+
const result: UpstreamSyncPollResult = { issuesImported: 0, issuesUpdated: 0, commentsImported: 0 };
|
|
168
|
+
const issues = await this.fetchJson<GiteaIssue[]>(
|
|
169
|
+
`/issues?state=all&type=issues&limit=30&sort=updated&order=desc`
|
|
170
|
+
);
|
|
171
|
+
let issueCursor = this.sync.issue_cursor;
|
|
172
|
+
if (issues) {
|
|
173
|
+
for (const gi of issues) {
|
|
174
|
+
if (this.sync.issue_cursor && gi.updated_at <= this.sync.issue_cursor) continue;
|
|
175
|
+
const existing = await getIssueByUpstreamNumber(this.project.id, gi.number);
|
|
176
|
+
if (!existing) {
|
|
177
|
+
await this.importIssue(gi, true);
|
|
178
|
+
result.issuesImported++;
|
|
179
|
+
result.commentsImported += await this.importIssueComments(gi, true);
|
|
180
|
+
} else if (await this.syncIssueState(existing, gi, true)) {
|
|
181
|
+
result.issuesUpdated++;
|
|
182
|
+
}
|
|
183
|
+
if (gi.updated_at && (!issueCursor || gi.updated_at > issueCursor)) issueCursor = gi.updated_at;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let commentCursor = this.sync.comment_cursor;
|
|
188
|
+
const comments = await this.fetchJson<GiteaComment[]>(
|
|
189
|
+
`/issues/comments?limit=50&sort=updated&order=asc${
|
|
190
|
+
this.sync.comment_cursor ? `&since=${encodeURIComponent(this.sync.comment_cursor)}` : ""
|
|
191
|
+
}`
|
|
192
|
+
);
|
|
193
|
+
if (comments) {
|
|
194
|
+
for (const gc of comments) {
|
|
195
|
+
if (await getCommentByUpstreamId(gc.id)) continue;
|
|
196
|
+
const upstreamNumber = upstreamIssueNumberFromComment(gc);
|
|
197
|
+
if (!upstreamNumber) continue;
|
|
198
|
+
const local = await getIssueByUpstreamNumber(this.project.id, upstreamNumber);
|
|
199
|
+
if (!local) continue;
|
|
200
|
+
const row = await postComment(local.id, gc.body ?? "", gc.user?.login ?? "upstream", {
|
|
201
|
+
createdAt: gc.created_at,
|
|
202
|
+
updatedAt: gc.updated_at,
|
|
203
|
+
upstreamCommentId: gc.id,
|
|
204
|
+
});
|
|
205
|
+
void emitCommentEvent(this.project.id, local.id, row.id, this.origin);
|
|
206
|
+
result.commentsImported++;
|
|
207
|
+
}
|
|
208
|
+
const last = comments[comments.length - 1];
|
|
209
|
+
if (last?.updated_at && (!commentCursor || last.updated_at > commentCursor)) commentCursor = last.updated_at;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
await updateUpstreamSyncState(this.project.id, {
|
|
213
|
+
issueCursor: issueCursor ?? undefined,
|
|
214
|
+
commentCursor: commentCursor ?? undefined,
|
|
215
|
+
lastError: null,
|
|
216
|
+
});
|
|
217
|
+
return result;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async pollOnce(): Promise<UpstreamSyncPollResult> {
|
|
221
|
+
return this.isBackfill ? await this.backfillOnce() : await this.liveOnce();
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
let tickTimer: ReturnType<typeof setInterval> | null = null;
|
|
226
|
+
const inFlight = new Set<number>();
|
|
227
|
+
|
|
228
|
+
async function tick(cfg: Config): Promise<void> {
|
|
229
|
+
const rows = await listEnabledUpstreamSyncs();
|
|
230
|
+
for (const row of rows) {
|
|
231
|
+
if (inFlight.has(row.project_id)) continue;
|
|
232
|
+
const interval = Math.max(10_000, row.poll_interval_ms);
|
|
233
|
+
if (row.last_poll_at && Date.now() - Date.parse(row.last_poll_at) < interval) continue;
|
|
234
|
+
inFlight.add(row.project_id);
|
|
235
|
+
void (async () => {
|
|
236
|
+
try {
|
|
237
|
+
const project = await getProjectById(row.project_id);
|
|
238
|
+
if (!project) return;
|
|
239
|
+
const sync = new UpstreamSync(row, project, syncOrigin(cfg));
|
|
240
|
+
const r = await sync.pollOnce();
|
|
241
|
+
if (r.issuesImported || r.issuesUpdated || r.commentsImported) {
|
|
242
|
+
log.info("upstream-sync: polled", {
|
|
243
|
+
project: `${project.owner}/${project.name}`,
|
|
244
|
+
imported: r.issuesImported,
|
|
245
|
+
updated: r.issuesUpdated,
|
|
246
|
+
comments: r.commentsImported,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
} catch (e) {
|
|
250
|
+
log.warn(`upstream-sync: poll failed for project ${row.project_id}: ${(e as Error).message}`);
|
|
251
|
+
await updateUpstreamSyncState(row.project_id, { lastError: (e as Error).message }).catch(() => {});
|
|
252
|
+
} finally {
|
|
253
|
+
inFlight.delete(row.project_id);
|
|
254
|
+
}
|
|
255
|
+
})();
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function startUpstreamSyncPoller(cfg: Config): void {
|
|
260
|
+
if (tickTimer) return;
|
|
261
|
+
tickTimer = setInterval(() => {
|
|
262
|
+
void tick(cfg);
|
|
263
|
+
}, 15_000);
|
|
264
|
+
log.info("upstream-sync: poller started (tick=15s)");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export function stopUpstreamSyncPoller(): void {
|
|
268
|
+
if (tickTimer) clearInterval(tickTimer);
|
|
269
|
+
tickTimer = null;
|
|
270
|
+
}
|
|
@@ -3,8 +3,10 @@ import {
|
|
|
3
3
|
getDefaultUpstreamUrl,
|
|
4
4
|
getProjectUpstreamUrls,
|
|
5
5
|
setProjectUpstreamUrls,
|
|
6
|
+
upsertUpstreamSync,
|
|
6
7
|
StoreError,
|
|
7
8
|
type ProjectRow,
|
|
9
|
+
type UpstreamSyncRow,
|
|
8
10
|
type UserRow,
|
|
9
11
|
} from "../store";
|
|
10
12
|
|
|
@@ -56,10 +58,59 @@ function urlRowHtml(url: string, idx: number): string {
|
|
|
56
58
|
</tr>`;
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
function fmtDate(iso: string | null): string {
|
|
62
|
+
if (!iso) return "—";
|
|
63
|
+
const t = Date.parse(iso);
|
|
64
|
+
return Number.isNaN(t) ? "—" : new Date(t).toLocaleString("zh-CN", { hour12: false });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function syncCardHtml(project: ProjectRow, sync: UpstreamSyncRow | null): string {
|
|
68
|
+
const action = `/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/upstream-sync`;
|
|
69
|
+
const enabled = sync?.enabled === 1;
|
|
70
|
+
const statusHtml = sync
|
|
71
|
+
? `<table>
|
|
72
|
+
<thead><tr><th>状态</th><th>最近轮询</th><th>进度游标</th></tr></thead>
|
|
73
|
+
<tbody><tr>
|
|
74
|
+
<td>${enabled ? '<span class="badge default">运行中</span>' : "已停用"}</td>
|
|
75
|
+
<td>${escapeHtml(fmtDate(sync.last_poll_at))}${sync.last_error ? `<div class="err-text">⚠️ ${escapeHtml(sync.last_error)}</div>` : ""}</td>
|
|
76
|
+
<td>issue #${sync.issue_cursor ? String(sync.issue_cursor).slice(0, 10) : "未同步"} · 评论 #${sync.comment_cursor ? String(sync.comment_cursor).slice(0, 10) : "未同步"}</td>
|
|
77
|
+
</tr></tbody></table>`
|
|
78
|
+
: `<div class="hint">尚未配置。填写下方表单后,web 会定时从上游 Gitea 拉取 issue/评论(单向同步:上游 → 本地),首次会静默回填全部开放 issue,之后新事件按正常消息分发。</div>`;
|
|
79
|
+
const baseUrl = sync?.base_url ?? guessUpstreamBase(project) ?? "";
|
|
80
|
+
return `<form class="card" method="POST" action="${escapeAttr(action)}">
|
|
81
|
+
<h2>🔄 上游 Gitea 同步(单向拉取)</h2>
|
|
82
|
+
${statusHtml}
|
|
83
|
+
<div class="form-grid">
|
|
84
|
+
<div><label for="s-base">上游地址(Gitea 根地址,如 http://host:3000)</label>
|
|
85
|
+
<input id="s-base" name="base_url" type="url" placeholder="http://192.168.10.96:3300" value="${escapeAttr(baseUrl)}" required></div>
|
|
86
|
+
<div><label for="s-owner">上游 owner</label>
|
|
87
|
+
<input id="s-owner" name="upstream_owner" value="${escapeAttr(sync?.upstream_owner ?? project.owner)}" required></div>
|
|
88
|
+
<div><label for="s-repo">上游 repo</label>
|
|
89
|
+
<input id="s-repo" name="upstream_repo" value="${escapeAttr(sync?.upstream_repo ?? project.name)}" required></div>
|
|
90
|
+
<div><label for="s-token">访问 token(留空保持不变)</label>
|
|
91
|
+
<input id="s-token" name="token" type="password" placeholder="${sync?.token ? "已保存(留空不变)" : "可选,私有仓库必填"}"></div>
|
|
92
|
+
<div><label for="s-interval">轮询间隔(秒,最小 10)</label>
|
|
93
|
+
<input id="s-interval" name="poll_interval" type="number" min="10" step="1" value="${sync ? Math.round(sync.poll_interval_ms / 1000) : 60}"></div>
|
|
94
|
+
</div>
|
|
95
|
+
<label class="check"><input type="checkbox" name="enabled" value="1"${enabled ? " checked" : ""}> 启用同步</label>
|
|
96
|
+
<div class="hint">注意:上游需为 Gitea。地址填站点根地址即可(带不带 <code>/api/v1</code> 都可以,会自动归一)。回填阶段不触发 AI;之后的 opened/评论 事件会按正常策略唤醒 AI。</div>
|
|
97
|
+
<button class="primary" type="submit">保存同步配置</button>
|
|
98
|
+
</form>`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Heuristic: http(s) clone URL → Gitea host root; null otherwise.
|
|
102
|
+
function guessUpstreamBase(project: ProjectRow): string | null {
|
|
103
|
+
const url = getDefaultUpstreamUrl(project);
|
|
104
|
+
if (!url) return null;
|
|
105
|
+
const m = url.match(/^(https?:\/\/[^\/]+)\//i);
|
|
106
|
+
return m && m[1] ? m[1] : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
59
109
|
export function buildProjectUpstreamsPage(
|
|
60
110
|
_viewer: UserRow,
|
|
61
111
|
project: ProjectRow,
|
|
62
112
|
flash: Flash | null,
|
|
113
|
+
sync: UpstreamSyncRow | null = null,
|
|
63
114
|
): string {
|
|
64
115
|
const urls = getProjectUpstreamUrls(project);
|
|
65
116
|
const rowsHtml = urls.length
|
|
@@ -101,6 +152,11 @@ td.idx{width:60px;color:var(--text-muted);white-space:nowrap}
|
|
|
101
152
|
label{display:block;font-size:12px;color:var(--text-muted);margin:0 0 .25rem}
|
|
102
153
|
textarea{width:100%;box-sizing:border-box;padding:.5rem .65rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font:inherit;font-family:ui-monospace,monospace;font-size:13px;margin-bottom:.7rem;min-height:120px;resize:vertical}
|
|
103
154
|
button.primary{padding:.5rem 1rem;border:0;border-radius:6px;background:var(--accent);color:#fff;font-size:13px;cursor:pointer}
|
|
155
|
+
.form-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:.6rem;margin:.6rem 0}
|
|
156
|
+
.form-grid label{margin:0 0 .25rem}
|
|
157
|
+
input[type=url],input[type=text],input[type=password],input[type=number]{width:100%;box-sizing:border-box;padding:.45rem .6rem;border:1px solid var(--border);border-radius:6px;background:var(--bg);color:var(--text);font:inherit;font-size:13px}
|
|
158
|
+
label.check{display:flex;align-items:center;gap:.4rem;font-size:13px;color:var(--text);margin:.4rem 0 .6rem}
|
|
159
|
+
.err-text{color:#f85149;font-size:12px;margin-top:.3rem;word-break:break-all}
|
|
104
160
|
</style></head><body>
|
|
105
161
|
<header class="topbar"><span style="font-weight:600">🔗 ${escapeHtml(project.owner)}/${escapeHtml(project.name)} · 上游</span></header>
|
|
106
162
|
${tabNavHTML("projects")}
|
|
@@ -121,6 +177,8 @@ ${rowsHtml}
|
|
|
121
177
|
<div class="hint">支持协议:<code>http(s)://</code>、<code>ssh://</code>、<code>git@host:owner/repo</code>。最多 10 个。空行会被忽略,重复会被去重。</div>
|
|
122
178
|
<button class="primary" type="submit">保存</button>
|
|
123
179
|
</form>
|
|
180
|
+
|
|
181
|
+
${syncCardHtml(project, sync)}
|
|
124
182
|
</main></body></html>`;
|
|
125
183
|
}
|
|
126
184
|
|
|
@@ -131,6 +189,46 @@ export function parseUpstreamUrlsForm(text: string): string[] {
|
|
|
131
189
|
.filter((line) => line.length > 0);
|
|
132
190
|
}
|
|
133
191
|
|
|
192
|
+
export interface UpstreamSyncFormInput {
|
|
193
|
+
baseUrl: string;
|
|
194
|
+
upstreamOwner: string;
|
|
195
|
+
upstreamRepo: string;
|
|
196
|
+
token?: string;
|
|
197
|
+
enabled: boolean;
|
|
198
|
+
pollIntervalMs: number;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function parseUpstreamSyncForm(form: { get(name: string): string | File | null }): UpstreamSyncFormInput {
|
|
202
|
+
const val = (name: string): string => {
|
|
203
|
+
const v = form.get(name);
|
|
204
|
+
return typeof v === "string" ? v.trim() : "";
|
|
205
|
+
};
|
|
206
|
+
const intervalRaw = Number.parseInt(val("poll_interval") || "60", 10);
|
|
207
|
+
const intervalSec = Number.isFinite(intervalRaw) && intervalRaw >= 10 ? intervalRaw : 60;
|
|
208
|
+
const token = val("token");
|
|
209
|
+
return {
|
|
210
|
+
baseUrl: val("base_url"),
|
|
211
|
+
upstreamOwner: val("upstream_owner"),
|
|
212
|
+
upstreamRepo: val("upstream_repo"),
|
|
213
|
+
token: token.length ? token : undefined,
|
|
214
|
+
enabled: val("enabled") === "1",
|
|
215
|
+
pollIntervalMs: intervalSec * 1000,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
export async function trySetUpstreamSync(
|
|
220
|
+
projectId: number,
|
|
221
|
+
input: UpstreamSyncFormInput,
|
|
222
|
+
): Promise<{ ok: true } | { ok: false; msg: string }> {
|
|
223
|
+
try {
|
|
224
|
+
await upsertUpstreamSync(projectId, input);
|
|
225
|
+
return { ok: true };
|
|
226
|
+
} catch (e) {
|
|
227
|
+
const msg = e instanceof StoreError ? e.message : e instanceof Error ? e.message : "保存失败";
|
|
228
|
+
return { ok: false, msg };
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
134
232
|
export async function trySetUpstreamUrls(
|
|
135
233
|
projectId: number,
|
|
136
234
|
raw: string,
|