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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ework-web",
3
- "version": "0.10.71",
3
+ "version": "0.10.72",
4
4
  "type": "module",
5
5
  "description": "ework-web — standalone multi-project issue tracker. Local SQLite-backed, no external API dependency. Bun + TypeScript + SSR HTML.",
6
6
  "license": "MIT",
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 migrateMysqlIssuesAiStatus(pool: Pool): Promise<void> {
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(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN ai_status VARCHAR(32) NOT NULL DEFAULT ''"));
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("[db] MySQL issues ai_status column add failed:", (e as Error).message);
384
+ console.warn(`[db] MySQL ${table}.${column} column add failed:`, (e as Error).message);
372
385
  }
373
- try {
374
- const [mcols] = await pool.query(
375
- applyPrefix("SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{{issues}}' AND COLUMN_NAME = 'model'")
376
- );
377
- if (!(Array.isArray(mcols) && mcols.length > 0)) {
378
- await pool.query(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN model VARCHAR(128) NOT NULL DEFAULT ''"));
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 { buildProjectUpstreamsPage, trySetUpstreamUrls } from "./views/projectUpstreams";
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);
@@ -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,