ework-web 0.10.70 → 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.70",
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
@@ -131,6 +131,20 @@ function migrateIssuesTable(db: Database): void {
131
131
  if (!have.has("ai_status")) {
132
132
  db.exec(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN ai_status TEXT NOT NULL DEFAULT ''"));
133
133
  }
134
+ if (!have.has("model")) {
135
+ db.exec(applyPrefix("ALTER TABLE {{issues}} ADD COLUMN model TEXT NOT NULL DEFAULT ''"));
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
+ }
134
148
  }
135
149
 
136
150
  function migrateLabelsTable(db: Database): void {
@@ -239,6 +253,7 @@ class SqliteDriver implements AsyncDatabase {
239
253
  migratePatTable(db);
240
254
  migrateProjectsTable(db);
241
255
  migrateIssuesTable(db);
256
+ migrateCommentsTable(db);
242
257
  migrateLabelsTable(db);
243
258
  migrateAddSurrogateId(db);
244
259
  db.exec(applyPrefix(readFileSync(join(import.meta.dir, "schema.sql"), "utf8")));
@@ -357,15 +372,41 @@ async function migrateMysqlProjectsVisibility(pool: Pool): Promise<void> {
357
372
  }
358
373
  }
359
374
 
360
- async function migrateMysqlIssuesAiStatus(pool: Pool): Promise<void> {
361
- const [cols] = await pool.query(
362
- applyPrefix("SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = '{{issues}}' AND COLUMN_NAME = 'ai_status'")
363
- );
364
- if (Array.isArray(cols) && cols.length > 0) return;
375
+ async function migrateMysqlColumn(pool: Pool, table: string, column: string, ddl: string): Promise<void> {
365
376
  try {
366
- 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}`));
367
383
  } catch (e) {
368
- 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);
385
+ }
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);
409
+ }
369
410
  }
370
411
  }
371
412
 
package/src/giteaApi.ts CHANGED
@@ -197,6 +197,7 @@ export async function handleGiteaApi(
197
197
  updatedAt: asString(body.updated_at),
198
198
  state: asIssueState(body.state) ?? "open",
199
199
  closedAt: asString(body.closed_at) || undefined,
200
+ model: asString(body.model) || undefined,
200
201
  });
201
202
  void emitIssueEvent(project.id, created.id, "opened", origin);
202
203
  if (created.state === "closed") {
package/src/index.ts CHANGED
@@ -38,6 +38,7 @@ import {
38
38
  postComment,
39
39
  setIssueState,
40
40
  updateIssueAiStatus,
41
+ updateIssueModel,
41
42
  listIssues,
42
43
  createAttachment,
43
44
  getAttachment,
@@ -74,9 +75,11 @@ import {
74
75
  deleteLabel,
75
76
  setIssueLabels,
76
77
  listAllProjectIds,
78
+ getUpstreamSync,
77
79
  type ProjectRole,
78
80
  type UserRow,
79
81
  } from "./store";
82
+ import { startUpstreamSyncPoller } from "./upstream-sync";
80
83
  import {
81
84
  newAttachmentUUID,
82
85
  saveAttachmentBlob,
@@ -105,7 +108,12 @@ import { buildWebhooksPage } from "./views/webhooks";
105
108
  import { buildWebhookDeliveriesPage } from "./views/webhookDeliveries";
106
109
  import { browseRemoteFile, proxyFileSince, RemoteFileError } from "./remote-file";
107
110
  import { buildProjectMembersPage } from "./views/projectMembers";
108
- import { buildProjectUpstreamsPage, trySetUpstreamUrls } from "./views/projectUpstreams";
111
+ import {
112
+ buildProjectUpstreamsPage,
113
+ parseUpstreamSyncForm,
114
+ trySetUpstreamSync,
115
+ trySetUpstreamUrls,
116
+ } from "./views/projectUpstreams";
109
117
  import { buildProjectLabelsPage } from "./views/projectLabels";
110
118
  import { buildProjectModelPage } from "./views/projectModel";
111
119
  import { buildProjectAiPage } from "./views/projectAi";
@@ -136,6 +144,8 @@ async function refreshOpencodeClient(): Promise<void> {
136
144
  await refreshOpencodeClient();
137
145
  setInterval(refreshOpencodeClient, 10_000);
138
146
 
147
+ startUpstreamSyncPoller(cfg);
148
+
139
149
  async function autoWireDaemon(projectId: number, origin: string): Promise<void> {
140
150
  if (!cfg.autowireActive) {
141
151
  log.info("autoWireDaemon: skipped (WORK_AUTOWIRE_ACTIVE=false)", { projectId });
@@ -379,12 +389,14 @@ const REPO_DISPATCH_RE = /^\/([^/]+)\/([^/]+)\/settings\/dispatch$/;
379
389
  const REPO_HALT_ALL_RE = /^\/([^/]+)\/([^/]+)\/settings\/ai\/halt-all$/;
380
390
  const SETTINGS_DISPATCH_RE = /^\/settings\/dispatch$/;
381
391
  const REPO_UPSTREAMS_RE = /^\/([^/]+)\/([^/]+)\/settings\/upstreams$/;
392
+ const REPO_UPSTREAM_SYNC_RE = /^\/([^/]+)\/([^/]+)\/settings\/upstream-sync$/;
382
393
  const REPO_MODEL_RE = /^\/([^/]+)\/([^/]+)\/settings\/model$/;
383
394
  const REPO_AI_RE = /^\/([^/]+)\/([^/]+)\/settings\/ai$/;
384
395
  const REPO_LABELS_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels$/;
385
396
  const REPO_LABEL_ADD_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/add$/;
386
397
  const REPO_LABEL_ACTION_RE = /^\/([^/]+)\/([^/]+)\/settings\/labels\/(\d+)\/(update|archive|unarchive|delete)$/;
387
398
  const REPO_ISSUE_HALT_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/(halt|resume|dispatch-off|dispatch-on)$/;
399
+ const REPO_ISSUE_MODEL_RE = /^\/([^/]+)\/([^/]+)\/issues\/(\d+)\/model$/;
388
400
  const API_ISSUE_LABELS_RE = /^\/api\/([^/]+)\/([^/]+)\/issues\/(\d+)\/labels$/;
389
401
  const WH_ACTION_RE = /^\/__wh\/(\d+)\/(delete|toggle|test)$/;
390
402
  const SESSIONS_RE = /^\/sessions$/;
@@ -1832,6 +1844,28 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1832
1844
  }
1833
1845
  }
1834
1846
 
1847
+ const mp = url.pathname.match(REPO_ISSUE_MODEL_RE);
1848
+ if (mp) {
1849
+ const [, owner, repo, numStr] = mp;
1850
+ if (!(owner && repo && numStr)) return json({ error: "bad path" }, 400);
1851
+ const number = Number(numStr);
1852
+ try {
1853
+ const project = await getProject(owner, repo);
1854
+ if (!project) return json({ error: "project not found" }, 404);
1855
+ const issue = await getIssueWithMeta(project.id, number);
1856
+ if (!issue) return json({ error: "issue not found" }, 404);
1857
+ if (!(await canWriteProject(project.id, ctx.user))) {
1858
+ return json({ error: "writer role required" }, 403);
1859
+ }
1860
+ const form = await req.formData().catch(() => new FormData());
1861
+ const model = String(form.get("model") ?? "").trim().slice(0, 128);
1862
+ await updateIssueModel(issue.id, model);
1863
+ return json({ ok: true, model });
1864
+ } catch (e) {
1865
+ return json({ error: errMsg(e) }, e instanceof StoreError ? e.status : 500);
1866
+ }
1867
+ }
1868
+
1835
1869
  const ci = url.pathname.match(REPO_LIST_RE);
1836
1870
  if (ci) {
1837
1871
  const [, owner, repo] = ci;
@@ -1840,6 +1874,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1840
1874
  const form = await req.formData().catch(() => new FormData());
1841
1875
  const title = String(form.get("title") ?? "").trim();
1842
1876
  const body = String(form.get("body") ?? "");
1877
+ const model = String(form.get("model") ?? "").trim().slice(0, 128);
1843
1878
  if (!title) return html(errorPage("标题不能为空", "回到上一页填写标题后重试"), 400);
1844
1879
  let project = await getProject(owner, repo);
1845
1880
  let createdProject = false;
@@ -1854,7 +1889,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
1854
1889
  await ensureProjectBootstrapAdmin(project.id, ctx.user!.login);
1855
1890
  await autoWireDaemon(project.id, url.origin);
1856
1891
  }
1857
- const issue = await createIssue(project.id, title, body, ctx.user!.login);
1892
+ const issue = await createIssue(project.id, title, body, ctx.user!.login, model ? { model } : {});
1858
1893
  void emitIssueEvent(project.id, issue.id, "opened", url.origin);
1859
1894
  return Response.redirect(
1860
1895
  `${url.origin}/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issue.number}`,
@@ -2080,6 +2115,28 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
2080
2115
  return Response.redirect(`${back}?err=${encodeURIComponent(result.msg)}`, 303);
2081
2116
  }
2082
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
+
2083
2140
  const modelSave = url.pathname.match(REPO_MODEL_RE);
2084
2141
  if (modelSave) {
2085
2142
  const [, owner, repo] = modelSave;
@@ -2193,7 +2250,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
2193
2250
  if (isNew) {
2194
2251
  const [, owner, repo] = isNew;
2195
2252
  if (!(owner && repo)) return html(errorPage("404", "bad path"), 404);
2196
- return html(buildIssueNew(owner, repo, cfg.writesEnabled));
2253
+ return html(buildIssueNew(owner, repo, cfg.writesEnabled, await listCachedModels()));
2197
2254
  }
2198
2255
 
2199
2256
  const view = url.pathname.match(REPO_ISSUE_RE);
@@ -2295,7 +2352,7 @@ async function handle(req: Request, url: URL, ip: string, ctx: { authed: boolean
2295
2352
  const flashKind = url.searchParams.get("ok") === "1" ? "ok" : url.searchParams.get("err") ? "err" : null;
2296
2353
  const flashMsg = flashKind === "ok" ? (url.searchParams.get("ok_msg") ?? "") : (url.searchParams.get("err") ?? "");
2297
2354
  const flash = flashKind ? { kind: flashKind as "ok" | "err", msg: flashMsg } : null;
2298
- return html(buildProjectUpstreamsPage(ctx.user!, project, flash));
2355
+ return html(buildProjectUpstreamsPage(ctx.user!, project, flash, await getUpstreamSync(project.id)));
2299
2356
  }
2300
2357
 
2301
2358
  const labelsPage = url.pathname.match(REPO_LABELS_RE);
@@ -23,6 +23,7 @@ export interface LayoutProps {
23
23
  viewerIsAdmin?: boolean;
24
24
  customActions?: IssueAction[];
25
25
  extraStatusBadges?: Record<string, { cls: string; label: string }>;
26
+ modelSelect?: { current: string; options: { id: string; label: string }[] } | null;
26
27
  }
27
28
 
28
29
  export const THEME_CSS = `
@@ -127,6 +128,9 @@ header.topbar .num{opacity:.7}
127
128
  .issue-label{display:inline-flex;align-items:center;font-size:12px;font-weight:500;padding:.05rem .5rem;border:1px solid;border-radius:99px;line-height:1.6;background:color-mix(in srgb,currentColor 8%,transparent)}
128
129
  .label-edit-btn{background:none;border:1px solid var(--border);border-radius:6px;padding:.05rem .35rem;cursor:pointer;font-size:13px;line-height:1.5;color:var(--text-muted)}
129
130
  .label-edit-btn:hover{border-color:var(--accent);color:var(--accent)}
131
+ .model-select-wrap{display:inline-flex;align-items:center;gap:.25rem}
132
+ .model-select{background:var(--bg-elev);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:.15rem .3rem;font:12px system-ui,sans-serif;max-width:180px}
133
+ .model-save-btn{padding:.15rem .45rem;font-size:13px}
130
134
  #labelDlg{border:1px solid var(--border);border-radius:10px;background:var(--bg-elev);color:var(--text);padding:1.1rem;max-width:380px;width:90vw}
131
135
  #labelDlg::backdrop{background:rgba(0,0,0,.5)}
132
136
  #labelDlg h3{margin:0 0 .6rem;font-size:14px}
@@ -199,6 +203,12 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
199
203
  ? `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-on" title="允许自动接单">🔔 接单</button>`
200
204
  : `<button type="button" class="action-btn dispatch-btn" data-action-href="${escapeAttr(repoIssuesHref)}/${props.issueNumber}/dispatch-off" data-action-confirm="设为不自动接单?" title="设为不自动接单">🔕 不接单</button>`
201
205
  : "";
206
+ const modelSelectHtml = props.modelSelect && props.modelSelect.options.length > 0 && showActions
207
+ ? `<span class="model-select-wrap"><select class="model-select" id="issueModelSelect" title="此 issue 的模型(覆盖项目/全局默认)">
208
+ <option value="">默认模型</option>
209
+ ${props.modelSelect.options.map((m) => `<option value="${escapeAttr(m.id)}" ${m.id === props.modelSelect!.current ? "selected" : ""}>${escapeHtml(m.label)}</option>`).join("")}
210
+ </select><button type="button" class="action-btn model-save-btn" id="issueModelSave" title="保存模型选择">💾</button></span>`
211
+ : "";
202
212
  return `<!doctype html>
203
213
  <html lang="zh">
204
214
  <head>
@@ -221,7 +231,7 @@ export function renderLayout(props: LayoutProps, inner: string, initialItems: st
221
231
  <div class="meta-status">
222
232
  <span class="state-badge ${stateClass}">${stateLabel}</span>
223
233
  ${aiBadgeHtml}
224
- ${(haltBtnHtml || dispatchBtnHtml || (props.customActions ?? []).length) ? `<span class="action-group">${haltBtnHtml}${dispatchBtnHtml}${(props.customActions ?? []).map((a) => {
234
+ ${(haltBtnHtml || dispatchBtnHtml || (props.customActions ?? []).length) ? `<span class="action-group">${haltBtnHtml}${dispatchBtnHtml}${modelSelectHtml}${(props.customActions ?? []).map((a) => {
225
235
  const attrs = [`data-action-href="${escapeAttr(a.href)}"`];
226
236
  if (a.method && a.method !== "POST") attrs.push(`data-action-method="${escapeAttr(a.method)}"`);
227
237
  if (a.confirm) attrs.push(`data-action-confirm="${escapeAttr(a.confirm)}"`);
@@ -54,7 +54,10 @@ CREATE TABLE IF NOT EXISTS {{issues}} (
54
54
  updated_at VARCHAR(40) NOT NULL,
55
55
  closed_at VARCHAR(40) DEFAULT NULL,
56
56
  ai_status VARCHAR(32) NOT NULL DEFAULT '',
57
+ model VARCHAR(128) NOT NULL DEFAULT '',
58
+ upstream_issue_number INT DEFAULT NULL,
57
59
  UNIQUE (project_id, number),
60
+ UNIQUE uq_issues_project_upstream (project_id, upstream_issue_number),
58
61
  CONSTRAINT {{fk_issues_project}} FOREIGN KEY (project_id) REFERENCES {{projects}}(id) ON DELETE CASCADE,
59
62
  CONSTRAINT {{fk_issues_author}} FOREIGN KEY (author) REFERENCES {{users}}(login)
60
63
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@@ -71,12 +74,33 @@ CREATE TABLE IF NOT EXISTS {{comments}} (
71
74
  body TEXT NOT NULL,
72
75
  created_at VARCHAR(40) NOT NULL,
73
76
  updated_at VARCHAR(40) NOT NULL DEFAULT '',
77
+ upstream_comment_id BIGINT DEFAULT NULL,
78
+ UNIQUE uq_comments_upstream (upstream_comment_id),
74
79
  CONSTRAINT {{fk_comments_issue}} FOREIGN KEY (issue_id) REFERENCES {{issues}}(id) ON DELETE CASCADE,
75
80
  CONSTRAINT {{fk_comments_author}} FOREIGN KEY (author) REFERENCES {{users}}(login)
76
81
  ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
77
82
  CREATE INDEX comments_issue_created ON {{comments}} (issue_id, created_at);
78
83
  CREATE INDEX comments_author ON {{comments}} (author);
79
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
+
80
104
  CREATE TABLE IF NOT EXISTS {{labels}} (
81
105
  id BIGINT AUTO_INCREMENT PRIMARY KEY,
82
106
  project_id BIGINT NOT NULL,
package/src/schema.sql CHANGED
@@ -65,12 +65,18 @@ CREATE TABLE IF NOT EXISTS {{issues}} (
65
65
  closed_at TEXT,
66
66
  -- AI processing status: '' (none) | 'processing' | 'halted' | 'completed' | 'failed'
67
67
  ai_status TEXT NOT NULL DEFAULT '',
68
+ -- Resolved "provider/model" for this issue. Empty = inherit project/global default.
69
+ model TEXT NOT NULL DEFAULT '',
70
+ -- Upstream Gitea issue number this row was imported from (NULL = native).
71
+ upstream_issue_number INTEGER,
68
72
  UNIQUE (project_id, number)
69
73
  );
70
74
  CREATE INDEX IF NOT EXISTS issues_project_state_updated
71
75
  ON {{issues}} (project_id, state, updated_at DESC);
72
76
  CREATE INDEX IF NOT EXISTS issues_state_updated
73
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;
74
80
 
75
81
  CREATE TABLE IF NOT EXISTS {{comments}} (
76
82
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -78,10 +84,31 @@ CREATE TABLE IF NOT EXISTS {{comments}} (
78
84
  author TEXT NOT NULL REFERENCES {{users}}(login),
79
85
  body TEXT NOT NULL,
80
86
  created_at TEXT NOT NULL,
81
- updated_at TEXT NOT NULL DEFAULT ''
87
+ updated_at TEXT NOT NULL DEFAULT '',
88
+ upstream_comment_id INTEGER
82
89
  );
83
90
  CREATE INDEX IF NOT EXISTS comments_issue_created
84
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
+ );
85
112
 
86
113
  CREATE TABLE IF NOT EXISTS {{labels}} (
87
114
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -35,4 +35,30 @@
35
35
  btn.textContent = orig;
36
36
  });
37
37
  });
38
+
39
+ document.addEventListener("click", function (e) {
40
+ var saveBtn = e.target.closest("#issueModelSave");
41
+ if (!saveBtn) return;
42
+ e.preventDefault();
43
+ var sel = document.getElementById("issueModelSelect");
44
+ if (!sel) return;
45
+ var m = sel.value;
46
+ saveBtn.disabled = true;
47
+ var path = location.pathname.split("/").slice(0, 5).join("/");
48
+ fetch(path + "/model", {
49
+ method: "POST",
50
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
51
+ body: "model=" + encodeURIComponent(m)
52
+ })
53
+ .then(function (r) { return r.json(); })
54
+ .then(function (d) {
55
+ saveBtn.disabled = false;
56
+ if (d.ok) { saveBtn.textContent = "✓"; setTimeout(function () { saveBtn.textContent = "💾"; }, 1200); }
57
+ else alert(d.error || "保存失败");
58
+ })
59
+ .catch(function (err) {
60
+ saveBtn.disabled = false;
61
+ alert("网络错误: " + err);
62
+ });
63
+ });
38
64
  })();
package/src/store.ts CHANGED
@@ -74,6 +74,7 @@ export interface IssueRow {
74
74
  updated_at: string;
75
75
  closed_at: string | null;
76
76
  ai_status: string;
77
+ model: string;
77
78
  }
78
79
 
79
80
  export interface IssueWithMeta extends IssueRow {
@@ -316,7 +317,9 @@ export async function setProjectModel(projectId: number, model: string): Promise
316
317
  // Resolve the effective model for a project: project override > global
317
318
  // default. Returns "" when neither is set (caller omits --model and lets
318
319
  // opencode pick per its own config).
319
- export function resolveModel(projectModel: string, globalDefault: string): string {
320
+ export function resolveModel(projectModel: string, globalDefault: string, issueModel?: string): string {
321
+ const i = String(issueModel ?? "").trim();
322
+ if (i) return i;
320
323
  const p = String(projectModel ?? "").trim();
321
324
  if (p) return p;
322
325
  return String(globalDefault ?? "").trim();
@@ -448,6 +451,8 @@ export interface CreateIssueOpts {
448
451
  updatedAt?: string;
449
452
  state?: "open" | "closed";
450
453
  closedAt?: string | null;
454
+ model?: string;
455
+ upstreamIssueNumber?: number;
451
456
  }
452
457
 
453
458
  function isoOr(value: string | undefined, fallback: string): string {
@@ -478,8 +483,8 @@ export async function createIssue(
478
483
  "SELECT COALESCE(MAX(number), 0) + 1 AS n FROM {{issues}} WHERE project_id = ?", [projectId]
479
484
  ))!;
480
485
  const info = await getDB().run(
481
- "INSERT INTO {{issues}} (project_id, number, title, body, state, author, created_at, updated_at, closed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
482
- [projectId, next.n, title, body, state, author, createdAt, updatedAt, closedAt]
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]
483
488
  );
484
489
  await getDB().run("UPDATE {{projects}} SET updated_at = ? WHERE id = ?", [updatedAt, projectId]);
485
490
  return (await getIssueById(info.insertId))!;
@@ -514,6 +519,112 @@ export async function getIssueAiStatus(issueId: number): Promise<string> {
514
519
  return row?.ai_status ?? "";
515
520
  }
516
521
 
522
+ export async function updateIssueModel(issueId: number, model: string): Promise<void> {
523
+ const clean = model.trim().slice(0, 128);
524
+ await getDB().run("UPDATE {{issues}} SET model = ?, updated_at = ? WHERE id = ?", [clean, now(), issueId]);
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
+
517
628
  export interface IssuePatch {
518
629
  title?: string;
519
630
  body?: string;
@@ -612,6 +723,7 @@ export async function listCommentsForIssue(issueId: number): Promise<CommentRow[
612
723
  export interface CreateCommentOpts {
613
724
  createdAt?: string;
614
725
  updatedAt?: string;
726
+ upstreamCommentId?: number;
615
727
  }
616
728
 
617
729
  export async function postComment(
@@ -627,8 +739,8 @@ export async function postComment(
627
739
  const updatedAt = opts.updatedAt ? isoOr(opts.updatedAt, createdAt) : createdAt;
628
740
  return await getDB().transaction(async () => {
629
741
  const info = await getDB().run(
630
- "INSERT INTO {{comments}} (issue_id, author, body, created_at, updated_at) VALUES (?, ?, ?, ?, ?)",
631
- [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]
632
744
  );
633
745
  await getDB().run("UPDATE {{issues}} SET updated_at = ? WHERE id = ?", [updatedAt, issueId]);
634
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
+ }
@@ -1,12 +1,20 @@
1
1
  import { THEME_CSS, escapeHtml, escapeAttr } from "../render/layout";
2
+ import type { CachedModel } from "../store";
2
3
 
3
- export function buildIssueNew(owner: string, repo: string, writesEnabled: boolean): string {
4
+ export function buildIssueNew(owner: string, repo: string, writesEnabled: boolean, models: CachedModel[] = [], currentModel = ""): string {
4
5
  const listHref = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`;
5
6
  const action = `/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues`;
7
+ const modelSelect = models.length > 0
8
+ ? `<select name="model" class="new-model">
9
+ <option value="">默认模型(项目/全局设置)</option>
10
+ ${models.map((m) => `<option value="${escapeAttr(m.id)}" ${m.id === currentModel ? "selected" : ""}>${escapeHtml(m.label)}</option>`).join("")}
11
+ </select>`
12
+ : "";
6
13
  const body = writesEnabled
7
14
  ? `<form class="new-form" method="POST" action="${escapeAttr(action)}">
8
15
  <input type="text" name="title" placeholder="标题(必填)" required maxlength="255" class="new-title">
9
16
  <textarea name="body" rows="14" placeholder="正文(支持 Markdown)…"></textarea>
17
+ ${modelSelect}
10
18
  <div class="new-actions"><a class="new-cancel" href="${escapeAttr(listHref)}">取消</a><button type="submit">创建工单</button></div>
11
19
  </form>`
12
20
  : `<div class="composer-ro">只读模式:创建工单未启用(WORK_WRITES_ENABLED=false)</div>`;
@@ -20,6 +28,7 @@ export function buildIssueNew(owner: string, repo: string, writesEnabled: boolea
20
28
  .new-form{display:flex;flex-direction:column;gap:.6rem}
21
29
  .new-title{width:100%;background:var(--bg-elev);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:.55rem .7rem;font:600 16px system-ui,sans-serif}
22
30
  .new-form textarea{width:100%;resize:vertical;min-height:14em;background:var(--bg-elev);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:.6rem .7rem;font:14px/1.55 -apple-system,"PingFang SC",sans-serif}
31
+ .new-model{width:100%;background:var(--bg-elev);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:.5rem .7rem;font:13px system-ui,sans-serif}
23
32
  .new-actions{display:flex;gap:.6rem;justify-content:flex-end;align-items:center}
24
33
  .new-cancel{font-size:13px;color:var(--text-muted)}
25
34
  .new-form button{background:var(--green);color:#fff;border:none;border-radius:8px;padding:.55rem 1.2rem;font:600 13px system-ui,sans-serif;cursor:pointer}
@@ -14,6 +14,7 @@ import {
14
14
  getDefaultUpstreamUrl,
15
15
  listLabelsForIssue,
16
16
  getUserByLogin,
17
+ listCachedModels,
17
18
  type CommentRow,
18
19
  type IssueWithMeta,
19
20
  } from "../store";
@@ -156,6 +157,9 @@ export async function buildIssueThread(
156
157
  viewerIsAdmin,
157
158
  customActions,
158
159
  extraStatusBadges,
160
+ modelSelect: cfg.writesEnabled !== false
161
+ ? { current: issue.model ?? "", options: (await listCachedModels()).map((m) => ({ id: m.id, label: m.label })) }
162
+ : null,
159
163
  },
160
164
  safeJsonEmbed(payload),
161
165
  displayViews.map((v) => renderCommentCard(v, cfg)).join("")
@@ -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,
package/src/webhooks.ts CHANGED
@@ -667,7 +667,7 @@ export async function emitIssueEvent(
667
667
  // override (daemon omits --model, lets opencode pick). globalDefault
668
668
  // comes from the config table via loadConfig() — cheap DB read.
669
669
  const globalDefault = (await loadConfig()).defaultModel;
670
- const model = resolveModel(project.model, globalDefault);
670
+ const model = resolveModel(project.model, globalDefault, issue.model);
671
671
  const labels = await toPayloadLabels(issueId);
672
672
  const payload = buildIssuePayload(issue, project, commentCount, action, origin, model, labels);
673
673
  (payload as IssueEventPayload).event_id = randomUUID();
@@ -707,7 +707,7 @@ export async function emitCommentEvent(
707
707
  const authorUser = await getUserByLogin(comment.author);
708
708
  const commentCount = await countCommentsSafe(issueId);
709
709
  const globalDefault = (await loadConfig()).defaultModel;
710
- const model = resolveModel(project.model, globalDefault);
710
+ const model = resolveModel(project.model, globalDefault, issue.model);
711
711
  const labels = await toPayloadLabels(issueId);
712
712
  const payload = buildCommentPayload(issue, comment, project, commentCount, origin, model, labels);
713
713
  (payload as CommentEventPayload).event_id = randomUUID();