import { THEME_CSS, escapeHtml, escapeAttr, tabNavHTML } from "../render/layout"; import { getDefaultUpstreamUrl, getProjectUpstreamUrls, setProjectUpstreamUrls, upsertUpstreamSync, StoreError, type ProjectRow, type UpstreamSyncRow, type UserRow, } from "../store"; interface Flash { kind: "ok" | "err"; msg: string; } export function projectSettingsTabsHTML( owner: string, name: string, active: "webhooks" | "members" | "upstreams" | "model" | "labels" | "ai", ): string { const base = `/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/settings`; const cls = (which: typeof active) => (active === which ? " active" : ""); return ``; } // Try to convert a Git clone URL into a clickable web URL. Returns null if the // protocol can't be reliably mapped (e.g. SSH SCP form `git@host:o/r`). function webUrlFromClone(cloneUrl: string): string | null { if (/^https?:\/\//i.test(cloneUrl)) { return cloneUrl.replace(/\.git$/i, ""); } if (/^ssh:\/\/([^/]+)\/(.+)$/i.test(cloneUrl)) { // ssh://user@host:port/owner/repo → https://host/owner/repo const m = cloneUrl.match(/^ssh:\/\/(?:[^@]*@)?([^:/]+)(?::\d+)?\/(.+)$/i); if (m && m[1] && m[2]) return `https://${m[1]}/${m[2].replace(/\.git$/i, "")}`; } return null; } function urlRowHtml(url: string, idx: number): string { const isDefault = idx === 0; const webUrl = webUrlFromClone(url); const link = webUrl ? `${escapeHtml(url)}` : `${escapeHtml(url)}`; return ` ${isDefault ? '默认' : String(idx + 1)} ${link} `; } function fmtDate(iso: string | null): string { if (!iso) return "—"; const t = Date.parse(iso); return Number.isNaN(t) ? "—" : new Date(t).toLocaleString("zh-CN", { hour12: false }); } function syncCardHtml(project: ProjectRow, sync: UpstreamSyncRow | null): string { const action = `/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/upstream-sync`; const enabled = sync?.enabled === 1; const statusHtml = sync ? `
状态最近轮询进度游标
${enabled ? '运行中' : "已停用"} ${escapeHtml(fmtDate(sync.last_poll_at))}${sync.last_error ? `
⚠️ ${escapeHtml(sync.last_error)}
` : ""}
issue #${sync.issue_cursor ? String(sync.issue_cursor).slice(0, 10) : "未同步"} · 评论 #${sync.comment_cursor ? String(sync.comment_cursor).slice(0, 10) : "未同步"}
` : `
尚未配置。填写下方表单后,web 会定时从上游 Gitea 拉取 issue/评论(单向同步:上游 → 本地),首次会静默回填全部开放 issue,之后新事件按正常消息分发。
`; const baseUrl = sync?.base_url ?? guessUpstreamBase(project) ?? ""; return `

🔄 上游 Gitea 同步(单向拉取)

${statusHtml}
注意:上游需为 Gitea。地址填站点根地址即可(带不带 /api/v1 都可以,会自动归一)。回填阶段不触发 AI;之后的 opened/评论 事件会按正常策略唤醒 AI。
`; } // Heuristic: http(s) clone URL → Gitea host root; null otherwise. function guessUpstreamBase(project: ProjectRow): string | null { const url = getDefaultUpstreamUrl(project); if (!url) return null; const m = url.match(/^(https?:\/\/[^\/]+)\//i); return m && m[1] ? m[1] : null; } export function buildProjectUpstreamsPage( _viewer: UserRow, project: ProjectRow, flash: Flash | null, sync: UpstreamSyncRow | null = null, ): string { const urls = getProjectUpstreamUrls(project); const rowsHtml = urls.length ? `${urls.map((u, i) => urlRowHtml(u, i)).join("")}
URL
` : `
该项目还没有绑定上游 Git 仓库。在下方添加(每行一个 URL,第一个为默认上游)。
`; const flashHtml = flash ? `
${escapeHtml(flash.msg)}
` : ""; const formAction = `/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/upstreams`; const textareaContent = escapeHtml(urls.join("\n")); return ` 上游 · ${escapeHtml(project.owner)}/${escapeHtml(project.name)}
🔗 ${escapeHtml(project.owner)}/${escapeHtml(project.name)} · 上游
${tabNavHTML("projects")}

${escapeHtml(project.owner)}/${escapeHtml(project.name)} · 上游 Git 仓库

${projectSettingsTabsHTML(project.owner, project.name, "upstreams")} ${flashHtml}

当前绑定的上游(${urls.length})

${rowsHtml}

编辑上游 URL

支持协议:http(s)://ssh://git@host:owner/repo。最多 10 个。空行会被忽略,重复会被去重。
${syncCardHtml(project, sync)}
`; } export function parseUpstreamUrlsForm(text: string): string[] { return text .split(/\r?\n/) .map((line) => line.trim()) .filter((line) => line.length > 0); } export interface UpstreamSyncFormInput { baseUrl: string; upstreamOwner: string; upstreamRepo: string; token?: string; enabled: boolean; pollIntervalMs: number; } export function parseUpstreamSyncForm(form: { get(name: string): string | File | null }): UpstreamSyncFormInput { const val = (name: string): string => { const v = form.get(name); return typeof v === "string" ? v.trim() : ""; }; const intervalRaw = Number.parseInt(val("poll_interval") || "60", 10); const intervalSec = Number.isFinite(intervalRaw) && intervalRaw >= 10 ? intervalRaw : 60; const token = val("token"); return { baseUrl: val("base_url"), upstreamOwner: val("upstream_owner"), upstreamRepo: val("upstream_repo"), token: token.length ? token : undefined, enabled: val("enabled") === "1", pollIntervalMs: intervalSec * 1000, }; } export async function trySetUpstreamSync( projectId: number, input: UpstreamSyncFormInput, ): Promise<{ ok: true } | { ok: false; msg: string }> { try { await upsertUpstreamSync(projectId, input); return { ok: true }; } catch (e) { const msg = e instanceof StoreError ? e.message : e instanceof Error ? e.message : "保存失败"; return { ok: false, msg }; } } export async function trySetUpstreamUrls( projectId: number, raw: string, ): Promise<{ ok: true; urls: string[] } | { ok: false; msg: string }> { try { const urls = parseUpstreamUrlsForm(raw); const cleaned = await setProjectUpstreamUrls(projectId, urls); return { ok: true, urls: cleaned }; } catch (e) { const msg = e instanceof StoreError ? e.message : e instanceof Error ? e.message : "保存失败"; return { ok: false, msg }; } } export { getDefaultUpstreamUrl, webUrlFromClone };