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 ``;
}
// 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
? `
| 序 | URL |
${urls.map((u, i) => urlRowHtml(u, i)).join("")}
`
: `该项目还没有绑定上游 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")}
${projectSettingsTabsHTML(project.owner, project.name, "upstreams")}
${flashHtml}
当前绑定的上游(${urls.length})
${rowsHtml}
${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 };