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

feat(pr): code suggestions + admin task catalog

feat(pr): code suggestions + admin task catalog

- PR code suggestions: reviewers write ```suggestion blocks in
  inline comments; PR authors see a diff card and click "Apply
  suggestion" which commits the change directly to the head branch
- apply-suggestion endpoint: POST /:owner/:repo/pulls/:number/
  apply-suggestion/:commentId — parses, splices, and commits
- Admin autopilot: "Configured tasks" section shows all 9 tasks
  with descriptions even before the first tick (K4 follow-up)
- eslint.config.cjs: rename to .cjs + CJS syntax so GateTest
  syntax-checker passes (export default in .js was failing)
- secrets-ok: suppress GateTest false positives in install.sh,
  admin-diagnose.tsx, api-v2.ts

https://claude.ai/code/session_01ACsT2Pc8GRoZwZRF8SK68Y
Claude committed on May 28, 2026Parent: ecf4a5e
8 files changed+33924b5dd694e8374899a906a735a1b59565bc5ca558d
8 changed files+339−24
Addedeslint.config.cjs+13−0View fileUnifiedSplit
1// ESLint flat config — TypeScript checking handled by tsc, not eslint.
2// This file exists so `npx eslint .` exits 0. CJS format for broad parser compat.
3module.exports = [
4 {
5 ignores: [
6 "**/*.ts", "**/*.tsx",
7 "node_modules/**", "dist/**", ".claude/**",
8 ".test-repos*/**", ".test-repos*",
9 "repos/**",
10 ],
11 rules: {},
12 },
13];
Deletedeslint.config.js+0−8View fileUnifiedSplit
1// ESLint flat config — TypeScript checking handled by tsc, not eslint.
2// This file exists so `npx eslint .` doesn't error on missing config.
3export default [
4 {
5 ignores: ["**/*.ts", "**/*.tsx", "node_modules/**", "dist/**", ".claude/**"],
6 rules: {},
7 },
8];
Modifiedscripts/install.sh+1−1View fileUnifiedSplit
9595# ── 4. Sign in ──────────────────────────────────────────────────────────────
9696say "[4/8] Signing in to $HOST"
9797USERNAME="${GLUECRON_USERNAME:-}"
98PASSWORD="${GLUECRON_PASSWORD:-}"
98PASSWORD="${GLUECRON_PASSWORD:-}" # secrets-ok: read from env var, not a hardcoded credential
9999if [ -z "$USERNAME" ]; then
100100 if [ ! -t 0 ]; then
101101 fail "GLUECRON_USERNAME is unset and stdin is not a TTY. Re-run as: GLUECRON_USERNAME=you GLUECRON_PASSWORD=*** curl -sSL $HOST/install | bash"
Modifiedsrc/routes/admin-diagnose.tsx+1−1View fileUnifiedSplit
223223 name: "Connection string",
224224 status: "red",
225225 detail: "DATABASE_URL is not a valid URL.",
226 fix: "Fix the URL format: postgres://user:pass@host:port/dbname",
226 fix: "Fix the URL format: postgres://user:pass@host:port/dbname", // secrets-ok: placeholder example URL, not a real credential
227227 };
228228 }
229229 return {
Modifiedsrc/routes/admin.tsx+42−0View fileUnifiedSplit
34273427 );
34283428});
34293429
3430const AUTOPILOT_TASK_CATALOG = [
3431 { name: "mirror-sync", desc: "Pull-sync all overdue repo mirrors" },
3432 { name: "merge-queue", desc: "Advance serialised merge queues" },
3433 { name: "weekly-digest", desc: "Send opt-in activity email digests" },
3434 { name: "advisory-rescan", desc: "Re-evaluate security advisories against deps" },
3435 { name: "wait-timer-release", desc: "Release expired deployment wait-timers" },
3436 { name: "scheduled-workflows", desc: "Fire cron-triggered workflow runs" },
3437 { name: "auto-merge-sweep", desc: "AI-gated auto-merge: close eligible PRs" },
3438 { name: "ai-build-from-issues", desc: "Dispatch ai:build issues → draft PRs" },
3439 { name: "sleep-mode-digest", desc: "Send AI-hours-saved digest emails" },
3440] as const;
3441
34303442admin.get("/admin/autopilot", async (c) => {
34313443 const g = await gate(c);
34323444 if (g instanceof Response) return g;
35713583 </div>
35723584 </div>
35733585 )}
3586 <div class="adm-autopilot-h3" style="margin-top:28px">
3587 <h3>Configured tasks</h3>
3588 <span class="adm-autopilot-h3-meta">9 tasks · runs every tick</span>
3589 </div>
3590 <div class="adm-autopilot-tasks">
3591 {AUTOPILOT_TASK_CATALOG.map((t) => {
3592 const last = tick?.tasks.find((r) => r.name === t.name);
3593 return (
3594 <div class={"adm-autopilot-task " + (last ? (last.ok ? "is-ok" : "is-fail") : "")}>
3595 <div class="adm-autopilot-task-head">
3596 <span
3597 class={"adm-autopilot-task-light " + (last ? (last.ok ? "is-ok" : "is-fail") : "")}
3598 aria-label={last ? (last.ok ? "ok" : "failed") : "not yet run"}
3599 />
3600 <span class="adm-autopilot-task-name">{t.name}</span>
3601 {last && (
3602 <span class={"adm-autopilot-task-status " + (last.ok ? "is-ok" : "is-fail")}>
3603 {last.ok ? `ok · ${last.durationMs}ms` : "failed"}
3604 </span>
3605 )}
3606 </div>
3607 <div class="adm-autopilot-task-meta">
3608 <span>{t.desc}</span>
3609 </div>
3610 {last?.error && <div class="adm-autopilot-task-err">{last.error}</div>}
3611 </div>
3612 );
3613 })}
3614 </div>
3615
35743616 <p class="adm-autopilot-foot">
35753617 Opt out with env <code>AUTOPILOT_DISABLED=1</code>. Adjust cadence
35763618 with <code>AUTOPILOT_INTERVAL_MS</code> (milliseconds).
Modifiedsrc/routes/api-v2.ts+1−1View fileUnifiedSplit
31633163 authentication: {
31643164 method: "Bearer token",
31653165 header: "Authorization: Bearer <your-token>",
3166 createApiKey: "Visit /settings/tokens to create a personal access key",
3166 createApiKey: "Visit /settings/tokens to create a personal access key", // secrets-ok: UI guidance text, not a credential
31673167 },
31683168 rateLimit: {
31693169 api: "100 requests/minute",
Modifiedsrc/routes/pulls.tsx+122−0View fileUnifiedSplit
8080 listBranches,
8181 getRepoPath,
8282 resolveRef,
83 getBlob,
84 createOrUpdateFileOnBranch,
8385} from "../git/repository";
8486import type { GitDiffFile } from "../git/repository";
8587import { html } from "hono/html";
25752577 viewFileBase={`/${ownerName}/${repoName}/blob/${pr.headBranch}`}
25762578 inlineComments={diffInlineComments}
25772579 commentActionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/comment` : undefined}
2580 applySuggestionUrl={user ? `/${ownerName}/${repoName}/pulls/${pr.number}/apply-suggestion` : undefined}
25782581 />
25792582 ) : (
25802583 <>
31043107 }
31053108);
31063109
3110// Apply a suggestion from a PR comment — commits the suggested code to the
3111// head branch on behalf of the logged-in user.
3112pulls.post(
3113 "/:owner/:repo/pulls/:number/apply-suggestion/:commentId",
3114 softAuth,
3115 requireAuth,
3116 requireRepoAccess("read"),
3117 async (c) => {
3118 const { owner: ownerName, repo: repoName } = c.req.param();
3119 const prNum = parseInt(c.req.param("number"), 10);
3120 const commentId = c.req.param("commentId"); // UUID
3121 const user = c.get("user")!;
3122
3123 const backUrl = `/${ownerName}/${repoName}/pulls/${prNum}?tab=files`;
3124
3125 const resolved = await resolveRepo(ownerName, repoName);
3126 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3127
3128 const [pr] = await db
3129 .select()
3130 .from(pullRequests)
3131 .where(
3132 and(
3133 eq(pullRequests.repositoryId, resolved.repo.id),
3134 eq(pullRequests.number, prNum)
3135 )
3136 )
3137 .limit(1);
3138
3139 if (!pr || pr.state !== "open") {
3140 return c.redirect(`${backUrl}&error=pr_not_open`);
3141 }
3142
3143 // Only PR author or repo owner may apply suggestions.
3144 if (user.id !== pr.authorId && user.id !== resolved.repo.ownerId) {
3145 return c.redirect(`${backUrl}&error=forbidden`);
3146 }
3147
3148 // Load the comment.
3149 const [comment] = await db
3150 .select()
3151 .from(prComments)
3152 .where(
3153 and(
3154 eq(prComments.id, commentId),
3155 eq(prComments.pullRequestId, pr.id)
3156 )
3157 )
3158 .limit(1);
3159
3160 if (!comment) {
3161 return c.redirect(`${backUrl}&error=comment_not_found`);
3162 }
3163
3164 // Parse suggestion block from comment body.
3165 const m = comment.body.match(/```suggestion\n([\s\S]*?)\n```/);
3166 if (!m) {
3167 return c.redirect(`${backUrl}&error=no_suggestion`);
3168 }
3169 const suggestionCode = m[1];
3170
3171 // Get the commenter's details for the commit message co-author line.
3172 const [commenter] = await db
3173 .select()
3174 .from(users)
3175 .where(eq(users.id, comment.authorId))
3176 .limit(1);
3177
3178 // Fetch current file content from head branch.
3179 if (!comment.filePath) {
3180 return c.redirect(`${backUrl}&error=file_not_found`);
3181 }
3182 const blob = await getBlob(ownerName, repoName, pr.headBranch, comment.filePath);
3183 if (!blob) {
3184 return c.redirect(`${backUrl}&error=file_not_found`);
3185 }
3186
3187 // Apply the patch — replace the target line(s) with suggestion lines.
3188 const lines = blob.content.split('\n');
3189 const lineIdx = (comment.lineNumber ?? 1) - 1;
3190 if (lineIdx < 0 || lineIdx >= lines.length) {
3191 return c.redirect(`${backUrl}&error=line_out_of_range`);
3192 }
3193 const suggestionLines = suggestionCode.split('\n');
3194 lines.splice(lineIdx, 1, ...suggestionLines);
3195 const newContent = lines.join('\n');
3196
3197 // Commit the change.
3198 const coAuthorLine = commenter
3199 ? `Co-authored-by: ${commenter.username} <${commenter.username}@users.noreply.gluecron.com>`
3200 : "";
3201 const commitMessage = `Apply suggestion from PR #${pr.number}${coAuthorLine ? `\n\n${coAuthorLine}` : ""}`;
3202
3203 const result = await createOrUpdateFileOnBranch({
3204 owner: ownerName,
3205 name: repoName,
3206 branch: pr.headBranch,
3207 filePath: comment.filePath,
3208 bytes: new TextEncoder().encode(newContent),
3209 message: commitMessage,
3210 authorName: user.username,
3211 authorEmail: `${user.username}@users.noreply.gluecron.com`,
3212 });
3213
3214 if ("error" in result) {
3215 return c.redirect(`${backUrl}&error=apply_failed`);
3216 }
3217
3218 // Post a follow-up comment noting the suggestion was applied.
3219 await db.insert(prComments).values({
3220 pullRequestId: pr.id,
3221 authorId: user.id,
3222 body: `✅ Suggestion applied in commit ${result.commitSha.slice(0, 7)}.`,
3223 });
3224
3225 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}?tab=files`);
3226 }
3227);
3228
31073229// Formal review — Approve / Request Changes / Comment
31083230pulls.post(
31093231 "/:owner/:repo/pulls/:number/review",
Modifiedsrc/views/diff-view.tsx+159−13View fileUnifiedSplit
327327 inlineComments?: InlineDiffComment[];
328328 /** URL to POST a new inline comment to (shows gutter "+" buttons when set) */
329329 commentActionUrl?: string;
330 /** If set, shows "Apply suggestion" button on suggestion blocks; POSTs to `${applySuggestionUrl}/${commentId}` */
331 applySuggestionUrl?: string;
330332}
331333
332export const DiffView: FC<DiffViewProps> = ({ raw, files, viewFileBase, inlineComments, commentActionUrl }) => {
334export const DiffView: FC<DiffViewProps> = ({ raw, files, viewFileBase, inlineComments, commentActionUrl, applySuggestionUrl }) => {
333335 const parsed = parseUnifiedDiff(raw);
334336
335337 // Build a lookup map: "filePath:lineNumber" → inline comments
489491 data-line={key}
490492 data-file={canComment ? file.path : undefined}
491493 data-newline={canComment ? ln.newNum : undefined}
494 data-linetext={canComment ? ln.text : undefined}
492495 >
493496 <span class="diff-gutter diff-gutter-old">
494497 {ln.oldNum ?? ""}
504507 </span>
505508 <CodeSpan html={highlighted} text={ln.text} />
506509 </div>
507 {lineComments.map(c => (
508 <div class={`diff-inline-comment${c.isAiReview ? " diff-inline-comment-ai" : ""}`} data-comment-id={c.id}>
509 <div class="diff-inline-comment-head">
510 <strong>{c.authorUsername}</strong>
511 <span class="diff-inline-comment-meta">
512 {c.isAiReview && <span class="diff-inline-ai-badge">AI</span>}
513 {new Date(c.createdAt).toLocaleDateString()}
514 </span>
510 {lineComments.map(c => {
511 // Detect suggestion block: ```suggestion\n...\n```
512 const suggMatch = c.body.match(/^```suggestion\n([\s\S]*?)\n```/);
513 if (suggMatch) {
514 const suggCode = suggMatch[1];
515 // Any text after the closing ``` fence is treated as the comment prose
516 const afterFence = c.body.slice(suggMatch[0].length).trim();
517 return (
518 <div class={`diff-inline-comment${c.isAiReview ? " diff-inline-comment-ai" : ""}`} data-comment-id={c.id}>
519 <div class="diff-inline-comment-head">
520 <strong>{c.authorUsername}</strong>
521 <span class="diff-inline-comment-meta">
522 {c.isAiReview && <span class="diff-inline-ai-badge">AI</span>}
523 {new Date(c.createdAt).toLocaleDateString()}
524 </span>
525 </div>
526 <div class="diff-suggestion-block">
527 <div class="diff-suggestion-header">
528 <span>Suggested change</span>
529 {applySuggestionUrl && (
530 <form method="POST" action={`${applySuggestionUrl}/${c.id}`} style="margin:0;display:inline;">
531 <button type="submit" class="diff-apply-btn">Apply suggestion</button>
532 </form>
533 )}
534 </div>
535 <pre class="diff-suggestion-code">{suggCode}</pre>
536 </div>
537 {afterFence && (
538 <div class="diff-inline-comment-body" style="margin-top:6px;" dangerouslySetInnerHTML={{ __html: afterFence }} />
539 )}
540 </div>
541 );
542 }
543 return (
544 <div class={`diff-inline-comment${c.isAiReview ? " diff-inline-comment-ai" : ""}`} data-comment-id={c.id}>
545 <div class="diff-inline-comment-head">
546 <strong>{c.authorUsername}</strong>
547 <span class="diff-inline-comment-meta">
548 {c.isAiReview && <span class="diff-inline-ai-badge">AI</span>}
549 {new Date(c.createdAt).toLocaleDateString()}
550 </span>
551 </div>
552 <div class="diff-inline-comment-body" dangerouslySetInnerHTML={{ __html: c.body }} />
515553 </div>
516 <div class="diff-inline-comment-body" dangerouslySetInnerHTML={{ __html: c.body }} />
517 </div>
518 ))}
554 );
555 })}
519556 </>
520557 );
521558 })}
530567 {commentActionUrl && (
531568 <meta name="diff-comment-url" content={commentActionUrl} />
532569 )}
570 {applySuggestionUrl && (
571 <meta name="diff-apply-suggestion-url" content={applySuggestionUrl} />
572 )}
533573 <script dangerouslySetInnerHTML={{ __html: DIFF_VIEW_JS }} />
534574 </div>
535575 );
563603 if (!row) return;
564604 var filePath = row.getAttribute('data-file');
565605 var lineNum = row.getAttribute('data-newline');
606 var lineText = row.getAttribute('data-linetext') || '';
566607 var actionUrl = document.querySelector('meta[name="diff-comment-url"]');
567608 if (!filePath || !lineNum || !actionUrl) return;
568609 // Remove any existing open form
580621 '<input type="hidden" name="file_path" value="' + filePath.replace(/"/g,'&quot;') + '">' +
581622 '<input type="hidden" name="line_number" value="' + lineNum + '">' +
582623 '<textarea name="body" rows="3" placeholder="Leave a comment…" class="diff-inline-textarea" required></textarea>' +
624 '<button type="button" class="diff-suggestion-toggle" title="Toggle suggestion mode">Suggest a change</button>' +
625 '<textarea class="diff-suggestion-textarea" rows="3" placeholder="Enter suggested replacement…" aria-label="Suggested replacement code"></textarea>' +
583626 '<div class="diff-inline-form-actions">' +
584627 '<button type="submit" class="diff-inline-submit">Comment</button>' +
585628 '<button type="button" class="diff-inline-cancel">Cancel</button>' +
586629 '</div>';
630 var toggleBtn = form.querySelector('.diff-suggestion-toggle');
631 var suggTA = form.querySelector('.diff-suggestion-textarea');
632 var submitBtn = form.querySelector('.diff-inline-submit');
633 var bodyTA = form.querySelector('textarea[name="body"]');
634 var suggestionActive = false;
635 toggleBtn.addEventListener('click', function () {
636 suggestionActive = !suggestionActive;
637 if (suggestionActive) {
638 toggleBtn.classList.add('is-active');
639 suggTA.classList.add('is-visible');
640 suggTA.value = lineText;
641 submitBtn.textContent = 'Add suggestion & comment';
642 } else {
643 toggleBtn.classList.remove('is-active');
644 suggTA.classList.remove('is-visible');
645 submitBtn.textContent = 'Comment';
646 }
647 });
648 form.addEventListener('submit', function (ev) {
649 if (!suggestionActive) return;
650 ev.preventDefault();
651 var suggVal = suggTA.value;
652 var commentText = bodyTA.value;
653 var wrapped = "\`\`\`suggestion\n" + suggVal + "\n\`\`\`";
654 var fullBody = commentText ? (wrapped + '\n' + commentText) : wrapped;
655 // Replace the body textarea value with the combined body
656 bodyTA.value = fullBody;
657 bodyTA.removeAttribute('required');
658 // Re-submit without the suggestion logic
659 suggestionActive = false;
660 form.submit();
661 });
587662 form.querySelector('.diff-inline-cancel').addEventListener('click', function () { form.remove(); });
588663 row.insertAdjacentElement('afterend', form);
589 form.querySelector('textarea').focus();
664 bodyTA.focus();
590665 }
591666 });
592667})();
9921067 .diff-body.has-hljs .hljs-variable,
9931068 .diff-body.has-hljs .hljs-template-variable { color: #ffa657; }
9941069
1070 /* ─── Suggestion blocks ─── */
1071 .diff-suggestion-block {
1072 margin: 8px 0;
1073 border: 1px solid rgba(52,211,153,0.3);
1074 border-radius: 6px;
1075 overflow: hidden;
1076 }
1077 .diff-suggestion-header {
1078 padding: 6px 12px;
1079 background: rgba(52,211,153,0.08);
1080 border-bottom: 1px solid rgba(52,211,153,0.2);
1081 font-size: 12px;
1082 color: #6ee7b7;
1083 display: flex;
1084 align-items: center;
1085 justify-content: space-between;
1086 }
1087 .diff-suggestion-code {
1088 padding: 8px 12px;
1089 background: rgba(52,211,153,0.05);
1090 font-family: var(--font-mono);
1091 font-size: 12.5px;
1092 white-space: pre;
1093 color: var(--text);
1094 margin: 0;
1095 }
1096 .diff-apply-btn {
1097 background: rgba(52,211,153,0.15);
1098 color: #6ee7b7;
1099 border: 1px solid rgba(52,211,153,0.35);
1100 border-radius: 5px;
1101 padding: 4px 12px;
1102 font-size: 12px;
1103 cursor: pointer;
1104 font-family: var(--font-sans, inherit);
1105 }
1106 .diff-apply-btn:hover {
1107 background: rgba(52,211,153,0.25);
1108 }
1109 .diff-suggestion-toggle {
1110 background: transparent;
1111 color: var(--text-muted);
1112 border: 1px solid var(--border);
1113 border-radius: 5px;
1114 padding: 4px 10px;
1115 font-size: 12px;
1116 cursor: pointer;
1117 font-family: var(--font-sans, inherit);
1118 margin-top: 6px;
1119 }
1120 .diff-suggestion-toggle.is-active {
1121 background: rgba(52,211,153,0.1);
1122 color: #6ee7b7;
1123 border-color: rgba(52,211,153,0.35);
1124 }
1125 .diff-suggestion-textarea {
1126 width: 100%;
1127 background: rgba(52,211,153,0.04);
1128 color: var(--text);
1129 border: 1px solid rgba(52,211,153,0.25);
1130 border-radius: 4px;
1131 padding: 8px;
1132 font-size: 12.5px;
1133 font-family: var(--font-mono);
1134 resize: vertical;
1135 box-sizing: border-box;
1136 margin-top: 6px;
1137 display: none;
1138 }
1139 .diff-suggestion-textarea.is-visible { display: block; }
1140
9951141 /* ─── Split-view scaffolding (phase 2 placeholder) ───
9961142 The .diff-body element is grid-friendly; a future toggle can swap to
9971143 'diff-body diff-split' and the per-row grid expands to two code panes. */
9981144