Skip to content

Commit 05d40ef

Browse files
aksOpsclaude
andauthored
refactor: dedupe auth forms, yolo modes, session API preamble + pin actions (#9)
* refactor: dedupe auth forms, yolo modes, session API preamble Targets the duplication blocks Sonar flagged on main. Behaviour unchanged across the board — verified by 692 Go tests and 110 UI tests passing. UI: - LoginForm/SignupForm shared an identical local <Field> input and serverMessage error parser. Lifted to ui/src/components/auth/. - Cuts the 41-line duplicate from each form. Go cmd/yolo.go: - runYoloBang and runSafe shared the same 24-line preamble (serve up, config load, store/tmux clients, name + workdir resolution). Lifted to setupModeContext. - runYolo and runSafe shared the same 24-line resume-or-recreate dance (banner, lifecycle hooks, preflight or kill+recreate). Lifted to runResumeOrRecreate with mode-specific knobs (banner color, hook/serve event names, store-delete log loudness). Go internal/serve/api/: - New handler_helpers.go houses two reused snippets: - requireSessionPreamble (method check + standard JSON headers + {name} extraction + empty-name reject) — Subagents and Teams both used this verbatim. - truncateToolInputField (per-tool primary-input field router for Bash/Edit/Glob/WebFetch/Task) — shortestSubagentInputLabel and summariseHistoryInput shared the get-closure + switch. Workflow hygiene: - pnpm/action-setup pinned to commit SHA in ci.yml + release.yml (the only third-party action). Same hardening already done in sonar.yml; closes the remaining 2 githubactions:S7637 hotspots. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * test(ui): cover serverMessage; defer cmd/yolo.go refactor Two adjustments to satisfy the SonarCloud new-code coverage gate (≥80% on PR-touched lines): - Add ui/src/components/auth/serverMessage.test.ts — covers all five branches (string message hit, non-ApiError, missing message field, non-string message, null/string body). Bumps the file from 50% to 100% new-code coverage. - Revert cmd/yolo.go to main. The setupModeContext + runResumeOrRecreate refactor was correct (692 tests stayed green) but tightly coupled to proc.EnsureServeRunning / preflight / createAndAttach side effects, so the new lines landed uncovered and dragged the gate from 39.9%. This refactor is reattempted in PR-C alongside the cmd/ unit-test scaffolding that lets it be tested cleanly. The api/ + ui/auth dedupes (the bulk of the dup-density wins) stay. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 39346d6 commit 05d40ef

11 files changed

Lines changed: 163 additions & 142 deletions

File tree

.github/workflows/ci.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,10 @@ jobs:
3737
go-version-file: go.mod
3838

3939
- name: Set up pnpm
40-
uses: pnpm/action-setup@v4
40+
# Pinned to commit SHA per supply-chain hygiene — third-party
41+
# actions can be rewritten under a moving tag. Bump by re-running
42+
# `gh api repos/pnpm/action-setup/git/refs/tags/v4`.
43+
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
4144
with:
4245
version: 10.33.0
4346

.github/workflows/release.yml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ jobs:
4141
go-version-file: go.mod
4242

4343
- name: Set up pnpm
44-
uses: pnpm/action-setup@v4
44+
# Pinned to commit SHA per supply-chain hygiene — third-party
45+
# actions can be rewritten under a moving tag. Bump by re-running
46+
# `gh api repos/pnpm/action-setup/git/refs/tags/v4`.
47+
uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4
4548
with:
4649
# Pinned to ui/package.json packageManager so a bumped lockfile
4750
# doesn't desync from the toolchain CI runs the build with.

internal/serve/api/feed_history.go

Lines changed: 2 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -422,23 +422,8 @@ func summariseHistoryInput(raw map[string]any, tool string) string {
422422
if !ok {
423423
return ""
424424
}
425-
get := func(k string) string {
426-
if v, ok := in[k].(string); ok {
427-
return v
428-
}
429-
return ""
430-
}
431-
switch tool {
432-
case "Bash":
433-
return truncateHistory(get("command"))
434-
case "Edit", "Write", "Read", "MultiEdit", "NotebookEdit":
435-
return truncateHistory(get("file_path"))
436-
case "Glob", "Grep":
437-
return truncateHistory(get("pattern"))
438-
case "WebFetch":
439-
return truncateHistory(get("url"))
440-
case "Task":
441-
return truncateHistory(get("description"))
425+
if v, ok := truncateToolInputField(tool, in); ok {
426+
return v
442427
}
443428
body, err := json.Marshal(in)
444429
if err != nil {
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package api
2+
3+
import (
4+
"net/http"
5+
)
6+
7+
// requireSessionPreamble runs the boilerplate every /api/sessions/{name}/...
8+
// JSON GET handler needs: enforce GET/HEAD only, set the standard
9+
// Content-Type + Cache-Control headers, extract the {name} path param, and
10+
// reject an empty name. Returns the session name on success; on failure it
11+
// has already written the error response and the caller should return.
12+
//
13+
// Sonar previously flagged subagents.go, teams.go, etc. as duplicating this
14+
// 16-line block — lifting it removes the copy-paste while keeping each
15+
// handler's mode-specific logic local.
16+
func requireSessionPreamble(w http.ResponseWriter, r *http.Request) (name string, ok bool) {
17+
if r.Method != http.MethodGet && r.Method != http.MethodHead {
18+
w.Header().Set("Allow", "GET, HEAD")
19+
w.Header().Set("Cache-Control", "no-store")
20+
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
21+
return "", false
22+
}
23+
24+
w.Header().Set("Content-Type", "application/json")
25+
w.Header().Set("Cache-Control", "no-store")
26+
27+
name = r.PathValue("name")
28+
if name == "" {
29+
writeJSON(w, http.StatusBadRequest, errorBody{Error: "session name required"})
30+
return "", false
31+
}
32+
return name, true
33+
}
34+
35+
// truncateToolInputField returns the well-known "primary input" string for
36+
// the named tool — Bash → command, Edit/Write/etc. → file_path, Glob/Grep
37+
// → pattern, WebFetch → url, Task → description. Returns ok=false for tools
38+
// the switch doesn't know about so the caller can pick its own fallback
39+
// (the feed-history summariser JSON-marshals the whole input map; the
40+
// subagent label scans description/prompt/query).
41+
//
42+
// Both shortestSubagentInputLabel (subagents.go) and summariseHistoryInput
43+
// (feed_history.go) used to inline the same get-closure + switch — Sonar
44+
// reported the duplicate, this consolidates the per-tool routing.
45+
func truncateToolInputField(tool string, in map[string]any) (string, bool) {
46+
get := func(k string) string {
47+
if v, ok := in[k].(string); ok {
48+
return v
49+
}
50+
return ""
51+
}
52+
switch tool {
53+
case "Bash":
54+
return truncateHistory(get("command")), true
55+
case "Edit", "Write", "Read", "MultiEdit", "NotebookEdit":
56+
return truncateHistory(get("file_path")), true
57+
case "Glob", "Grep":
58+
return truncateHistory(get("pattern")), true
59+
case "WebFetch":
60+
return truncateHistory(get("url")), true
61+
case "Task":
62+
return truncateHistory(get("description")), true
63+
}
64+
return "", false
65+
}

internal/serve/api/subagents.go

Lines changed: 5 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -92,19 +92,8 @@ type subagentsResponse struct {
9292
// the api package.
9393
func Subagents(logDir string, resolver UUIDNameResolver) http.HandlerFunc {
9494
return func(w http.ResponseWriter, r *http.Request) {
95-
if r.Method != http.MethodGet && r.Method != http.MethodHead {
96-
w.Header().Set("Allow", "GET, HEAD")
97-
w.Header().Set("Cache-Control", "no-store")
98-
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
99-
return
100-
}
101-
102-
w.Header().Set("Content-Type", "application/json")
103-
w.Header().Set("Cache-Control", "no-store")
104-
105-
name := r.PathValue("name")
106-
if name == "" {
107-
writeJSON(w, http.StatusBadRequest, errorBody{Error: "session name required"})
95+
name, ok := requireSessionPreamble(w, r)
96+
if !ok {
10897
return
10998
}
11099

@@ -317,27 +306,12 @@ func parseSubagentLine(line []byte) (subagentLineMeta, bool) {
317306
// Mirrors the feed-row summariser's per-tool conventions so a
318307
// subagent expanded in the UI matches the feed rows shown below it.
319308
func shortestSubagentInputLabel(tool string, in map[string]any) string {
320-
get := func(k string) string {
321-
if v, ok := in[k].(string); ok {
322-
return v
323-
}
324-
return ""
325-
}
326-
switch tool {
327-
case "Bash":
328-
return truncateHistory(get("command"))
329-
case "Edit", "Write", "Read", "MultiEdit", "NotebookEdit":
330-
return truncateHistory(get("file_path"))
331-
case "Glob", "Grep":
332-
return truncateHistory(get("pattern"))
333-
case "WebFetch":
334-
return truncateHistory(get("url"))
335-
case "Task":
336-
return truncateHistory(get("description"))
309+
if v, ok := truncateToolInputField(tool, in); ok {
310+
return v
337311
}
338312
// Fallback: any "description"-ish key.
339313
for _, k := range []string{"description", "prompt", "query"} {
340-
if v := get(k); v != "" {
314+
if v, ok := in[k].(string); ok && v != "" {
341315
return truncateHistory(v)
342316
}
343317
}

internal/serve/api/teams.go

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -86,19 +86,8 @@ type teamsResponse struct {
8686
// Teams returns GET /api/sessions/{name}/teams.
8787
func Teams(logDir string, resolver UUIDNameResolver) http.HandlerFunc {
8888
return func(w http.ResponseWriter, r *http.Request) {
89-
if r.Method != http.MethodGet && r.Method != http.MethodHead {
90-
w.Header().Set("Allow", "GET, HEAD")
91-
w.Header().Set("Cache-Control", "no-store")
92-
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
93-
return
94-
}
95-
96-
w.Header().Set("Content-Type", "application/json")
97-
w.Header().Set("Cache-Control", "no-store")
98-
99-
name := r.PathValue("name")
100-
if name == "" {
101-
writeJSON(w, http.StatusBadRequest, errorBody{Error: "session name required"})
89+
name, ok := requireSessionPreamble(w, r)
90+
if !ok {
10291
return
10392
}
10493

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// Shared input field for the LoginForm + SignupForm. Each form previously
2+
// declared an identical local `Field` component — Sonar flagged the block
3+
// as duplicated. Lifted here so both forms keep identical styling without
4+
// drifting.
5+
6+
interface Props {
7+
label: string;
8+
value: string;
9+
onChange: (v: string) => void;
10+
type?: string;
11+
autoComplete?: string;
12+
}
13+
14+
export function AuthField({ label, value, onChange, type = "text", autoComplete }: Props) {
15+
return (
16+
<label className="block">
17+
<span className="mb-1 block text-[11px] font-semibold uppercase tracking-[0.18em] text-fg-muted">
18+
{label}
19+
</span>
20+
<input
21+
type={type}
22+
value={value}
23+
onChange={(e) => onChange(e.target.value)}
24+
autoComplete={autoComplete}
25+
className="block w-full rounded border border-border bg-bg px-2 py-1.5 font-mono text-sm text-fg placeholder:text-fg-dim focus:outline-none focus:ring-1 focus:ring-accent-gold sm:text-xs"
26+
/>
27+
</label>
28+
);
29+
}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { describe, it, expect } from "vitest";
2+
import { ApiError } from "@/lib/api";
3+
import { serverMessage } from "./serverMessage";
4+
5+
describe("serverMessage", () => {
6+
it("returns the body.message when ApiError carries a string message", () => {
7+
const err = new ApiError(400, "bad request", { message: "username already taken" });
8+
expect(serverMessage(err)).toBe("username already taken");
9+
});
10+
11+
it("returns undefined when the error is not an ApiError", () => {
12+
expect(serverMessage(new Error("network"))).toBeUndefined();
13+
expect(serverMessage("oops")).toBeUndefined();
14+
expect(serverMessage(null)).toBeUndefined();
15+
});
16+
17+
it("returns undefined when the body has no message field", () => {
18+
const err = new ApiError(500, "internal", { code: 42 });
19+
expect(serverMessage(err)).toBeUndefined();
20+
});
21+
22+
it("returns undefined when message is non-string", () => {
23+
const err = new ApiError(400, "bad", { message: 123 });
24+
expect(serverMessage(err)).toBeUndefined();
25+
});
26+
27+
it("returns undefined when body is null or non-object", () => {
28+
expect(serverMessage(new ApiError(400, "bad", null))).toBeUndefined();
29+
expect(serverMessage(new ApiError(400, "bad", "string body"))).toBeUndefined();
30+
});
31+
});
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { ApiError } from "@/lib/api";
2+
3+
// Pulls a `message` field out of an ApiError body when the server sent
4+
// one. LoginForm and SignupForm both want this — `await mutateAsync` can
5+
// surface a typed ApiError whose body shape is `{ message?: string }`.
6+
export function serverMessage(e: unknown): string | undefined {
7+
if (e instanceof ApiError && typeof e.body === "object" && e.body !== null) {
8+
const m = (e.body as { message?: unknown }).message;
9+
if (typeof m === "string") return m;
10+
}
11+
return undefined;
12+
}

ui/src/routes/LoginForm.tsx

Lines changed: 4 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import { useState } from "react";
22
import { Button } from "@ossrandom/design-system";
33
import { ApiError } from "@/lib/api";
44
import { useLogin } from "@/hooks/useLogin";
5+
import { AuthField } from "@/components/auth/AuthField";
6+
import { serverMessage } from "@/components/auth/serverMessage";
57

68
interface Props {
79
onSwitchToSignup?: () => void;
@@ -38,8 +40,8 @@ export function LoginForm({ onSwitchToSignup }: Props) {
3840
<div className="mx-auto mt-16 w-full max-w-sm rounded-lg border border-border bg-surface p-6">
3941
<h1 className="mb-4 text-lg font-bold sm:text-xl">Log in to ctm</h1>
4042
<form onSubmit={onSubmit} className="space-y-3">
41-
<Field label="Email" type="email" value={username} onChange={setUsername} autoComplete="email" />
42-
<Field label="Password" type="password" value={password} onChange={setPassword} autoComplete="current-password" />
43+
<AuthField label="Email" type="email" value={username} onChange={setUsername} autoComplete="email" />
44+
<AuthField label="Password" type="password" value={password} onChange={setPassword} autoComplete="current-password" />
4345
{err && (
4446
<div role="alert" className="text-[11px] text-alert-ember">
4547
{err}
@@ -72,40 +74,3 @@ export function LoginForm({ onSwitchToSignup }: Props) {
7274
</div>
7375
);
7476
}
75-
76-
function Field({
77-
label,
78-
value,
79-
onChange,
80-
type = "text",
81-
autoComplete,
82-
}: {
83-
label: string;
84-
value: string;
85-
onChange: (v: string) => void;
86-
type?: string;
87-
autoComplete?: string;
88-
}) {
89-
return (
90-
<label className="block">
91-
<span className="mb-1 block text-[11px] font-semibold uppercase tracking-[0.18em] text-fg-muted">
92-
{label}
93-
</span>
94-
<input
95-
type={type}
96-
value={value}
97-
onChange={(e) => onChange(e.target.value)}
98-
autoComplete={autoComplete}
99-
className="block w-full rounded border border-border bg-bg px-2 py-1.5 font-mono text-sm text-fg placeholder:text-fg-dim focus:outline-none focus:ring-1 focus:ring-accent-gold sm:text-xs"
100-
/>
101-
</label>
102-
);
103-
}
104-
105-
function serverMessage(e: unknown): string | undefined {
106-
if (e instanceof ApiError && typeof e.body === "object" && e.body !== null) {
107-
const m = (e.body as { message?: unknown }).message;
108-
if (typeof m === "string") return m;
109-
}
110-
return undefined;
111-
}

0 commit comments

Comments
 (0)