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

feat(pr): linked issues panel, task progress pill, update-branch button

feat(pr): linked issues panel, task progress pill, update-branch button

- Closing-keyword linked issues sidebar: parses PR title+body for
  "Closes/Fixes/Resolves #N" refs, looks up matching issues, renders
  them as a panel in the conversation tab with open/closed state pills.
- Task list progress pill: counts `- [ ]`/`- [x]` checkboxes in the PR
  body and shows a mini progress bar + fraction in the PR meta row;
  turns green when all tasks are checked.
- "Update branch" button: appears in the PR header when the head branch
  is behind base. POSTs to /pulls/:n/update-branch, which uses a git
  worktree to merge base into head — same pattern as the merge handler.
  Conflicts redirect back with an error; success shows an info pill.

https://claude.ai/code/session_01ACsT2Pc8GRoZwZRF8SK68Y
Claude committed on May 28, 2026Parent: 4800647
1 file changed+22306d1bbc243368627be96cf946c8973ebd59eee2cd
1 changed file+223−0
Modifiedsrc/routes/pulls.tsx+223−0View fileUnifiedSplit
12511251 .trio-grid { grid-template-columns: 1fr; }
12521252 .trio-wrap { padding: 12px; }
12531253 }
1254
1255 /* ─── Task list progress pill ─── */
1256 .prs-tasks-pill {
1257 display: inline-flex; align-items: center; gap: 5px;
1258 font-size: 11.5px; font-weight: 600;
1259 padding: 2px 9px; border-radius: 9999px;
1260 border: 1px solid var(--border);
1261 background: var(--bg-elevated);
1262 color: var(--text-muted);
1263 }
1264 .prs-tasks-pill.is-complete {
1265 color: #34d399;
1266 border-color: rgba(52,211,153,0.40);
1267 background: rgba(52,211,153,0.08);
1268 }
1269 .prs-tasks-progress { display: inline-block; width: 36px; height: 4px; border-radius: 9999px; background: var(--border); overflow: hidden; vertical-align: middle; }
1270 .prs-tasks-progress-bar { height: 100%; border-radius: 9999px; background: #34d399; }
1271
1272 /* ─── Update branch button ─── */
1273 .prs-update-branch-btn {
1274 display: inline-flex; align-items: center; gap: 5px;
1275 padding: 4px 12px; border-radius: 8px; font-size: 12.5px;
1276 font-weight: 600; cursor: pointer;
1277 background: rgba(96,165,250,0.10);
1278 color: #60a5fa;
1279 border: 1px solid rgba(96,165,250,0.30);
1280 transition: background 120ms;
1281 }
1282 .prs-update-branch-btn:hover { background: rgba(96,165,250,0.20); }
1283
1284 /* ─── Linked issues panel ─── */
1285 .prs-linked-issues {
1286 margin-top: 16px;
1287 border: 1px solid var(--border);
1288 border-radius: 12px;
1289 overflow: hidden;
1290 }
1291 .prs-linked-issues-head {
1292 display: flex; align-items: center; justify-content: space-between;
1293 padding: 10px 16px;
1294 background: var(--bg-elevated);
1295 border-bottom: 1px solid var(--border);
1296 font-size: 13px; font-weight: 600; color: var(--text);
1297 }
1298 .prs-linked-issues-count {
1299 font-size: 11px; font-weight: 700;
1300 padding: 1px 7px; border-radius: 9999px;
1301 background: var(--bg-tertiary);
1302 color: var(--text-muted);
1303 }
1304 .prs-linked-issue-row {
1305 display: flex; align-items: center; gap: 10px;
1306 padding: 9px 16px;
1307 border-bottom: 1px solid var(--border);
1308 font-size: 13px;
1309 text-decoration: none; color: inherit;
1310 }
1311 .prs-linked-issue-row:last-child { border-bottom: none; }
1312 .prs-linked-issue-row:hover { background: var(--bg-hover); }
1313 .prs-linked-issue-icon { flex: 0 0 auto; font-size: 14px; }
1314 .prs-linked-issue-icon.is-open { color: #34d399; }
1315 .prs-linked-issue-icon.is-closed { color: #8b949e; }
1316 .prs-linked-issue-title { flex: 1 1 auto; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1317 .prs-linked-issue-num { color: var(--text-muted); font-size: 12px; }
1318 .prs-linked-issue-state { font-size: 11px; font-weight: 600; padding: 1px 7px; border-radius: 9999px; }
1319 .prs-linked-issue-state.is-open { color: #34d399; background: rgba(52,211,153,0.10); }
1320 .prs-linked-issue-state.is-closed { color: #8b949e; background: var(--bg-tertiary); }
12541321`;
12551322
12561323/**
30663133 } catch { /* non-blocking */ }
30673134 }
30683135
3136 // Linked issues — parse closing keywords from PR title+body, look up issues
3137 let linkedIssues: Array<{ number: number; title: string; state: string }> = [];
3138 try {
3139 const { extractClosingRefsMulti } = await import("../lib/close-keywords");
3140 const refs = extractClosingRefsMulti([pr.title, pr.body]);
3141 if (refs.length > 0) {
3142 linkedIssues = await db
3143 .select({ number: issues.number, title: issues.title, state: issues.state })
3144 .from(issues)
3145 .where(and(
3146 eq(issues.repositoryId, resolved.repo.id),
3147 inArray(issues.number, refs),
3148 ));
3149 }
3150 } catch { /* non-blocking */ }
3151
3152 // Task list progress — count markdown checkboxes in PR body
3153 let taskTotal = 0;
3154 let taskChecked = 0;
3155 if (pr.body) {
3156 for (const m of pr.body.matchAll(/^[ \t]*[-*][ \t]+\[([ xX])\]/gm)) {
3157 taskTotal++;
3158 if (m[1].trim() !== "") taskChecked++;
3159 }
3160 }
3161
30693162 // Get diff for "Files changed" tab + load inline comments for that tab
30703163 let diffRaw = "";
30713164 let diffFiles: GitDiffFile[] = [];
32443337 </span>
32453338 )}
32463339 <span>opened {formatRelative(pr.createdAt)}</span>
3340 {taskTotal > 0 && (
3341 <span
3342 class={`prs-tasks-pill${taskChecked === taskTotal ? " is-complete" : ""}`}
3343 title={`${taskChecked} of ${taskTotal} tasks completed`}
3344 >
3345 <span class="prs-tasks-progress" aria-hidden="true">
3346 <span
3347 class="prs-tasks-progress-bar"
3348 style={`width:${Math.round((taskChecked / taskTotal) * 100)}%`}
3349 ></span>
3350 </span>
3351 {taskChecked}/{taskTotal} tasks
3352 </span>
3353 )}
3354 {canManage && pr.state === "open" && branchBehind > 0 && (
3355 <form
3356 method="post"
3357 action={`/${ownerName}/${repoName}/pulls/${pr.number}/update-branch`}
3358 class="prs-inline-form"
3359 >
3360 <button
3361 type="submit"
3362 class="prs-update-branch-btn"
3363 title={`Merge ${pr.baseBranch} into ${pr.headBranch} to bring this branch up to date (${branchBehind} commit${branchBehind === 1 ? "" : "s"} behind)`}
3364 >
3365 ↑ Update branch
3366 </button>
3367 </form>
3368 )}
32473369 <span
32483370 id="live-pill"
32493371 class="live-pill"
34153537 </a>
34163538 )}
34173539
3540 {linkedIssues.length > 0 && (
3541 <div class="prs-linked-issues">
3542 <div class="prs-linked-issues-head">
3543 <span>Closing issues</span>
3544 <span class="prs-linked-issues-count">{linkedIssues.length}</span>
3545 </div>
3546 {linkedIssues.map((issue) => (
3547 <a
3548 href={`/${ownerName}/${repoName}/issues/${issue.number}`}
3549 class="prs-linked-issue-row"
3550 >
3551 <span class={`prs-linked-issue-icon${issue.state === "open" ? " is-open" : " is-closed"}`} aria-hidden="true">
3552 {issue.state === "open" ? "○" : "✓"}
3553 </span>
3554 <span class="prs-linked-issue-title">{issue.title}</span>
3555 <span class="prs-linked-issue-num">#{issue.number}</span>
3556 <span class={`prs-linked-issue-state${issue.state === "open" ? " is-open" : " is-closed"}`}>
3557 {issue.state}
3558 </span>
3559 </a>
3560 ))}
3561 </div>
3562 )}
3563
34183564 {error && (
34193565 <div
34203566 class="auth-error"
37183864 );
37193865});
37203866
3867// Update branch — merge base into head so the PR branch is up to date.
3868// Uses a git worktree so the bare repo stays clean. Write access required.
3869pulls.post(
3870 "/:owner/:repo/pulls/:number/update-branch",
3871 softAuth,
3872 requireAuth,
3873 requireRepoAccess("write"),
3874 async (c) => {
3875 const { owner: ownerName, repo: repoName } = c.req.param();
3876 const prNum = parseInt(c.req.param("number"), 10);
3877 const user = c.get("user")!;
3878 const resolved = await resolveRepo(ownerName, repoName);
3879 if (!resolved) return c.redirect(`/${ownerName}/${repoName}`);
3880
3881 const [pr] = await db
3882 .select()
3883 .from(pullRequests)
3884 .where(and(
3885 eq(pullRequests.repositoryId, resolved.repo.id),
3886 eq(pullRequests.number, prNum),
3887 ))
3888 .limit(1);
3889 if (!pr || pr.state !== "open") {
3890 return c.redirect(`/${ownerName}/${repoName}/pulls/${prNum}`);
3891 }
3892
3893 const repoDir = getRepoPath(ownerName, repoName);
3894 const wt = `${repoDir}/_update_wt_${Date.now()}`;
3895 const gitEnv = {
3896 ...process.env,
3897 GIT_AUTHOR_NAME: user.displayName || user.username,
3898 GIT_AUTHOR_EMAIL: user.email,
3899 GIT_COMMITTER_NAME: user.displayName || user.username,
3900 GIT_COMMITTER_EMAIL: user.email,
3901 };
3902
3903 const addWt = Bun.spawn(
3904 ["git", "worktree", "add", wt, pr.headBranch],
3905 { cwd: repoDir, stdout: "pipe", stderr: "pipe" }
3906 );
3907 if (await addWt.exited !== 0) {
3908 return c.redirect(
3909 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Could not create working tree — branch may be locked")}`
3910 );
3911 }
3912
3913 let ok = false;
3914 try {
3915 const mergeProc = Bun.spawn(
3916 ["git", "merge", "--no-edit", pr.baseBranch],
3917 { cwd: wt, env: gitEnv, stdout: "pipe", stderr: "pipe" }
3918 );
3919 if (await mergeProc.exited === 0) {
3920 ok = true;
3921 } else {
3922 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
3923 }
3924 } catch {
3925 await Bun.spawn(["git", "merge", "--abort"], { cwd: wt }).exited.catch(() => {});
3926 }
3927
3928 await Bun.spawn(
3929 ["git", "worktree", "remove", "--force", wt],
3930 { cwd: repoDir }
3931 ).exited.catch(() => {});
3932
3933 if (ok) {
3934 return c.redirect(
3935 `/${ownerName}/${repoName}/pulls/${prNum}?info=${encodeURIComponent("Branch updated — base merged in successfully")}`
3936 );
3937 }
3938 return c.redirect(
3939 `/${ownerName}/${repoName}/pulls/${prNum}?error=${encodeURIComponent("Update failed — conflicts must be resolved manually")}`
3940 );
3941 }
3942);
3943
37213944// Add comment to PR.
37223945//
37233946// Permission model mirrors `issues.tsx`: any logged-in user with read
37243947