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 +1 -1
- package/src/db.ts +48 -7
- package/src/giteaApi.ts +1 -0
- package/src/index.ts +61 -4
- package/src/render/layout.ts +11 -1
- package/src/schema-mysql.sql +24 -0
- package/src/schema.sql +28 -1
- package/src/static/issue-actions.js +26 -0
- package/src/store.ts +117 -5
- package/src/upstream-sync.ts +270 -0
- package/src/views/issueNew.ts +10 -1
- package/src/views/issueThread.ts +4 -0
- package/src/views/projectUpstreams.ts +98 -0
- package/src/webhooks.ts +2 -2
package/package.json
CHANGED
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
|
|
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(
|
|
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(
|
|
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 {
|
|
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);
|
package/src/render/layout.ts
CHANGED
|
@@ -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)}"`);
|
package/src/schema-mysql.sql
CHANGED
|
@@ -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
|
+
}
|
package/src/views/issueNew.ts
CHANGED
|
@@ -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}
|
package/src/views/issueThread.ts
CHANGED
|
@@ -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();
|