Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit94c2165unknown_key

Merge pull request #123 from ccantynz-alt/claude/confident-faraday-tikcwb

Merge pull request #123 from ccantynz-alt/claude/confident-faraday-tikcwb

Claude/confident faraday tikcwb
CC LABS App committed on June 17, 2026Parents: 616eec8 57cd4d2
6 files changed+5533194c21656fbf89e50b8d0f1f8099019461084b15b
6 changed files+553−31
Modifiedsrc/lib/ai-review.ts+24−10View fileUnifiedSplit
236236}
237237
238238/**
239 * Has this PR already been reviewed by the AI? Detected by an existing
240 * PR comment carrying our summary marker. Cheap LIKE query — if it
241 * fails (DB hiccup) we fall back to "not yet" and re-review, which is
242 * idempotent at worst (a duplicate summary), never destructive.
239 * Has this PR already been reviewed by the AI for the given head SHA?
240 * Detected by an existing PR comment carrying our summary marker that
241 * also embeds a SHA marker matching the current head. If the stored SHA
242 * differs (new commits pushed) we allow a re-review. If no review
243 * exists yet we also allow it. Cheap LIKE query — if it fails (DB
244 * hiccup) we fall back to "not yet" and re-review, which is idempotent
245 * at worst (a duplicate summary), never destructive.
243246 */
244async function alreadyReviewed(prId: string): Promise<boolean> {
247async function alreadyReviewed(prId: string, headSha: string): Promise<boolean> {
245248 try {
246249 const [row] = await db
247 .select({ id: prComments.id })
250 .select({ body: prComments.body })
248251 .from(prComments)
249252 .where(
250253 and(
254257 )
255258 )
256259 .limit(1);
257 return !!row;
260 if (!row) return false;
261 // Extract the SHA embedded in the comment. If it matches the current
262 // head we skip (same commit reviewed already). If it differs or is
263 // missing we allow re-review (new commits pushed).
264 const shaMatch = row.body.match(/<!-- gluecron-ai-review:sha:([0-9a-f]+) -->/);
265 if (!shaMatch) return false; // Legacy comment without SHA — re-review.
266 return shaMatch[1] === headSha;
258267 } catch {
259268 return false;
260269 }
290299): Promise<void> {
291300 try {
292301 if (!isAiReviewEnabled()) return;
302
303 // Resolve the current head SHA early — needed for SHA-based idempotency
304 // on both the single-Claude and trio paths.
305 const headSha = await resolveHeadSha(ownerName, repoName, headBranch);
306
293307 const useTrio = isTrioReviewEnabled();
294308 if (
295309 !options.force &&
296310 (useTrio
297311 ? await alreadyTrioReviewed(prId)
298 : await alreadyReviewed(prId))
312 : await alreadyReviewed(prId, headSha))
299313 )
300314 return;
301315
356370 // trio helper owns its own persistence + audit; we bail after it.
357371 if (useTrio) {
358372 try {
359 const headSha = await resolveHeadSha(ownerName, repoName, headBranch);
360373 await runTrioReview({
361374 pullRequestId: prId,
362375 headSha,
412425 const verdict = result.approved
413426 ? "**AI review:** no blocking issues found."
414427 : `**AI review:** flagged ${result.comments.length} item(s) for human attention.`;
415 const summaryBody = `${AI_REVIEW_MARKER}\n## AI Code Review\n\n${verdict}\n\n${result.summary}`;
428 const shaMarker = headSha ? `<!-- gluecron-ai-review:sha:${headSha} -->` : "";
429 const summaryBody = `${AI_REVIEW_MARKER}${shaMarker ? `\n${shaMarker}` : ""}\n## AI Code Review\n\n${verdict}\n\n${result.summary}`;
416430 await db
417431 .insert(prComments)
418432 .values({
Modifiedsrc/routes/admin.tsx+188−16View fileUnifiedSplit
2323import { Hono } from "hono";
2424import { and, desc, eq, gte, ilike, or, sql } from "drizzle-orm";
2525import { db } from "../db";
26import { aiCostEvents, auditLog, repositories, users } from "../db/schema";
26import { aiCostEvents, auditLog, issueComments, prComments, repositories, users } from "../db/schema";
2727import { Layout } from "../views/layout";
2828import { softAuth } from "../middleware/auth";
2929import type { AuthEnv } from "../middleware/auth";
677677 color: var(--text-strong);
678678 }
679679 .admin-403 p { color: var(--text-muted); margin: 0; font-size: 14px; }
680
681 /* ─── Automation activity tile ─── */
682 .admin-automation-tile {
683 position: relative;
684 padding: var(--space-4);
685 background: var(--bg-elevated);
686 border: 1px solid var(--border);
687 border-radius: 14px;
688 overflow: hidden;
689 margin-bottom: var(--space-5);
690 }
691 .admin-automation-tile::before {
692 content: '';
693 position: absolute;
694 top: 0; left: 0; right: 0;
695 height: 2px;
696 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
697 opacity: 0.5;
698 pointer-events: none;
699 }
700 .admin-automation-heading {
701 display: flex;
702 align-items: center;
703 gap: 8px;
704 margin-bottom: var(--space-3);
705 }
706 .admin-automation-heading-icon {
707 display: inline-flex;
708 align-items: center;
709 justify-content: center;
710 width: 26px; height: 26px;
711 border-radius: 8px;
712 background: rgba(140,109,255,0.12);
713 color: #b69dff;
714 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
715 }
716 .admin-automation-heading-label {
717 font-family: var(--font-display);
718 font-size: 14px;
719 font-weight: 700;
720 letter-spacing: -0.012em;
721 color: var(--text-strong);
722 }
723 .admin-automation-heading-sub {
724 font-size: 12px;
725 color: var(--text-muted);
726 margin-left: auto;
727 }
728 .admin-automation-grid {
729 display: grid;
730 grid-template-columns: repeat(4, 1fr);
731 gap: var(--space-3);
732 }
733 @media (max-width: 720px) {
734 .admin-automation-grid { grid-template-columns: 1fr 1fr; }
735 }
736 .admin-automation-stat {
737 padding: var(--space-3) var(--space-3);
738 background: rgba(255,255,255,0.02);
739 border: 1px solid var(--border-subtle);
740 border-radius: 10px;
741 display: flex;
742 flex-direction: column;
743 gap: 4px;
744 }
745 .admin-automation-stat-label {
746 font-size: 10.5px;
747 font-weight: 600;
748 letter-spacing: 0.06em;
749 text-transform: uppercase;
750 color: var(--text-muted);
751 }
752 .admin-automation-stat-value {
753 font-family: var(--font-display);
754 font-size: 28px;
755 font-weight: 800;
756 letter-spacing: -0.022em;
757 line-height: 1;
758 color: var(--text-strong);
759 }
760 .admin-automation-stat-hint {
761 font-size: 11px;
762 color: var(--text-faint);
763 }
680764`;
681765
682766/* ─────────────────────────────────────────────────────────────────────────
27132797 if (g instanceof Response) return g;
27142798 const { user } = g;
27152799
2716 const [uc] = await db.select({ n: sql<number>`count(*)::int` }).from(users);
2717 const [rc] = await db
2718 .select({ n: sql<number>`count(*)::int` })
2719 .from(repositories);
2720
2721 const recent = await db
2722 .select({
2723 id: users.id,
2724 username: users.username,
2725 createdAt: users.createdAt,
2726 })
2727 .from(users)
2728 .orderBy(desc(users.createdAt))
2729 .limit(10);
2800 const since24h = new Date(Date.now() - 24 * 60 * 60 * 1000);
27302801
2731 const admins = await listSiteAdmins();
2802 const [[uc], [rc], recent, admins] = await Promise.all([
2803 db.select({ n: sql<number>`count(*)::int` }).from(users),
2804 db.select({ n: sql<number>`count(*)::int` }).from(repositories),
2805 db
2806 .select({
2807 id: users.id,
2808 username: users.username,
2809 createdAt: users.createdAt,
2810 })
2811 .from(users)
2812 .orderBy(desc(users.createdAt))
2813 .limit(10),
2814 listSiteAdmins(),
2815 ]);
2816
2817 // Automation activity stats (last 24h) — wrapped in try/catch so a missing
2818 // table never breaks the dashboard.
2819 let aiReviewsCount = 0;
2820 let autoMergesCount = 0;
2821 let issueTriagedCount = 0;
2822 let ciAutofixCount = 0;
2823 try {
2824 const [aiReviewsRow] = await db
2825 .select({ n: sql<number>`count(*)::int` })
2826 .from(prComments)
2827 .where(
2828 sql`${prComments.isAiReview} = true AND ${prComments.createdAt} > ${since24h}`
2829 );
2830 const [autoMergesRow] = await db
2831 .select({ n: sql<number>`count(*)::int` })
2832 .from(auditLog)
2833 .where(
2834 sql`${auditLog.action} = 'auto_merge.merged' AND ${auditLog.createdAt} > ${since24h}`
2835 );
2836 const [issueTriagedRow] = await db
2837 .select({ n: sql<number>`count(*)::int` })
2838 .from(issueComments)
2839 .where(
2840 sql`${issueComments.body} LIKE '%gluecron:issue-triage%' AND ${issueComments.createdAt} > ${since24h}`
2841 );
2842 const [ciAutofixRow] = await db
2843 .select({ n: sql<number>`count(*)::int` })
2844 .from(auditLog)
2845 .where(
2846 sql`${auditLog.action} LIKE 'ci_autofix%' AND ${auditLog.createdAt} > ${since24h}`
2847 );
2848 aiReviewsCount = Number(aiReviewsRow?.n || 0);
2849 autoMergesCount = Number(autoMergesRow?.n || 0);
2850 issueTriagedCount = Number(issueTriagedRow?.n || 0);
2851 ciAutofixCount = Number(ciAutofixRow?.n || 0);
2852 } catch (_) {
2853 // Stats unavailable — defaults (0) already set above
2854 }
27322855
27332856 const msg = c.req.query("result") || c.req.query("error");
27342857 const isErr = !!c.req.query("error");
28963019 </form>
28973020 </div>
28983021
3022 <div class="admin-h3">
3023 <h3>System Health</h3>
3024 <span class="admin-h3-meta">Ops &amp; security pages</span>
3025 </div>
3026 <div class="admin-actions" style="margin-bottom:var(--space-5)">
3027 <a href="/admin/env-health" class="admin-action is-primary" title="Check which features are enabled via env vars">
3028 <span class="admin-action-icon">{Icons.pulse}</span>
3029 Env health
3030 </a>
3031 <a href="/admin/advancement" class="admin-action" title="Platform advancement tracker">
3032 <span class="admin-action-icon">{Icons.trendingUp}</span>
3033 Advancement
3034 </a>
3035 <a href="/admin/security" class="admin-action" title="Security overview">
3036 <span class="admin-action-icon">{Icons.starShield}</span>
3037 Security
3038 </a>
3039 </div>
3040
3041 <div class="admin-automation-tile">
3042 <div class="admin-automation-heading">
3043 <span class="admin-automation-heading-icon" aria-hidden="true">{Icons.bot}</span>
3044 <span class="admin-automation-heading-label">Automation Activity</span>
3045 <span class="admin-automation-heading-sub">last 24h</span>
3046 </div>
3047 <div class="admin-automation-grid">
3048 <div class="admin-automation-stat">
3049 <div class="admin-automation-stat-label">AI Reviews</div>
3050 <div class="admin-automation-stat-value">{aiReviewsCount}</div>
3051 <div class="admin-automation-stat-hint">PR comments posted</div>
3052 </div>
3053 <div class="admin-automation-stat">
3054 <div class="admin-automation-stat-label">Auto-merges</div>
3055 <div class="admin-automation-stat-value">{autoMergesCount}</div>
3056 <div class="admin-automation-stat-hint">PRs auto-merged</div>
3057 </div>
3058 <div class="admin-automation-stat">
3059 <div class="admin-automation-stat-label">Issues Triaged</div>
3060 <div class="admin-automation-stat-value">{issueTriagedCount}</div>
3061 <div class="admin-automation-stat-hint">AI triage comments</div>
3062 </div>
3063 <div class="admin-automation-stat">
3064 <div class="admin-automation-stat-label">CI Autofixes</div>
3065 <div class="admin-automation-stat-value">{ciAutofixCount}</div>
3066 <div class="admin-automation-stat-hint">Autofix runs</div>
3067 </div>
3068 </div>
3069 </div>
3070
28993071 <div class="admin-h3">
29003072 <h3>Recent signups</h3>
29013073 <span class="admin-h3-meta">
Modifiedsrc/routes/issues.tsx+163−0View fileUnifiedSplit
697697 .bulk-bar-clear { background: none; border: none; color: var(--text-muted); font-size: 12px; cursor: pointer; padding: 4px 8px; }
698698 .bulk-bar-clear:hover { color: var(--text); }
699699
700 /* ─── Issue detail metadata sidebar ─── */
701 .issue-detail-layout {
702 display: flex;
703 align-items: flex-start;
704 gap: 24px;
705 }
706 .issue-detail-main {
707 flex: 1;
708 min-width: 0;
709 }
710 .issue-meta-sidebar {
711 width: 240px;
712 flex-shrink: 0;
713 display: flex;
714 flex-direction: column;
715 gap: 20px;
716 }
717 .issue-meta-section {
718 border-top: 1px solid var(--border);
719 padding-top: 14px;
720 }
721 .issue-meta-section:first-child {
722 border-top: none;
723 padding-top: 0;
724 }
725 .issue-meta-label {
726 font-size: 12px;
727 font-weight: 600;
728 color: var(--text-muted);
729 text-transform: uppercase;
730 letter-spacing: 0.06em;
731 margin-bottom: 8px;
732 }
733 .issue-meta-empty {
734 font-size: 13px;
735 color: var(--text-subtle, var(--text-muted));
736 font-style: italic;
737 }
738 .issue-meta-label-pill {
739 display: inline-flex;
740 align-items: center;
741 padding: 3px 10px;
742 border-radius: 9999px;
743 font-size: 12px;
744 font-weight: 600;
745 line-height: 1.4;
746 margin: 2px 3px 2px 0;
747 border: 1px solid transparent;
748 }
749 .issue-meta-labels {
750 display: flex;
751 flex-wrap: wrap;
752 gap: 4px;
753 }
754 .issue-meta-assignee {
755 display: flex;
756 align-items: center;
757 gap: 8px;
758 font-size: 13px;
759 color: var(--text);
760 }
761 .issue-meta-avatar {
762 width: 24px;
763 height: 24px;
764 border-radius: 9999px;
765 background: rgba(140,109,255,0.25);
766 color: var(--text-strong);
767 font-size: 11px;
768 font-weight: 700;
769 display: inline-flex;
770 align-items: center;
771 justify-content: center;
772 flex-shrink: 0;
773 text-transform: uppercase;
774 }
775 .issue-meta-milestone-link {
776 font-size: 13px;
777 color: var(--text-link, var(--accent));
778 text-decoration: none;
779 display: inline-flex;
780 align-items: center;
781 gap: 5px;
782 }
783 .issue-meta-milestone-link:hover { text-decoration: underline; }
784 @media (max-width: 720px) {
785 .issue-detail-layout { flex-direction: column; }
786 .issue-meta-sidebar { width: 100%; order: 1; }
787 .issue-detail-main { order: 0; }
788 }
789
700790 /* ─── Sort controls (issue list) ─── */
701791 .issues-sort-row {
702792 display: flex;
14401530 (user.id === resolved.owner.id || user.id === issue.authorId);
14411531 const info = c.req.query("info");
14421532
1533 // Labels attached to this issue.
1534 const issueDetailLabels = await db
1535 .select({ id: labels.id, name: labels.name, color: labels.color })
1536 .from(issueLabels)
1537 .innerJoin(labels, eq(issueLabels.labelId, labels.id))
1538 .where(eq(issueLabels.issueId, issue.id));
1539
1540 // Milestone for this issue (if any).
1541 const issueMilestone = issue.milestoneId
1542 ? await db
1543 .select({ id: milestones.id, title: milestones.title })
1544 .from(milestones)
1545 .where(eq(milestones.id, issue.milestoneId))
1546 .limit(1)
1547 .then((rows) => rows[0] ?? null)
1548 : null;
1549
14431550 // Linked PRs — find PRs in this repo whose title or body reference this issue number.
14441551 const issueRefPattern = `%#${issue.number}%`;
14451552 const linkedPrs = await db
15811688 </div>
15821689 </section>
15831690
1691 <div class="issue-detail-layout">
1692 <div class="issue-detail-main">
15841693 <div class="issues-thread">
15851694 {issue.body && (
15861695 <article class="issues-comment">
17101819 </div>
17111820 </form>
17121821 )}
1822 </div>{/* /issue-detail-main */}
1823
1824 {/* Metadata sidebar — Labels, Assignees, Milestone */}
1825 <aside class="issue-meta-sidebar">
1826 {/* Labels */}
1827 <div class="issue-meta-section">
1828 <div class="issue-meta-label">Labels</div>
1829 {issueDetailLabels.length === 0 ? (
1830 <span class="issue-meta-empty">None yet</span>
1831 ) : (
1832 <div class="issue-meta-labels">
1833 {issueDetailLabels.map((lbl) => {
1834 // Compute luminance from hex color to choose dark/light text
1835 const hex = lbl.color.replace("#", "");
1836 const r = parseInt(hex.slice(0, 2), 16) / 255;
1837 const g = parseInt(hex.slice(2, 4), 16) / 255;
1838 const b = parseInt(hex.slice(4, 6), 16) / 255;
1839 const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b;
1840 const textColor = lum > 0.45 ? "#1a1a1a" : "#ffffff";
1841 return (
1842 <span
1843 class="issue-meta-label-pill"
1844 style={`background:${lbl.color};color:${textColor};border-color:${lbl.color}`}
1845 >
1846 {lbl.name}
1847 </span>
1848 );
1849 })}
1850 </div>
1851 )}
1852 </div>
1853
1854 {/* Assignees — field not yet in schema; placeholder */}
1855 <div class="issue-meta-section">
1856 <div class="issue-meta-label">Assignees</div>
1857 <span class="issue-meta-empty">No one assigned</span>
1858 </div>
1859
1860 {/* Milestone */}
1861 <div class="issue-meta-section">
1862 <div class="issue-meta-label">Milestone</div>
1863 {issueMilestone ? (
1864 <a
1865 href={`/${ownerName}/${repoName}/milestones/${issueMilestone.id}`}
1866 class="issue-meta-milestone-link"
1867 >
1868 &#x25CE; {issueMilestone.title}
1869 </a>
1870 ) : (
1871 <span class="issue-meta-empty">No milestone</span>
1872 )}
1873 </div>
1874 </aside>
1875 </div>{/* /issue-detail-layout */}
17131876 </div>
17141877 {/* Issue keyboard hints bar */}
17151878 <div class="kbd-hints" aria-label="Keyboard shortcuts for this issue">
Modifiedsrc/routes/pulls.tsx+12−3View fileUnifiedSplit
59315931 );
59325932 }
59335933
5934 // Check if AI review approved this PR
5934 // Check if AI review approved this PR.
5935 // The AI summary comment body uses:
5936 // "**AI review:** no blocking issues found." → approved
5937 // "**AI review:** flagged N item(s)..." → not approved
5938 // "severity: blocking" → explicit blocking (future)
5939 // If no AI comments exist yet, treat as approved (gate hasn't run).
59355940 const aiComments = await db
59365941 .select()
59375942 .from(prComments)
59415946 eq(prComments.isAiReview, true)
59425947 )
59435948 );
5944 const aiApproved = aiComments.length === 0 || aiComments.some(
5945 (c) => c.body.includes("**Approved**") || c.body.includes("approved: true") || c.body.toLowerCase().includes("lgtm")
5949 const aiSummaryComment = aiComments.find((c) =>
5950 c.body.includes("<!-- gluecron-ai-review:summary -->")
59465951 );
5952 const aiApproved =
5953 !aiSummaryComment ||
5954 (aiSummaryComment.body.includes("no blocking issues found") &&
5955 !aiSummaryComment.body.includes("severity: blocking"));
59475956
59485957 // Run all green gate checks (GateTest + mergeability + AI review)
59495958 const gateResult = await runAllGateChecks(
Modifiedsrc/routes/repo-settings.tsx+138−0View fileUnifiedSplit
499499 .repo-settings-danger-head,
500500 .repo-settings-danger-body { padding-left: var(--space-4); padding-right: var(--space-4); }
501501 }
502
503 /* ─── Settings sidebar layout ─── */
504 .rsettings-layout {
505 display: flex;
506 gap: var(--space-5);
507 align-items: flex-start;
508 }
509 .rsettings-sidebar {
510 width: 220px;
511 flex-shrink: 0;
512 position: sticky;
513 top: var(--space-4);
514 background: var(--bg-elevated);
515 border: 1px solid var(--border);
516 border-radius: 12px;
517 overflow: hidden;
518 }
519 .rsettings-sidebar-label {
520 padding: 10px 14px 8px;
521 font-size: 11px;
522 font-weight: 700;
523 letter-spacing: 0.07em;
524 text-transform: uppercase;
525 color: var(--text-subtle, var(--text-muted));
526 border-bottom: 1px solid var(--border);
527 }
528 .rsettings-nav {
529 display: flex;
530 flex-direction: column;
531 padding: 6px;
532 gap: 2px;
533 }
534 .rsettings-nav-item {
535 display: flex;
536 align-items: center;
537 gap: 8px;
538 padding: 7px 10px;
539 border-radius: 8px;
540 font-size: 13.5px;
541 font-weight: 500;
542 color: var(--text-muted);
543 text-decoration: none;
544 transition: background 100ms ease, color 100ms ease;
545 line-height: 1.3;
546 }
547 .rsettings-nav-item:hover {
548 background: var(--bg-surface, rgba(255,255,255,0.04));
549 color: var(--text);
550 }
551 .rsettings-nav-item.is-active {
552 background: var(--bg-surface, rgba(255,255,255,0.06));
553 color: var(--text);
554 font-weight: 600;
555 }
556 .rsettings-nav-divider {
557 height: 1px;
558 background: var(--border);
559 margin: 4px 6px;
560 }
561 .rsettings-content {
562 flex: 1;
563 min-width: 0;
564 }
565 @media (max-width: 860px) {
566 .rsettings-layout { flex-direction: column; }
567 .rsettings-sidebar {
568 width: 100%;
569 position: static;
570 }
571 .rsettings-nav { flex-direction: row; flex-wrap: wrap; }
572 }
502573`;
503574
504575/** Hero header for the repo settings page. */
547618 );
548619}
549620
621/** Left-hand settings navigation sidebar. */
622function RepoSettingsSidebar(props: { owner: string; repo: string; currentPath: string }) {
623 const { owner, repo, currentPath } = props;
624 const base = `/${owner}/${repo}`;
625
626 type NavItem =
627 | { kind: "link"; label: string; href: string }
628 | { kind: "divider" };
629
630 const items: NavItem[] = [
631 { kind: "link", label: "General", href: `${base}/settings` },
632 { kind: "link", label: "Collaborators", href: `${base}/settings/collaborators` },
633 { kind: "link", label: "Automation", href: `${base}/settings/automation` },
634 { kind: "divider" },
635 { kind: "link", label: "Branch Protection", href: `${base}/gates` },
636 { kind: "link", label: "Protected Tags", href: `${base}/settings/protected-tags` },
637 { kind: "link", label: "Rulesets", href: `${base}/settings/rulesets` },
638 { kind: "divider" },
639 { kind: "link", label: "Webhooks", href: `${base}/settings/webhooks` },
640 { kind: "link", label: "Environments", href: `${base}/settings/environments` },
641 { kind: "link", label: "Pages", href: `${base}/settings/pages` },
642 { kind: "link", label: "Workflow Secrets", href: `${base}/settings/secrets` },
643 { kind: "divider" },
644 { kind: "link", label: "Mirrors", href: `${base}/settings/mirror` },
645 { kind: "link", label: "Dep Updater", href: `${base}/settings/dep-updater` },
646 { kind: "link", label: "Audit Log", href: `${base}/settings/audit` },
647 ];
648
649 return (
650 <nav class="rsettings-sidebar" aria-label="Settings navigation">
651 <div class="rsettings-sidebar-label">Settings</div>
652 <ul class="rsettings-nav" role="list" style="list-style:none;margin:0;padding:6px;">
653 {items.map((item) => {
654 if (item.kind === "divider") {
655 return <li class="rsettings-nav-divider" role="separator" />;
656 }
657 // Exact match for "General" (base settings URL), prefix match for sub-pages
658 const isActive =
659 item.href === `${base}/settings`
660 ? currentPath === `${base}/settings` || currentPath === `${base}/settings/`
661 : currentPath === item.href || currentPath.startsWith(item.href + "/");
662 return (
663 <li>
664 <a
665 href={item.href}
666 class={`rsettings-nav-item${isActive ? " is-active" : ""}`}
667 aria-current={isActive ? "page" : undefined}
668 >
669 {item.label}
670 </a>
671 </li>
672 );
673 })}
674 </ul>
675 </nav>
676 );
677}
678
550679// Settings page
551680repoSettings.get("/:owner/:repo/settings", requireAuth, requireRepoAccess("admin"), async (c) => {
552681 const { owner: ownerName, repo: repoName } = c.req.param();
596725 <style dangerouslySetInnerHTML={{ __html: repoSettingsStyles }} />
597726 <div class="repo-settings-container">
598727 <RepoSettingsHero owner={ownerName} repo={repoName} />
728 <div class="rsettings-layout">
729 <RepoSettingsSidebar
730 owner={ownerName}
731 repo={repoName}
732 currentPath={`/${ownerName}/${repoName}/settings`}
733 />
734 <div class="rsettings-content">
599735
600736 {success && (
601737 <Banner kind="success" text={decodeURIComponent(success)} />
13301466 </form>
13311467 </div>
13321468 </section>
1469 </div>{/* .rsettings-content */}
1470 </div>{/* .rsettings-layout */}
13331471 </div>
13341472 </Layout>
13351473 );
Modifiedsrc/views/components.tsx+28−2View fileUnifiedSplit
173173 | "migrate"
174174 | "deployments"
175175 | "nl-search"
176 | "archaeology";
177}> = ({ owner, repo, active }) => (
176 | "contributors"
177 | "pulse"
178 | "traffic";
179 /** Current authenticated user — used for owner-only tab gating. */
180 currentUser?: string | null;
181 /** Repo owner username — used for owner-only tab gating. */
182 repoOwner?: string;
183}> = ({ owner, repo, active, currentUser, repoOwner }) => (
178184 <div class="repo-nav">
179185 <a href={`/${owner}/${repo}`} class={active === "code" ? "active" : ""}>
180186 Code
227233 >
228234 Releases
229235 </a>
236 <a
237 href={`/${owner}/${repo}/contributors`}
238 class={active === "contributors" ? "active" : ""}
239 >
240 Contributors
241 </a>
242 <a
243 href={`/${owner}/${repo}/pulse`}
244 class={active === "pulse" ? "active" : ""}
245 >
246 Pulse
247 </a>
248 {currentUser && repoOwner && currentUser === repoOwner && (
249 <a
250 href={`/${owner}/${repo}/traffic`}
251 class={active === "traffic" ? "active" : ""}
252 >
253 Traffic
254 </a>
255 )}
230256 <a
231257 href={`/${owner}/${repo}/gates`}
232258 class={active === "gates" ? "active" : ""}
233259