ework-web 0.1.0

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.
@@ -0,0 +1,179 @@
1
+ import type { Config } from "../config";
2
+ import { classifyActor, renderCommentCard, type CommentView } from "../render/components";
3
+ import { renderMarkdown } from "../render/markdown";
4
+ import { renderLayout, escapeHtml } from "../render/layout";
5
+ import { hydrateReactions } from "../reactions";
6
+ import {
7
+ StoreError,
8
+ countComments,
9
+ getProject,
10
+ getIssueWithMeta,
11
+ listCommentsPage,
12
+ listCommentsSince,
13
+ getDefaultUpstreamUrl,
14
+ type CommentRow,
15
+ type IssueWithMeta,
16
+ } from "../store";
17
+ import { webUrlFromClone } from "./projectUpstreams";
18
+
19
+ export const PAGE_SIZE = 30;
20
+
21
+ export interface IssueThreadPayload {
22
+ owner: string;
23
+ repo: string;
24
+ number: number;
25
+ issueTitle: string;
26
+ state: string;
27
+ totalComments: number;
28
+ pageSize: number;
29
+ currentPage: number;
30
+ hasOlder: boolean;
31
+ sinceISO: string;
32
+ commentSort: "desc" | "asc";
33
+ comments: CommentView[];
34
+ }
35
+
36
+ function toView(c: CommentRow): CommentView {
37
+ return {
38
+ id: c.id,
39
+ tag: classifyActor(c.body, c.author_kind),
40
+ login: c.author,
41
+ avatar: "",
42
+ created_at: c.created_at,
43
+ body_html: renderMarkdown(c.body),
44
+ };
45
+ }
46
+
47
+ export function viewsFromComments(rows: CommentRow[]): CommentView[] {
48
+ const views = rows.map((r) => toView(r));
49
+ hydrateReactions(views);
50
+ return views;
51
+ }
52
+
53
+ export function payloadFromComments(
54
+ issue: IssueWithMeta,
55
+ comments: CommentView[],
56
+ page: number,
57
+ hasOlder: boolean,
58
+ commentSort: "desc" | "asc"
59
+ ): IssueThreadPayload {
60
+ const last = comments.length > 0 ? comments[comments.length - 1] : null;
61
+ const sinceISO = last?.created_at || issue.updated_at;
62
+ return {
63
+ owner: issue.project_owner,
64
+ repo: issue.project_name,
65
+ number: issue.number,
66
+ issueTitle: issue.title,
67
+ state: issue.state,
68
+ totalComments: issue.comment_count,
69
+ pageSize: PAGE_SIZE,
70
+ currentPage: page,
71
+ hasOlder,
72
+ sinceISO,
73
+ commentSort,
74
+ comments,
75
+ };
76
+ }
77
+
78
+ // app.js's state model is "items[0] = newest" (DESC). For DESC mode we send the server's
79
+ // ASC-ordered rows reversed; for ASC mode we send them as-is. The displayOrder is then a
80
+ // pure function of commentSort + the canonical DESC internal state.
81
+ function orderForDisplay(views: CommentView[], sort: "desc" | "asc"): CommentView[] {
82
+ return sort === "asc" ? views : views.slice().reverse();
83
+ }
84
+
85
+ export function buildIssueThread(
86
+ cfg: Config,
87
+ owner: string,
88
+ repo: string,
89
+ number: number,
90
+ viewerLogin?: string
91
+ ): { html: string } {
92
+ const project = getProject(owner, repo);
93
+ if (!project) throw new StoreError(404, `项目 ${owner}/${repo} 不存在`);
94
+ const issue = getIssueWithMeta(project.id, number);
95
+ if (!issue) throw new StoreError(404, `#${number} 在 ${owner}/${repo} 不存在`);
96
+
97
+ const total = countComments(issue.id);
98
+ const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
99
+ const currentPage = totalPages;
100
+ const { rows } = listCommentsPage(issue.id, currentPage, PAGE_SIZE);
101
+ const views = viewsFromComments(rows);
102
+ const hasOlder = currentPage > 1;
103
+ const displayViews = orderForDisplay(views, cfg.commentSort);
104
+ const payload = payloadFromComments(issue, displayViews, currentPage, hasOlder, cfg.commentSort);
105
+
106
+ const descriptionHtml = renderMarkdown(issue.body);
107
+ const descriptionCollapsed = issue.body.length > 1200;
108
+ const upstreamWebUrl = (() => {
109
+ const clone = getDefaultUpstreamUrl(project);
110
+ if (!clone) return null;
111
+ return webUrlFromClone(clone);
112
+ })();
113
+
114
+ const html = renderLayout(
115
+ {
116
+ title: `${issue.title} · ${owner}/${repo}#${number}`,
117
+ issueTitle: issue.title,
118
+ repoPath: `${owner}/${repo}`,
119
+ issueNumber: number,
120
+ state: issue.state,
121
+ totalComments: issue.comment_count,
122
+ descriptionHtml,
123
+ descriptionCollapsed,
124
+ writesEnabled: cfg.writesEnabled,
125
+ operatorLogin: viewerLogin ?? cfg.operatorLogin,
126
+ upstreamWebUrl,
127
+ },
128
+ safeJsonEmbed(payload),
129
+ displayViews.map(renderCommentCard).join("")
130
+ );
131
+ return { html };
132
+ }
133
+
134
+ export interface IssuePageData {
135
+ issue: IssueWithMeta;
136
+ views: CommentView[];
137
+ currentPage: number;
138
+ hasOlder: boolean;
139
+ }
140
+
141
+ export function fetchIssuePage(
142
+ owner: string,
143
+ repo: string,
144
+ number: number,
145
+ page: number
146
+ ): IssuePageData {
147
+ const project = getProject(owner, repo);
148
+ if (!project) throw new StoreError(404, `项目 ${owner}/${repo} 不存在`);
149
+ const issue = getIssueWithMeta(project.id, number);
150
+ if (!issue) throw new StoreError(404, `#${number} 不存在`);
151
+ const { rows, page: clamped } = listCommentsPage(issue.id, page, PAGE_SIZE);
152
+ const views = viewsFromComments(rows);
153
+ return { issue, views, currentPage: clamped, hasOlder: clamped > 1 };
154
+ }
155
+
156
+ export function fetchIssueSince(
157
+ owner: string,
158
+ repo: string,
159
+ number: number,
160
+ sinceISO: string
161
+ ): CommentView[] {
162
+ const project = getProject(owner, repo);
163
+ if (!project) throw new StoreError(404, `项目 ${owner}/${repo} 不存在`);
164
+ const issue = getIssueWithMeta(project.id, number);
165
+ if (!issue) throw new StoreError(404, `#${number} 不存在`);
166
+ const rows = listCommentsSince(issue.id, sinceISO);
167
+ return viewsFromComments(rows);
168
+ }
169
+
170
+ export function safeJsonEmbed(v: unknown): string {
171
+ return JSON.stringify(v).replace(/</g, "\\u003c").replace(/\u2028/g, "\\u2028").replace(/\u2029/g, "\\u2029");
172
+ }
173
+
174
+ export function errorPage(title: string, detail: string): string {
175
+ return `<!doctype html><html lang="zh"><head><meta charset="utf-8"><title>${escapeHtml(title)}</title>
176
+ <style>body{font-family:system-ui,sans-serif;background:#1b1b1b;color:#e6e6e6;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
177
+ .box{max-width:560px;padding:2rem}h1{font-size:18px;margin:0 0 .5rem}pre{background:#0d1117;padding:.8rem;border-radius:6px;overflow:auto;white-space:pre-wrap;font-size:12.5px}</style>
178
+ </head><body><div class="box"><h1>${escapeHtml(title)}</h1><pre>${escapeHtml(detail)}</pre></div></body></html>`;
179
+ }
@@ -0,0 +1,66 @@
1
+ import { THEME_CSS, escapeHtml, escapeAttr, containsCI, highlightAll, tabNavHTML } from "../render/layout";
2
+ import { listAllIssues, type IssueWithMeta } from "../store";
3
+ import { relTime } from "../render/components";
4
+
5
+ export const FEED_PAGE_SIZE = 50;
6
+
7
+ function issueRow(it: IssueWithMeta, q: string): string {
8
+ const href = `/${encodeURIComponent(it.project_owner)}/${encodeURIComponent(it.project_name)}/issues/${it.number}`;
9
+ const title = q ? highlightAll(it.title, q) : escapeHtml(it.title);
10
+ const projectStr = `${escapeHtml(it.project_owner)}<span style="opacity:.55">/</span>${escapeHtml(it.project_name)}`;
11
+ return `<a class="row" href="${escapeAttr(href)}">
12
+ <div class="row-title">${title}</div>
13
+ <div class="row-meta">${projectStr} · #${it.number} · 💬 ${it.comment_count} · ${relTime(it.updated_at)}</div>
14
+ </a>`;
15
+ }
16
+
17
+ export function buildIssuesFeed(
18
+ state: "open" | "closed" | "all",
19
+ q: string
20
+ ): string {
21
+ const issues = listAllIssues({ state, q, limit: FEED_PAGE_SIZE });
22
+ const matchesFirst = q
23
+ ? [...issues].sort((a, b) => Number(containsCI(b.title, q)) - Number(containsCI(a.title, q)))
24
+ : issues;
25
+ const openActive = state === "open" ? " active" : "";
26
+ const closedActive = state === "closed" ? " active" : "";
27
+ const allActive = state === "all" ? " active" : "";
28
+ const qParam = q ? `&q=${encodeURIComponent(q)}` : "";
29
+ const rows = matchesFirst.length
30
+ ? matchesFirst.map((it) => issueRow(it, q)).join("")
31
+ : `<div class="empty">暂无工单</div>`;
32
+ return `<!doctype html>
33
+ <html lang="zh"><head><meta charset="utf-8">
34
+ <meta name="viewport" content="width=device-width,initial-scale=1">
35
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
36
+ <title>Issues · 全部</title>
37
+ <style>${THEME_CSS}
38
+ .feed-wrap{max-width:900px;margin:0 auto;padding:.4rem 1rem 3rem}
39
+ .search{margin:.4rem 0 .8rem;display:flex;gap:.4rem}
40
+ .search input{flex:1;background:var(--bg-elev);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:.5rem .7rem;font:inherit}
41
+ .tabs-sub{display:flex;gap:.3rem;margin-bottom:.6rem}
42
+ .tabs-sub a{padding:.3rem .8rem;border-radius:6px;font-size:13px;color:var(--text-muted)}
43
+ .tabs-sub a.active{background:var(--bg-muted);color:var(--text);font-weight:600}
44
+ .row{display:block;padding:.6rem .2rem;border-bottom:1px solid var(--border);color:var(--text)}
45
+ .row:hover{text-decoration:none;background:var(--bg-muted)}
46
+ .row-title{font-weight:500;overflow-wrap:anywhere}
47
+ .row-meta{color:var(--text-muted);font-size:12px;margin-top:.2rem}
48
+ .empty{color:var(--text-muted);text-align:center;padding:2rem;font-size:13px}
49
+ </style></head><body>
50
+ <header class="topbar"><span style="font-weight:600">📦 ework</span></header>
51
+ ${tabNavHTML("issues")}
52
+ <main class="feed-wrap">
53
+ <form class="search" method="GET" action="/issues">
54
+ <input type="text" name="q" value="${escapeAttr(q)}" placeholder="搜索全部项目的标题/正文/项目名…">
55
+ <input type="hidden" name="state" value="${state}">
56
+ <button type="submit" style="background:var(--bg-muted);color:var(--text);border:1px solid var(--border);border-radius:8px;padding:.4rem .8rem;font-size:13px">搜索</button>
57
+ </form>
58
+ <div class="tabs-sub">
59
+ <a class="tab${openActive}" href="/issues?state=open${qParam}">Open</a>
60
+ <a class="tab${closedActive}" href="/issues?state=closed${qParam}">Closed</a>
61
+ <a class="tab${allActive}" href="/issues?state=all${qParam}">All</a>
62
+ </div>
63
+ <div class="rows">${rows}</div>
64
+ </main>
65
+ </body></html>`;
66
+ }
@@ -0,0 +1,146 @@
1
+ import { THEME_CSS, escapeHtml, escapeAttr, tabNavHTML } from "../render/layout";
2
+ import {
3
+ countProjectAdmins,
4
+ listProjectMembersWithUsers,
5
+ listUsers,
6
+ type ProjectMemberWithUser,
7
+ type ProjectRole,
8
+ type ProjectRow,
9
+ type UserRow,
10
+ } from "../store";
11
+ import { projectSettingsTabsHTML } from "./projectUpstreams";
12
+
13
+ interface Flash {
14
+ kind: "ok" | "err";
15
+ msg: string;
16
+ }
17
+
18
+ const ALL_ROLES: ProjectRole[] = ["reader", "writer", "admin"];
19
+
20
+ function roleBadge(role: ProjectRole): string {
21
+ const cls = role === "admin" ? "admin" : role === "writer" ? "writer" : "reader";
22
+ return `<span class="badge role-${cls}">${escapeHtml(role)}</span>`;
23
+ }
24
+
25
+ export function buildProjectMembersPage(
26
+ viewer: UserRow,
27
+ project: ProjectRow,
28
+ flash: Flash | null,
29
+ ): string {
30
+ const members = listProjectMembersWithUsers(project.id);
31
+ const users = listUsers();
32
+ const memberLogins = new Set(members.map((m) => m.user_login));
33
+ const candidates = users.filter((u) => u.is_active === 1 && !memberLogins.has(u.login));
34
+
35
+ const flashHtml = flash ? `<div class="flash ${flash.kind}">${escapeHtml(flash.msg)}</div>` : "";
36
+
37
+ const rowsHtml = members.length
38
+ ? `<table>
39
+ <thead><tr><th>用户</th><th>角色</th><th>加入时间</th><th>操作</th></tr></thead>
40
+ <tbody>
41
+ ${members
42
+ .map((m) => memberRowHtml(m, viewer, project))
43
+ .join("")}
44
+ </tbody>
45
+ </table>`
46
+ : `<div class="hint">该项目还没有成员记录(site-admin 已自动获得 admin 权限,可在下方添加)。</div>`;
47
+
48
+ const candOptions = candidates
49
+ .map((u) => `<option value="${escapeAttr(u.login)}">${escapeHtml(u.login)} (${escapeHtml(u.kind)})</option>`)
50
+ .join("");
51
+ const addFormHtml = candidates.length
52
+ ? `<form class="card" method="POST" action="${escapeAttr(`/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/members/add`)}">
53
+ <h2>添加成员</h2>
54
+ <div class="form-grid">
55
+ <div><label for="n-login">用户</label><select id="n-login" name="login" required>${candOptions}</select></div>
56
+ <div><label for="n-role">角色</label><select id="n-role" name="role">${ALL_ROLES.map((r) => `<option value="${r}"${r === "writer" ? " selected" : ""}>${r}</option>`).join("")}</select></div>
57
+ </div>
58
+ <div class="hint">reader = 只读(预留);writer = 可创建 issue、评论、关闭、上传;admin = 可管理 webhook 和成员。</div>
59
+ <button class="primary" type="submit">添加</button>
60
+ </form>`
61
+ : `<div class="card"><h2>添加成员</h2><div class="hint">没有可添加的候选用户。先在 <a href="/admin/users">用户管理</a> 创建账户。</div></div>`;
62
+
63
+ return `<!doctype html>
64
+ <html lang="zh"><head><meta charset="utf-8">
65
+ <meta name="viewport" content="width=device-width,initial-scale=1">
66
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
67
+ <title>成员 · ${escapeHtml(project.owner)}/${escapeHtml(project.name)}</title>
68
+ <style>${THEME_CSS}
69
+ .flash{padding:.5rem .7rem;border-radius:6px;font-size:13px;margin-bottom:.8rem}
70
+ .flash.ok{background:color-mix(in srgb,var(--green) 18%,transparent);color:var(--green)}
71
+ .flash.err{background:color-mix(in srgb,#f85149 18%,transparent);color:#f85149}
72
+ .wrap{max-width:920px;margin:0 auto;padding:1rem}
73
+ h1{font-size:18px;margin:0 0 .4rem}
74
+ h1 a{color:var(--text)}
75
+ .subtabs{display:flex;gap:.4rem;padding:.4rem 0 0;border-bottom:1px solid var(--border);margin-bottom:.9rem}
76
+ .subtab{padding:.35rem .8rem;border-radius:6px 6px 0 0;font-size:13px;color:var(--text-muted)}
77
+ .subtab.active{background:var(--bg-muted);color:var(--text);font-weight:600}
78
+ .card{background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:.9rem 1rem;margin-bottom:.9rem}
79
+ .card h2{font-size:14px;margin:0 0 .7rem;font-weight:600}
80
+ table{width:100%;border-collapse:collapse;font-size:13px}
81
+ th,td{text-align:left;padding:.55rem .45rem;border-bottom:1px solid var(--border);vertical-align:middle}
82
+ th{color:var(--text-muted);font-weight:600;font-size:12px}
83
+ td.login{font-weight:600}
84
+ td.muted{color:var(--text-muted);font-size:12px;white-space:nowrap}
85
+ td.act{white-space:nowrap}
86
+ td.act form{display:inline-flex;gap:.3rem;align-items:center}
87
+ select{padding:.3rem .4rem;border:1px solid var(--border);border-radius:4px;background:var(--bg);color:var(--text);font:inherit;font-size:12px}
88
+ button.btn-mini,.btn-mini{padding:.3rem .55rem;border:0;border-radius:4px;background:var(--bg-muted);color:var(--text);font:inherit;font-size:12px;cursor:pointer;border:1px solid var(--border)}
89
+ button.btn-mini:hover{border-color:var(--accent)}
90
+ button.btn-danger{padding:.3rem .55rem;border:0;border-radius:4px;background:#f85149;color:#fff;font:inherit;font-size:12px;cursor:pointer}
91
+ button[disabled]{opacity:.4;cursor:not-allowed}
92
+ .badge{font-size:11px;font-weight:600;padding:.1rem .45rem;border-radius:4px;line-height:1.5;background:var(--bg-muted);color:var(--text-muted)}
93
+ .badge.role-admin{background:color-mix(in srgb,var(--system) 18%,transparent);color:var(--system)}
94
+ .badge.role-writer{background:color-mix(in srgb,var(--green) 18%,transparent);color:var(--green)}
95
+ .badge.role-reader{background:var(--bg-muted);color:var(--text-muted)}
96
+ .badge.bot{background:color-mix(in srgb,var(--bot) 18%,transparent);color:var(--bot)}
97
+ .badge.inactive{background:#f85149;color:#fff}
98
+ .hint{color:var(--text-muted);font-size:12px;line-height:1.5;margin:.4rem 0}
99
+ label{display:block;font-size:12px;color:var(--text-muted);margin:0 0 .25rem}
100
+ select{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-size:14px;margin-bottom:.7rem}
101
+ button.primary{padding:.5rem 1rem;border:0;border-radius:6px;background:var(--accent);color:#fff;font-size:13px;cursor:pointer}
102
+ .form-grid{display:grid;grid-template-columns:1fr 1fr;gap:.6rem}
103
+ </style></head><body>
104
+ <header class="topbar"><span style="font-weight:600">👥 ${escapeHtml(project.owner)}/${escapeHtml(project.name)} · 成员</span></header>
105
+ ${tabNavHTML("projects")}
106
+ <main class="wrap">
107
+ <h1><a href="/${escapeAttr(project.owner)}/${escapeAttr(project.name)}/issues">${escapeHtml(project.owner)}/${escapeHtml(project.name)}</a> · 成员</h1>
108
+ ${projectSettingsTabsHTML(project.owner, project.name, "members")}
109
+ ${flashHtml}
110
+
111
+ <div class="card">
112
+ <h2>当前成员(${members.length})</h2>
113
+ ${rowsHtml}
114
+ </div>
115
+
116
+ ${addFormHtml}
117
+ </main></body></html>`;
118
+ }
119
+
120
+ function memberRowHtml(m: ProjectMemberWithUser, viewer: UserRow, project: ProjectRow): string {
121
+ const isSelf = m.user_login === viewer.login;
122
+ const isAdminCountOne = m.role === "admin" && countProjectAdmins(project.id) <= 1;
123
+ const kindBadge = m.user_kind === "bot" ? `<span class="badge bot">bot</span>` : m.user_kind === "system" ? `<span class="badge">system</span>` : "";
124
+ const inactiveBadge = m.user_is_active === 1 ? "" : `<span class="badge inactive">禁用</span>`;
125
+ const selfNote = isSelf ? `<span class="meta">(你)</span>` : "";
126
+ const disableRole = isSelf && isAdminCountOne;
127
+ const disableRemove = isAdminCountOne;
128
+
129
+ const roleSelect = `<form method="POST" action="${escapeAttr(`/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/members/${encodeURIComponent(m.user_login)}/role`)}">
130
+ <select name="role" onchange="this.form.submit()">
131
+ ${ALL_ROLES.map((r) => `<option value="${r}"${r === m.role ? " selected" : ""}>${r}</option>`).join("")}
132
+ </select>
133
+ ${disableRole ? `<span class="meta" title="最后一个 admin 不能改自己">🔒</span>` : ""}
134
+ </form>`;
135
+
136
+ const removeBtn = `<form method="POST" action="${escapeAttr(`/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/members/${encodeURIComponent(m.user_login)}/remove`)}" onsubmit="return confirm('移除成员 ${escapeAttr(m.user_login)}?')">
137
+ <button type="submit" class="btn-danger"${disableRemove ? " disabled title='最后一个 admin 不能移除'" : ""}>移除</button>
138
+ </form>`;
139
+
140
+ return `<tr>
141
+ <td class="login">${escapeHtml(m.user_login)} ${selfNote} ${kindBadge} ${inactiveBadge}</td>
142
+ <td>${roleBadge(m.role)}</td>
143
+ <td class="muted">${escapeHtml((m.created_at || "").slice(0, 10))}</td>
144
+ <td class="act">${roleSelect} ${removeBtn}</td>
145
+ </tr>`;
146
+ }
@@ -0,0 +1,145 @@
1
+ import { THEME_CSS, escapeHtml, escapeAttr, tabNavHTML } from "../render/layout";
2
+ import {
3
+ getDefaultUpstreamUrl,
4
+ getProjectUpstreamUrls,
5
+ setProjectUpstreamUrls,
6
+ StoreError,
7
+ type ProjectRow,
8
+ type UserRow,
9
+ } from "../store";
10
+
11
+ interface Flash {
12
+ kind: "ok" | "err";
13
+ msg: string;
14
+ }
15
+
16
+ export function projectSettingsTabsHTML(
17
+ owner: string,
18
+ name: string,
19
+ active: "webhooks" | "members" | "upstreams",
20
+ ): string {
21
+ const base = `/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/settings`;
22
+ const cls = (which: typeof active) => (active === which ? " active" : "");
23
+ return `<nav class="subtabs">
24
+ <a class="subtab${cls("webhooks")}" href="${escapeAttr(base)}/webhooks">Webhooks</a>
25
+ <a class="subtab${cls("members")}" href="${escapeAttr(base)}/members">成员</a>
26
+ <a class="subtab${cls("upstreams")}" href="${escapeAttr(base)}/upstreams">上游</a>
27
+ </nav>`;
28
+ }
29
+
30
+ // Try to convert a Git clone URL into a clickable web URL. Returns null if the
31
+ // protocol can't be reliably mapped (e.g. SSH SCP form `git@host:o/r`).
32
+ function webUrlFromClone(cloneUrl: string): string | null {
33
+ if (/^https?:\/\//i.test(cloneUrl)) {
34
+ return cloneUrl.replace(/\.git$/i, "");
35
+ }
36
+ if (/^ssh:\/\/([^/]+)\/(.+)$/i.test(cloneUrl)) {
37
+ // ssh://user@host:port/owner/repo → https://host/owner/repo
38
+ const m = cloneUrl.match(/^ssh:\/\/(?:[^@]*@)?([^:/]+)(?::\d+)?\/(.+)$/i);
39
+ if (m && m[1] && m[2]) return `https://${m[1]}/${m[2].replace(/\.git$/i, "")}`;
40
+ }
41
+ return null;
42
+ }
43
+
44
+ function urlRowHtml(url: string, idx: number): string {
45
+ const isDefault = idx === 0;
46
+ const webUrl = webUrlFromClone(url);
47
+ const link = webUrl
48
+ ? `<a href="${escapeAttr(webUrl)}" target="_blank" rel="noopener noreferrer">${escapeHtml(url)}</a>`
49
+ : `<code>${escapeHtml(url)}</code>`;
50
+ return `<tr>
51
+ <td class="idx">${isDefault ? '<span class="badge default">默认</span>' : String(idx + 1)}</td>
52
+ <td class="url">${link}</td>
53
+ </tr>`;
54
+ }
55
+
56
+ export function buildProjectUpstreamsPage(
57
+ _viewer: UserRow,
58
+ project: ProjectRow,
59
+ flash: Flash | null,
60
+ ): string {
61
+ const urls = getProjectUpstreamUrls(project);
62
+ const rowsHtml = urls.length
63
+ ? `<table>
64
+ <thead><tr><th>序</th><th>URL</th></tr></thead>
65
+ <tbody>${urls.map((u, i) => urlRowHtml(u, i)).join("")}</tbody>
66
+ </table>`
67
+ : `<div class="hint">该项目还没有绑定上游 Git 仓库。在下方添加(每行一个 URL,第一个为默认上游)。</div>`;
68
+
69
+ const flashHtml = flash ? `<div class="flash ${flash.kind}">${escapeHtml(flash.msg)}</div>` : "";
70
+ const formAction = `/${encodeURIComponent(project.owner)}/${encodeURIComponent(project.name)}/settings/upstreams`;
71
+ const textareaContent = escapeHtml(urls.join("\n"));
72
+
73
+ return `<!doctype html>
74
+ <html lang="zh"><head><meta charset="utf-8">
75
+ <meta name="viewport" content="width=device-width,initial-scale=1">
76
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
77
+ <title>上游 · ${escapeHtml(project.owner)}/${escapeHtml(project.name)}</title>
78
+ <style>${THEME_CSS}
79
+ .flash{padding:.5rem .7rem;border-radius:6px;font-size:13px;margin-bottom:.8rem}
80
+ .flash.ok{background:color-mix(in srgb,var(--green) 18%,transparent);color:var(--green)}
81
+ .flash.err{background:color-mix(in srgb,#f85149 18%,transparent);color:#f85149}
82
+ .wrap{max-width:920px;margin:0 auto;padding:1rem}
83
+ h1{font-size:18px;margin:0 0 .4rem}
84
+ h1 a{color:var(--text)}
85
+ .subtabs{display:flex;gap:.4rem;padding:.4rem 0 0;border-bottom:1px solid var(--border);margin-bottom:.9rem}
86
+ .subtab{padding:.35rem .8rem;border-radius:6px 6px 0 0;font-size:13px;color:var(--text-muted)}
87
+ .subtab.active{background:var(--bg-muted);color:var(--text);font-weight:600}
88
+ .card{background:var(--bg-elev);border:1px solid var(--border);border-radius:10px;padding:.9rem 1rem;margin-bottom:.9rem}
89
+ .card h2{font-size:14px;margin:0 0 .7rem;font-weight:600}
90
+ table{width:100%;border-collapse:collapse;font-size:13px}
91
+ th,td{text-align:left;padding:.55rem .45rem;border-bottom:1px solid var(--border);vertical-align:middle}
92
+ th{color:var(--text-muted);font-weight:600;font-size:12px}
93
+ td.url{word-break:break-all}
94
+ td.url code{font-family:ui-monospace,monospace;font-size:12px}
95
+ td.idx{width:60px;color:var(--text-muted);white-space:nowrap}
96
+ .badge.default{background:color-mix(in srgb,var(--accent) 18%,transparent);color:var(--accent);font-size:11px;font-weight:600;padding:.1rem .45rem;border-radius:4px}
97
+ .hint{color:var(--text-muted);font-size:12px;line-height:1.5;margin:.4rem 0}
98
+ label{display:block;font-size:12px;color:var(--text-muted);margin:0 0 .25rem}
99
+ 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}
100
+ button.primary{padding:.5rem 1rem;border:0;border-radius:6px;background:var(--accent);color:#fff;font-size:13px;cursor:pointer}
101
+ </style></head><body>
102
+ <header class="topbar"><span style="font-weight:600">🔗 ${escapeHtml(project.owner)}/${escapeHtml(project.name)} · 上游</span></header>
103
+ ${tabNavHTML("projects")}
104
+ <main class="wrap">
105
+ <h1><a href="/${escapeAttr(project.owner)}/${escapeAttr(project.name)}/issues">${escapeHtml(project.owner)}/${escapeHtml(project.name)}</a> · 上游 Git 仓库</h1>
106
+ ${projectSettingsTabsHTML(project.owner, project.name, "upstreams")}
107
+ ${flashHtml}
108
+
109
+ <div class="card">
110
+ <h2>当前绑定的上游(${urls.length})</h2>
111
+ ${rowsHtml}
112
+ </div>
113
+
114
+ <form class="card" method="POST" action="${escapeAttr(formAction)}">
115
+ <h2>编辑上游 URL</h2>
116
+ <label for="f-urls">每行一个 URL;第一个为<strong>默认上游</strong>(webhook payload 的 <code>repository.clone_url</code> 会用它)</label>
117
+ <textarea id="f-urls" name="urls" placeholder="https://gitea.example.com/owner/repo.git&#10;git@github.com:owner/repo.git">${textareaContent}</textarea>
118
+ <div class="hint">支持协议:<code>http(s)://</code>、<code>ssh://</code>、<code>git@host:owner/repo</code>。最多 10 个。空行会被忽略,重复会被去重。</div>
119
+ <button class="primary" type="submit">保存</button>
120
+ </form>
121
+ </main></body></html>`;
122
+ }
123
+
124
+ export function parseUpstreamUrlsForm(text: string): string[] {
125
+ return text
126
+ .split(/\r?\n/)
127
+ .map((line) => line.trim())
128
+ .filter((line) => line.length > 0);
129
+ }
130
+
131
+ export function trySetUpstreamUrls(
132
+ projectId: number,
133
+ raw: string,
134
+ ): { ok: true; urls: string[] } | { ok: false; msg: string } {
135
+ try {
136
+ const urls = parseUpstreamUrlsForm(raw);
137
+ const cleaned = setProjectUpstreamUrls(projectId, urls);
138
+ return { ok: true, urls: cleaned };
139
+ } catch (e) {
140
+ const msg = e instanceof StoreError ? e.message : e instanceof Error ? e.message : "保存失败";
141
+ return { ok: false, msg };
142
+ }
143
+ }
144
+
145
+ export { getDefaultUpstreamUrl, webUrlFromClone };