Skip to content

GLOOK-38: configurable per-project Jira boards - #66

Merged
msogin merged 39 commits into
mainfrom
feat/glook-38-research-board
Aug 17, 2026
Merged

GLOOK-38: configurable per-project Jira boards#66
msogin merged 39 commits into
mainfrom
feat/glook-38-research-board

Conversation

@msogin

@msogin msogin commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Closes GLOOK-38.

Ben Loy asked for the LanguageAI Research team (Jira project RND) to appear in Glooker as a board like the other teams', replacing RND board 646. Rather than special-casing Research, this generalises the Projects page: any Jira project can be registered and rendered as a board.

What changed

The /projects board previously served exactly one Jira project, hardcoded through the JIRA_PROJECTS_JQL env var. It's now driven by a new org-scoped jira_projects table, with a project dropdown on the board and a Settings → Projects admin tab for CRUD.

Each project carries its own display name, two named statuses, and a hierarchy mode:

key display_name active_status middle_status hierarchy
SPS Smartling Platform In Progress Rollout goal-initiative (7 cols)
RND LanguageAI Research In Progress Backlog owner (6 cols)

goal-initiative renders the Goal → Initiative → Epic hierarchy SPS uses; owner is for flat projects that don't nest, which is why RND previously rendered an empty table — parentless epics were being dropped. Team is derived from the epic's assignee. Progress rings are now unified on commits for every project, rather than Jira-only for some.

The key design decision

The two configurable tabs generate JQL with an exact named status, never a status category:

active:  project = "<key>" AND issuetype = Epic AND status = "<active_status>"
middle:  project = "<key>" AND issuetype = Epic AND status = "<middle_status>"
done:    project = "<key>" AND issuetype = Epic AND statusCategory = "Done" AND updated >= -30d

This is load-bearing, and measured rather than assumed. On production Jira:

  • status = "In Progress"46 SPS epics
  • statusCategory = "In Progress"71

The extra 25 sit in Discovery, Rollout, Specs & Design and Ready for Dev. Using the category would have silently inflated the existing SPS board by 54%. The done tab is the deliberate exception — a category plus a rolling 30-day window, which also delivers Ben's request that finished work linger ~2 weeks.

Migration

Zero admin action. On first request ensureSeedProject parses the existing JIRA_PROJECTS_JQL into the first row, so a deployment upgrading to this branch keeps its board unchanged. Verified live: SPS still returns exactly 46 epics after migration.

The per-team board_config column from an earlier iteration is dropped in both DB backends. New table added to sqlite.ts, mysql.ts and schema.sql, with no pinned charset — verified the created table inherits the schema default collation (a pinned charset breaks FKs on dev's utf8mb3 database while passing locally; errno 3780).

Security

GET /api/projects is not admin-gated — viewers need to read the board — but it now writes via the seed. Seeding is therefore bounded to orgs already present in teams/reports, so an arbitrary ?org= value can't mint rows. Verified against a running server: bogus orgs return 404 with the row count unchanged.

Project keys and status names are interpolated into quoted JQL literals, so both are validated at save time and re-validated at point of use, which catches rows written directly to the DB. Keys must match /^[A-Z][A-Z0-9_]*$/; statuses reject ", \, \n, \r. That's deliberately a denylist, not a whitelist — Specs & Design and Ready for Dev (v2) are real statuses that must keep working.

Admin routes are gated with requireAdmin, and every handler is wrapped in withRequestLog.

Verification

Deployed locally against real Smartling Jira and MySQL. Every tab count independently confirmed equal to live Jira for the identical JQL:

project active middle done
SPS 46 7 37
RND 12 49 4
  • 115 suites / 1077 tests / 9 snapshots green; tsc --noEmit clean
  • Production container image builds successfully (the real next build gate)
  • Injection attempts via activeStatus and projectKey rejected with 400 by the running server; legitimate Specs & Design accepted

Reviewer notes

  • src/lib/jira-projects/jql.ts is the highest-risk file — the named-status invariant lives there.
  • The branch's early commits implement a superseded per-team board_config design that was later removed. The net diff is the thing to review; board_config survives only as a DROP COLUMN migration.

Known limitation

A project that doesn't use the Epic issue type renders an empty board with no explanation. Found while testing with SLIT, which has zero epics and uses Task/Security/Sub-task instead. Behaves per spec (the board is epic-based by design) but the empty state deserves a follow-up.

🤖 Generated with Claude Code

msogin and others added 30 commits August 14, 2026 16:12
Adds LanguageAI Research (RND) to the Glooker Projects board via a nullable
per-team board_config, so SPS behaviour is unchanged.

Four decisions settled against the mockup: group flat epics by researcher,
swap the Rollout tab for Backlog, keep a 30-day Done window including rejected
hypotheses, and render a Jira-only progress ring.

The 30-day window deliberately differs from the two weeks requested on the
ticket; doneWindowDays is config so it can be reversed without a code change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nine TDD tasks: BoardConfig parse/validate, the teams.board_config column
across all three schema locations, parentless-epic retention plus provenance
attribution, project source resolution and the team JQL builder, API wiring,
mock and seed data, ProgressRing extraction with a jira-only mode,
board-config-driven columns and tabs, and the Settings form.

Corrects the spec on deep-linking: /projects?team= already works via
useUrlState; what was missing is passing the team through to the API.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ProjectSources

This test verifies that two teams both declaring project keys are correctly
filtered by name, catching regressions in the team selection logic.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review finding: the 400 mapping for BoardConfigError in teams/route.ts and
teams/[id]/route.ts had no automated test, so a future refactor of
validateBoardConfig or a reordering of the catch blocks could silently break
it. Adds route-level tests for both handlers: BoardConfigError -> 400 with
the real message, and TeamDuplicateError/TeamNotFoundError still mapping to
409/404 unchanged, guarding catch-block ordering.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…board config

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… pin column parity

A failed /api/projects fetch renders an error banner instead of the tab bar, so
the tab-reset effect returning early on !tabData left the user with no way out.
Newly reachable: ?status=Backlog is a legal URL since ALL_TABS, and the route
404s on it once no team declares jiraProjectKeys — a bookmarked
?team=Research&status=Backlog dead-ends after that config is cleared. Reset to
visibleTabs(null) on error, since without a response there is no config to trust.

Also gate the footer untracked-work count on !boardConfig, matching the rows and
the trigger button — untrackedTeams persists across team switches, so the count
advertised work a configured board never renders.

Also assert widths/headers length parity in both columnLayout cases. Verified the
assertion has teeth: a 7-element owner widths array still summing to 100 passes
the sum check and is caught only by the parity check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ck mode

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…, drop dead code

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reframes the feature from "Research is a special team" to "point the board
at any Jira project". Config moves from teams.board_config to a jira_projects
table; attribution reverts to assignee-only; rings, the Done window and
rejected-work handling all unify to what SPS already does.

Each project names its own tab statuses rather than inferring them from
status categories. Measured against live Jira: SPS In Progress is 46 epics
by status but 71 by category, the extra 25 being Discovery, Rollout,
Specs & Design and Ready for Dev - so category inference would double-list
Rollout epics and surface pre-development work the board excludes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eleven TDD tasks: JiraProject types and validation, the jira_projects table
and service, per-project JQL generation, self-migration from the legacy env
var, provenance removal, the single-project API, CRUD routes, layout retyping
with the ring mode deleted, the board selector, the Settings tab plus the
board-config deletions, and mock/seed.

Carries a git workaround in its constraints: git commit and git status hang
in this checkout, so every task commits via write-tree/commit-tree/update-ref.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JQL literals escape with backslash, so a trailing one escapes the closing quote. Records why the guard stays a two-character denylist rather than a whitelist.
msogin and others added 6 commits August 16, 2026 18:17
…sweep stale comments

Final-review fix wave for the project-boards branch:
- Seed the legacy JIRA_PROJECTS_JQL project from GET /api/jira-projects too,
  not only GET /api/projects, so configuring a project via Settings before
  anyone loads the board no longer permanently strands the SPS seed.
- Gate ensureSeedProject's write on the org existing in teams or reports, so
  an unauthenticated GET /api/projects?org=<anything> can no longer mint
  jira_projects rows for arbitrary org strings.
- Reject interior \n/\r in status names at save time and at point-of-use in
  buildProjectJql, so a broken status surfaces as a 400 on save instead of a
  500 from the board.
- Resolve /api/projects' requested project from the already-fetched
  configured list instead of a second DB round trip.
- Sweep stale comments referencing deleted buildTeamJql/provenance concepts;
  revert a no-op reflow in teams/service.ts back to its merge-base form.
- Add admin-denial tests for PUT/DELETE /api/jira-projects/[id].

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@msogin

msogin commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

🔁 review-loop — 1 loop, 2 personas

Automated multi-lens review (sl-core-dev:review-loop). Intent: safe to merge. Range 5f87682..7d8e3de. Reviewers: Phase 1 minimalist gate, Sr. Architect (holistic), Senior Dev (holistic) — dispatched independently, no shared findings. No fixes applied.

tsc --noEmit clean; 115 suites / 1077 tests pass. Everything below is a logic or scope finding, not a build failure.


Cross-cutting: JIRA_PROJECTS_JQL is half-migrated

The strongest signal in this review isn't any single finding — it's that several of them share one root cause. The env var now carries four distinct meanings:

Location Meaning Still legitimate?
src/app/projects/page.tsx:10 page-level feature gate
src/lib/env-validation.ts:127 required-var warning when JIRA_ENABLED=true
src/lib/projects/untracked.ts:116 untracked-work source
src/lib/jira-projects/seed.ts:9 one-shot migration source

Reducing it to the last two removes C1 below and makes the feature self-describing. Patching page.tsx alone fixes the symptom and leaves the boundary undrawn.

Related: there are two independent sources of truth for "the default project" — the server picks configured[0] (api/projects/route.ts:43), the client independently picks projectList[0] (projects-content.tsx:599). They agree today only because both order by position, project_key. An isDefault/isLegacy flag on the response project object collapses this to one server-side decision and fixes I4 as a side effect.


🔴 Critical

[C1] /projects still hard-gates on the env var this PR replacessrc/app/projects/page.tsx:10

The page renders "JIRA_PROJECTS_JQL is not configured. Set this environment variable to enable the projects view." before ProjectsContent ever mounts. The API layer moved to the jira_projects table; this gate didn't move with it.

A deployment that configures boards entirely through Settings → Projects — the stated point of GLOOK-38 — still sees the env-var error page. Conversely the route's own new 404 copy, 'No Jira projects configured. Add one in Settings → Projects.', is unreachable in exactly the state it describes. An operator who migrates and then unsets the now-legacy var kills a working board.

Gate on JIRA_ENABLED only and let ProjectsContent / GET /api/projects own the "no projects configured" state, which they already render.

Found independently by both code reviewers.


🟡 Important

[I1] Deleting the last configured project silently resurrects itsrc/app/api/jira-projects/route.ts:18, src/lib/jira-projects/seed.ts:36

GET /api/jira-projects runs ensureSeedProject(org) on every call, and that re-seeds whenever listJiraProjects(org) is empty. ProjectsTab.del() calls load() immediately after the DELETE — which is that same GET.

On the current SPS deployment an admin deletes the only row and watches it reappear before the click finishes. Worse: an admin with one manually-created RND (owner hierarchy) row deletes it to reconfigure and gets an SPS row they never asked for — different key, different hierarchy, middleStatus: 'Rollout'. There is no way to reach a zero-project state while the env var is set. No test covers delete-then-list.

Make the migration one-shot rather than state-derived — gate on a marker row or config key instead of existing.length === 0, or run ensureSeedProject only from GET /api/projects and never from the Settings list endpoint.

[I2] Transitioning to any unnamed status pins the epic to the Done tab indefinitelysrc/app/projects/projects-content.tsx:186

targetTab falls back to 'done' for anything that is not activeStatus or middleStatus. The comment asserts Done-category is "the only other kind of transition the board offers", but GET /api/projects/[key]/status returns client.getTransitions(key) unfiltered (route.ts:19), so the dropdown lists every transition in the workflow.

On SPS (active = "In Progress", middle = "Rollout") a user moves an epic to Ready for Dev or Blocked. targetTab resolves to 'done', the entry lands in pendingTransitionsRef, and applyPendingTransitions prepends that epic to the Done tab on every subsequent fetch — Jira will never return it there, so it's re-injected indefinitely, showing status "Blocked" at the top of Done until reload.

This is a regression. Pre-diff, const targetTab = toStatus as StatusTab produced a value matching no tab, so the epic was correctly stripped from all tabs and injected into none.

Resolve to 'done' only when the target is known Done-category — the transitions payload can carry statusCategory — and otherwise record "strip from all tabs, inject nowhere" (e.g. a targetTab: null case in applyPendingTransitions).

[I3] The legacy-JQL migration is an unverified regex against a production-only value, with no fallback and no diagnosticssrc/lib/jira-projects/seed.ts:9

GET /api/projects no longer reads JIRA_PROJECTS_JQL at all. The entire upgrade path runs through parseLegacyJql, which requires exactly project = KEY and status = "Name". Anything else — project in (SPS, TCM), status in ("In Progress","Rollout"), statusCategory = "In Progress", a single-quoted status — returns null, ensureSeedProject no-ops, and the route 404s. A board that worked yesterday returns an error banner, and nothing in the logs explains why: ensureSeedProject logs only on exception, never on a null parse.

Three further concrete cases:

  • 'project = ABC AND issuetype = Epic AND status = "In Dev" AND component = Core' silently loses the component filter — the migrated board shows a strictly wider epic set than the operator configured, with no warning.
  • middleStatus: 'Rollout' is hardcoded for every seeded project. A workflow with no such status gets buildProjectJql emitting status = "Rollout", Jira rejects the field value, the route 500s, and the tab-reset effect bounces the user back to the active tab on every click.
  • env-validation.ts:127 documents the example as … statusCategory = "In Progress" — a form parseLegacyJql deliberately refuses. An operator who followed the documented example gets no migration at all.

⚠️ Verify the deployed JIRA_PROJECTS_JQL value before merge. Independently: seed middleStatus: null (a two-tab board is the honest default for an unknown workflow), log a warning when the parse returns null on a non-empty var, and either refuse to migrate a JQL carrying clauses beyond project/issuetype/status or name the dropped clauses in a warning.

[I4] isLegacyProject infers provenance from list position, which is not a contractsrc/app/projects/projects-content.tsx:599

The guard is project.projectKey === projectList[0].projectKey && project.hierarchy === 'goal-initiative'. The property it's trying to express — "this is the project JIRA_PROJECTS_JQL names", since getUntrackedWork derives its keys from that var (untracked.ts:116, falling back to a hardcoded ['SPS']) — is recorded nowhere.

projectList is ordered by position, project_key, and nothing pins the seeded row to position 0: PUT /api/jira-projects/[id] accepts an arbitrary position, creates use position: projects.length (so delete-and-re-add reorders), and a deployment that never auto-seeded makes whatever the admin adds first "legacy". Concretely — admin adds RND, deletes SPS to fix a typo, re-adds SPS at position 1: RND is now projectList[0], so the "Not in Project" rows, the untracked footer count, and the "load untracked" button all vanish from the SPS board with no error. Untracked work stops being visible anywhere. This is precisely the failure the comment above the guard says it prevents.

Mark the row whose projectKey matches parseLegacyJql(process.env.JIRA_PROJECTS_JQL)?.projectKey server-side and have the client key off that flag.

[I5] The project selector renders blank on every cold loadsrc/app/projects/projects-content.tsx:718

selectedProject defaults to '' (meaning "let the server pick"), but the <select> renders one <option> per project with value={p.projectKey} — none carries value="". React sets select.value = '', no option matches, selectedIndex becomes -1.

Every first visit to /projects without ?project= shows an empty dropdown beside a fully populated board. On single-project deployments it's blank 100% of the time until clicked. With two or more projects the user can't tell which board they're looking at, and selecting the already-displayed first project looks like a no-op. No test covers the selector.

Seed selectedProject from tabData.project.projectKey once the response lands, or render an explicit option for the empty value.

[I6] Dropping the parentTypeName === 'Initiative' filter is a global behaviour change, not a per-project onesrc/lib/projects/service.ts:55 · needs author confirmation

fetchProjectEpics used to drop every epic without an Initiative parent; it now keeps all of them unconditionally. The signature was reformatted but takes no hierarchy argument, so the goal-initiative board gets flat-board semantics too.

The existing production board's row set changes on merge — epics with no parent, and epics whose parent is some other type, now appear under the goal bucket. The jira_projects row already carries hierarchy, the one piece of config that should decide this, and it never reaches this function.

Is the widened SPS board intended? If this is only wanted for hierarchy: 'owner', pass the project (or a keepParentless flag) into fetchProjectEpics.


🔵 Suggestions

[S1] DROP COLUMN board_config will fail on every startup forever, and swallows every errorsrc/lib/db/mysql.ts:320, src/lib/db/sqlite.ts:321

board_config does not exist at the merge base — git grep board_config 5f87682 -- src schema.sql is empty. The column was added in d4d0a54 and dropped in 0d6b78d, both inside this branch; no released deployment ever had it. Every real DB will now run a DDL that always fails, on every boot, permanently.

The MySQL variant is .catch(() => {}), unlike every other ALTER in that file, which inspects err.code before logging — so it also swallows connection and permission errors. That is the exact silent-DDL-failure mode CLAUDE.md calls out from the 2026-08-11 outage. jira-projects-schema.test.ts:26 then asserts the dead statement is present, pinning it in place.

Drop both statements and the test that pins them. If they must stay for dev machines that ran the intermediate commits, at minimum match the file's convention and log anything that is not ER_CANT_DROP_FIELD_OR_KEY.

[S2] jira_projects mutations are keyed on a bare id and never see an orgsrc/lib/jira-projects/service.ts:68, :88

The table's real identity is (org, project_key), but updateJiraProject(id, …) and deleteJiraProject(id) take only the UUID and neither route passes an org — so the boundary the schema enforces can't be enforced by the service, is invisible in the type signature, and is impossible to test for. Harmless while AUTH_ADMIN_GROUP grants global admin; it becomes an authorization gap the moment admin scope goes per-org. deleteJiraProject also returns success unconditionally while PUT 404s on a missing row, and duplicate detection relies on err.message.includes('UNIQUE'), which also matches an id collision.

Thread org through both service functions and both routes, WHERE id = ? AND org = ?, and 404 the delete when no row matched.

[S3] ensureSeedProject is a migration wearing a request handlersrc/lib/jira-projects/seed.ts:43

It costs a SELECT on every board request forever, and /api/projects then repeats listJiraProjects(org) immediately after — two identical queries per request. The repo already has a migration seam in db/mysql.ts / db/sqlite.ts; in the read path it never retires and there's nowhere to see whether it ran. The org-existence guard mitigating write-on-GET is thoughtful, but the shape is wrong. Acceptable as an interim — worth a follow-up.

[S4] Status colours are still hardcoded to the SPS vocabulary the PR set out to removesrc/app/projects/projects-content.tsx:1042, :1051

The status dot and transition dropdown still switch on the literals 'Done', 'Rollout', 'In Progress'. Tabs, labels and JQL now read in each project's own vocabulary; the colour mapping doesn't, so a board configured with Backlog / Rejected renders every row grey. Same hidden coupling this change otherwise removes, in the last place it survives.

Derive from project.activeStatus / project.middleStatus / everything-else-is-done — the same three-way targetTab resolution already uses.

[S5] projects-api-board.test.ts asserts through a mock the route no longer callssrc/lib/__tests__/unit/projects-api-board.test.ts:5, :18, :45

The route resolves the project from the already-fetched configured list, so getJiraProject is now called by nothing in src/ outside its own tests — yet several cases still set mockGet.mockResolvedValue(RND) / mockResolvedValue(null) as their stated mechanism. it('404s for an unknown project key') passes because 'NOPE' is absent from mockList, not because mockGet returned null: swapping the route back to a getJiraProject lookup that returns a row for NOPE would leave this test green while the route 200s.

Drop the getJiraProject mock and drive every case from mockList. Either delete getJiraProject from service.ts or wire it to a caller.

[S6] ProjectEpic.projectKey is unused and adds a second derivation of project identitysrc/lib/projects/service.ts:66

projectKey: epic.key.split('-')[0] is added to the shared ProjectEpic shape and covered by a test, but nothing consumes it — the client's own ProjectEpic doesn't even declare it, and isLegacyProject uses the jira_projects row's key instead. It establishes a string-splitting derivation of "which project is this" alongside the authoritative jira_projects.project_key, with no caller keeping the two honest.

[S7] ProjectsTab.del() ignores the response statussrc/app/settings/projects-tab.tsx:294

await fetch(url, { method: 'DELETE' }).catch(() => {}) — neither res.ok nor a network error reaches the user, and the confirmation panel closes either way. An admin whose session lapsed to viewer clicks Delete, gets a 403, sees the panel close as if it worked, and the row reappears via load() — indistinguishable from the resurrection in I1, so neither failure can be diagnosed from the UI.

Check res.ok and route through the existing setError path, as save() already does.

[S8] Pre-PR tab URLs silently render the wrong tabsrc/app/api/projects/route.ts:19 · defer

The status param vocabulary changed from In Progress/Rollout/Done to active/middle/done, and both the route (TABS.includes(...) ? ... : 'active') and readValue's enum branch fall back to the default instead of rejecting. A teammate opening a previously shared /projects?org=X&status=Done link sees In Progress epics while the address bar still reads status=Done, with nothing indicating the parameter was ignored.

[S9] docs/projects-page.md still documents the env var as the configuration mechanism:12, :22, :168 · defer

It describes JIRA_PROJECTS_JQL as required and as the query the board runs; neither is true after this change. It's the only prose description of this subsystem and it's now actively misleading about how to configure it.


📉 Phase 1 minimalist gate: Bloat

Scope, not correctness. 6,871 of 9,446 lines (73%) are planning artifacts, and 46% of the total diff documents an approach this same PR deletes.

  1. Superseded-design artifacts committed as-is. docs/superpowers/plans/2026-08-14-glook-38-research-board.md (2,977 lines) plans the teams.board_config per-team design. b4c90d9 supersedes that design; 70d72bf deletes its code. The plan stays. Repo convention across 38 prior plans is one plan per ticket, largest 1,787 lines — this is two, and the first is for a design that no longer exists. mockups/glook-38-research-board.html (1,338 lines) creates a new top-level mockups/ directory with no precedent in the base tree, and this PR's own spec labels it: "mockups/glook-38-research-board.htmlstale, shows the superseded design." A file the author annotates as stale in the same PR that adds it isn't scope. The 08-14 spec was correctly added and deleted within the branch; the plan and mockup should have followed it.

  2. Migration for a column that never shipped — see S1 above.

  3. Legacy-JQL auto-seed (seed.ts, 69 lines + 131 test lines). Justified as "so an existing deployment keeps its board without operator action" — but this PR ships settings/projects-tab.tsx, where that same row is three text fields. The second call site is justified purely on an ordering hypothetical. Cost carried forever: an extra SELECT on every board and Settings load, plus a security caveat that exists only because a one-time migration was wired into two read paths.

  4. Refactors that ride along. progress-ring.tsx (83 lines) + its test (82 lines): fe32af5 extracted it "and add a jira-only ring mode"; a4dd5f2 removed the ring mode — what remains is a no-behaviour-change extraction plus a new test, unrelated to configurable boards. The TeamsTab.save() if/else collapse in settings/page.tsx is a drive-by in a different tab. board-layout.ts's hierarchy: 'owner' mode (111 + 132 test lines) is a second rendering mode — the load-bearing fix for flat projects is keeping parentless epics in projects/service.ts.

Scoped to intent: schema.sql + CREATE TABLE jira_projects in both dialects (no DROP) + jira-projects/{types,service,jql}.ts + the two API routes + the /api/projects rewiring + the project selector + settings/projects-tab.tsx + keeping parentless epics — with tests on validation, JQL construction, the CRUD API, and board resolution. One plan doc, one spec. Roughly 1,900–2,200 lines instead of 9,446.

Reviewing "safe to merge" against 9,446 lines where three quarters is prose — including 4,315 lines describing a design this PR deletes — spends the reviewer's attention where the risk is not.


Verdict

Reviewer Verdict
Phase 1 minimalist gate Bloat — hand back for rescope
Sr. Architect With fixes
Senior Dev No — with fixes

Not safe to merge as-is. C1 makes the headline capability unreachable without the very env var it deprecates. I1 and I2 are user-visible wrong-state bugs on paths this PR adds, and I2 is a regression against base. I3 is an unverified one-shot migration against a production-only value with no diagnostics. The JQL-building, validation, and schema work is solid and well covered — including a properly closed injection surface at both write and build time — and the gaps are concentrated in the migration path and client-side board state.

🤖 Generated with Claude Code · sl-core-dev:review-loop

@msogin

msogin commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

🔎 Smartling code review

Automated review using the centralized Smartling/claude-pr-review prompts (sl-core-dev:claude-pr-review-local) — same prompts the org workflow runs. Range 5f87682..7d8e3de. Profile: fullstack.md — the repo has no codebaseType/serviceTier custom properties and no .github/claude-code-review.json, so this is the fallback prompt. 14 suites / 163 affected tests green.

Issues

🔴 Critical

src/app/projects/page.tsx:10-17 — The Projects page still hard-gates on the legacy JIRA_PROJECTS_JQL env var that this PR otherwise replaces. A deployment that configures projects in Settings → Projects but never sets that variable renders "JIRA_PROJECTS_JQL is not configured. Set this environment variable to enable the projects view." and never reaches ProjectsContent. The headline use case of GLOOK-38 — configure boards from Settings, no env var — is unreachable. Related: src/lib/env-validation.ts:127 still lists JIRA_PROJECTS_JQL as required whenever JIRA_ENABLED=true, so startup also warns for a correctly-configured deployment.

// page.tsx — drop the env gate; let the API's 404 drive the empty state
export default function ProjectsPage() {
  if (process.env.JIRA_ENABLED !== 'true') notFound();
  return <ProjectsContent />;
}

🟡 Warning

src/app/projects/projects-content.tsx:76-81 + :718-726 — The project selector renders blank on first load. selectedProject defaults to '', no <option> carries that value, and nothing ever syncs it from the resolved project — so select.selectedIndex is -1 while the board below shows the server-chosen configured[0]. Sync it once the response lands (in the populate effect at :409-416), or render a placeholder option:

if (tabData?.project && !selectedProject) setSelectedProject(tabData.project.projectKey);

src/app/projects/projects-content.tsx:187-190targetTab treats any status that is neither activeStatus nor middleStatus as Done. SPS really has Discovery, Specs & Design and Ready for Dev (per the comment in jira-projects/types.ts), so transitioning an epic to one of those prepends it to the Done tab and pins it there via pendingTransitionsRef for the rest of the session — Jira will never return it, so applyPendingTransitions re-injects it on every fetch. Prefer returning statusCategory from the PATCH /status response and only recording a pending transition when the destination maps to a visible tab; otherwise skip the optimistic move and let the next fetch decide.

src/app/projects/projects-content.tsx:124, :470-485, :544-557ringStats is never cleared on org/project switch, so maxVolume and avgCommitsPerJira are computed across every project viewed in the session. A small research board's rings get scaled against a large platform board's max volume, and the "% of expected commits" arc mixes two projects' rates. Clear it alongside pendingTransitionsRef in the effect at :402-404.

src/lib/swr-provider.tsx:6-9 + projects-content.tsx:703 — The global fetcher throws new Error(\${r.status}`), discarding the response body. The API's deliberately-worded { error: 'No Jira projects configured. Add one in Settings → Projects.' } (api/projects/route.ts:31-36) surfaces to the user as Error: 404. This is now the *normal* first-run state for a fresh deployment, and seed.ts`'s comment explicitly claims the user falls through to that message. Parse the JSON error in the fetcher, or pass a local fetcher to this hook.

src/lib/jira-projects/seed.ts:35 + src/app/api/jira-projects/route.ts:18GET /api/jira-projects has no auth check at all and performs a DB INSERT as a side effect. The org gating (teams/reports lookup) bounds the blast radius, and the comment reasons about it well, but a GET should stay safe. Consider running the migration once at startup (instrumentation.ts) or only from the admin-gated POST.

src/app/projects/projects-content.tsx:591-604 — The comment says untracked work is "only meaningful on the project that variable [JIRA_PROJECTS_JQL] names", but the code compares against projectList[0].projectKey — whichever project sorts first by position. Reordering or deleting projects in Settings silently moves the "work outside projects" block onto an unrelated project's board. Compare against parseLegacyJql(JIRA_PROJECTS_JQL)?.projectKey, exposed via the API response, instead of list position.

src/lib/projects/service.ts:52-75 + projects-content.tsx:470-485 — Keeping parentless epics is correct, but it materially increases board size (SPS ~46, RND ~25), and each epic triggers its own /api/projects/{key}/stats request with no concurrency cap, no abort on unmount, and no cancellation on project switch. Consider a batched ?keys= endpoint or a small concurrency limiter.

Documentationdocs/projects-page.md is untouched and now wrong: line 12 says the page shows "not configured" without JIRA_PROJECTS_JQL, line 22 says the JQL drives the search, line 168 lists it as required. CLAUDE.md also gains no note about the jira_projects table or the Settings → Projects tab, despite its own convention of documenting new tables and features.

🔵 Suggestion

src/app/projects/projects-content.tsx:1040-1053, :1082-1084 — Status dot colours are still hardcoded to 'Done' / 'Rollout' / 'In Progress'. On a project whose statuses are named anything else, every dot renders grey. Derive from project.activeStatus / project.middleStatus the way tabLabel() already does.

src/app/projects/projects-content.tsx:16-25 — The local ProjectEpic interface duplicates the one in src/lib/projects/service.ts and is already out of sync (missing the new projectKey). Use a type-only import, as the file already does for EpicSummaryResult at :10.

src/app/api/projects/route.ts:29-31ensureSeedProject(org) runs listJiraProjects(org) internally, then the route runs the identical query again. Have ensureSeedProject return the list so each board request does one SELECT instead of two.

src/app/api/jira-projects/[id]/route.ts:18, :45-46await req.json() sits outside the try, so malformed JSON yields an unhandled 500 rather than a 400; deleteHandler returns { deleted: true } even when no row matched; and neither updateJiraProject nor deleteJiraProject scopes by org (admin role is global, so low risk today, but the list/create paths are org-scoped and these aren't).

src/app/settings/projects-tab.tsx:26-31, :83-88load() and del() swallow every error. A DELETE that 403s or 500s silently closes the confirm dialog, reloads, and the row reappears with no explanation. Surface the failure through the existing error state, as save() does.

src/app/settings/projects-tab.tsx:59position: projects.length collides after a delete (delete #1 of 3, add a new one → two rows at position 2), and there is no reorder UI. Position decides which project is the board's default and which one hosts the untracked-work block.

src/lib/db/mysql.ts:320 / src/lib/db/sqlite.ts:321ALTER TABLE teams DROP COLUMN board_config is a permanent, unconditional DDL for a column that only ever existed inside this PR's intermediate commits (added in d4d0a54, dropped in 0d6b78d); it never reached main. It will fail-and-swallow on every startup forever. Consider dropping the migration and the test that pins it. Separately, the MySQL .catch(() => {}) swallows all errors, unlike every sibling migration in that block which checks err.code.

src/lib/__tests__/unit/projects-api-board.test.ts — The suite mocks getJiraProject and sets return values per test, but the route no longer calls it (it resolves from the already-fetched configured list at route.ts:42-43). "404s for an unknown project key" and "builds the named project active tab" pass for reasons unrelated to their mockGet setup. Remove the dead mock so the tests can't drift into passing vacuously.

🟣 Question

src/lib/jira-projects/jql.ts:12DONE_WINDOW_DAYS = 30 is a module constant while every other tab dimension became per-project. Deliberate, or a follow-up?

src/app/projects/projects-content.tsx:91-114 — Team/goal/initiative/search filters aren't reset when the project changes, so a goal filter from one board carries into another and yields "No epics match the selected filters" with no obvious cause. Intended?

Recommendations

  1. The legacy env var straddle needs one owner. JIRA_PROJECTS_JQL is now read in four places with three different meanings: a page-level feature gate (page.tsx), a required-var warning (env-validation.ts), the untracked-work source (lib/projects/untracked.ts), and the one-shot migration source (jira-projects/seed.ts). Only the last two are still legitimate. Reducing it to "untracked work's config, plus a migration seed" would remove the 🔴 above and make the feature self-describing.

  2. Two independent sources of truth for "the default project." The server picks configured[0] (api/projects/route.ts:43); the client independently picks projectList[0] for isLegacyProject. They agree today only because both order by position, project_key. Returning an isDefault/isLegacy flag on the project object in the response would collapse this to one decision made server-side.

  3. Shared types across the stack are declared but not enforced. JiraProject is properly shared, but ProjectEpic is redeclared client-side and already diverged, and the /api/projects response shape ({ epics, jiraHost, project }) is untyped on both ends. A single exported response interface imported type-only by the client would have caught the missing projectKey.

  4. JQL injection defence is solid — regex-validated project keys plus quote/backslash/newline rejection on status names, enforced both at validation time and again at buildProjectJql, with good test coverage. No SQL injection risk: every query is parameterised.

Assessment

Ready to merge? With fixes.

Reasoning: The core design (per-project rows, per-project JQL, tab/column layout derivation, injection defence) is well-built and well-tested, but src/app/projects/page.tsx still gates the whole page on the env var the PR replaces, so a Settings-only configuration never renders — that alone blocks the feature. The blank project selector, the Done-tab misclassification on transitions, and the cross-project ring-stat leakage are all user-visible and should land with it.

🤖 Generated with Claude Code · sl-core-dev:claude-pr-review-local · profile fullstack.md

msogin and others added 3 commits August 17, 2026 14:43
…ct-board docs

- Remove docs/superpowers/plans/2026-08-14-glook-38-research-board.md
  (per-team board_config design superseded within this branch)
- Remove mockups/glook-38-research-board.html (stale Research-team mockup,
  and the only tracked file under mockups/)
- Rewrite docs/projects-page.md: boards are configured per project via
  Settings -> Projects (jira_projects table), not JIRA_PROJECTS_JQL; add
  the exact-named-status vs statusCategory invariant (46 vs 71 SPS epics);
  mark JIRA_PROJECTS_JQL legacy/optional
- CLAUDE.md: document the jira_projects table, buildProjectJql validation
  (denylist, not whitelist), and the dual-DB requirement

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…urrecting, tighten jira-projects API

Removes the JIRA_PROJECTS_JQL gate from /projects (Settings-configured boards
were unreachable behind it), memoizes the legacy-var seed per org per process
so deleting the last configured project does not bring it back, changes the
seed default to a two-tab board with a null middle status instead of a
hardcoded Rollout status, adds console.warn coverage for unparseable or
scope-widening legacy JQL, drops the permanently-dead ALTER TABLE teams DROP
COLUMN board_config migration, exposes isLegacy on the GET /api/projects
project object, removes the dead ProjectEpic.projectKey and getJiraProject,
and fixes malformed-JSON and DELETE-404 handling on the jira-projects [id]
route.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…cope board state per project

The board's status dropdown offers every transition Jira's workflow allows —
nine on SPS — while the board itself shows three tabs. `executeTransition`
resolved anything that was neither the active nor the middle status to the Done
tab, so five SPS destinations (Backlog, Discovery, Blocked, Specs & Design,
Ready for Dev) were recorded as moves to Done. `applyPendingTransitions`
prepends, and Jira's `statusCategory = "Done"` JQL never returns a Blocked
epic, so it was re-injected on every subsequent fetch until reload.

`getTransitions` now plumbs `to.statusCategory.key` through as
`toStatusCategory`, and `resolveStatusTab` makes "maps to no visible tab" a
first-class `null` outcome: strip from every tab, inject nowhere, let the next
fetch decide. The same three-way resolution now drives the status dot colours,
which were hardcoded to the SPS literals and rendered every dot grey on a board
with any other vocabulary.

Also:
- clear `ringStats` alongside the pending-transitions registry on org/project
  change: `maxVolume` and `avgCommitsPerJira` are aggregates over the whole map,
  so a small board's rings were scaled against a large board's maximum
- reset the team/goal/initiative/search filters on a project switch, keyed off
  an actual change so a `?project=…&goal=…` deep link survives
- the shared SWR fetcher throws the API's `error` string instead of the bare
  status code, so "No Jira projects configured. Add one in Settings → Projects."
  reaches the user instead of "Error: 404"
- Settings → Projects reports failed loads and deletes through `setError`
  instead of swallowing them; a failed delete keeps the confirm panel armed
- import `ProjectEpic` from the service rather than remirroring it, and take
  legacy-project detection from the server's `isLegacy` flag
- `POST /api/jira-projects` answers 400 on a malformed JSON body, matching
  `[id]/route.ts`

JQL construction is untouched: the active and middle tabs still build an exact
named status, never a status category.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@msogin

msogin commented Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Thanks — both reviews caught real defects, including one that blocked the feature outright. Addressed across four commits (357fb3a, bddc223, 8af40e6, plus the earlier 7d8e3de).

Tests: 115 suites / 1077 tests → 118 / 1136. tsc --noEmit clean. Verified against real Smartling Jira on a rebuilt container, not just unit tests.

Fixed

[C1] the env-var gate — confirmed, and it was the important one. page.tsx now gates only on JIRA_ENABLED; GET /api/projects owns the "nothing configured" state. Worth recording why this survived my own testing: both my local and dev deployments set JIRA_PROJECTS_JQL, so I never exercised the deployment shape this feature exists for. I verified the fix by running a throwaway container with the variable removed — /projects renders 42 rows with all three projects in the selector, where before it showed the env-var error page. Also moved the var out of the required-vars group in env-validation.ts and corrected its example from statusCategory = to status =, since the documented form was one parseLegacyJql refuses.

[I2] transition misclassification — you were right, and my earlier judgement was wrong. I'd previously accepted the 'done' fallback on the reasoning that most terminal transitions really are Done. I checked the live endpoint: it returns 9 transitions, unfiltered, and only Done and Won't Do carry to.statusCategory.key === 'done'. So Backlog, Discovery, Blocked, Specs & Design and Ready for Dev were all being prepended to Done and re-injected on every fetch. getTransitions now plumbs toStatusCategory through (mock client kept in sync, with a non-Done destination added so mock mode reproduces it), and resolveStatusTab() returns 'active' | 'middle' | 'done' | nullnull meaning strip from all tabs and inject nowhere, restoring the pre-diff behaviour instead of guessing. Both regression tests were confirmed failing against the old logic.

[I1] delete-then-list resurrection. Removed ensureSeedProject from GET /api/jira-projects entirely — that endpoint is a pure read again, which also resolves the "a GET performs an INSERT with no auth check" concern for it. The seed is now memoized per org per process, marked on every return path, so deleting the last row cannot bring it back. Verified live: created a throwaway project, deleted it, confirmed it stays gone.

[I3] silent migration. middleStatus now seeds null rather than a hardcoded 'Rollout' — a two-tab board is both the honest default for an unknown workflow and faithful to the legacy single-status board. Added warnings when parseLegacyJql returns null on a non-empty var, and when it drops clauses beyond project/issuetype/status, so a widened board is no longer silent.

[I4] positional provenance. GET /api/projects now returns project.isLegacy, derived server-side from parseLegacyJql(JIRA_PROJECTS_JQL)?.projectKey; the client consumes the flag instead of comparing against projectList[0]. Verified: SPS → true, RND → false, and with the var unset, false for everything — which is correct, since nothing is legacy then.

[S1] dead DROP COLUMN board_config. Confirmed your analysis — git grep board_config 5f87682 -- src schema.sql is empty, so no released deployment ever had the column. Removed from both dialects along with the test that pinned them, and replaced with a negative assertion so it can't come back. Agreed on the .catch(() => {}) point: swallowing connection and permission errors is exactly the 2026-08-11 failure mode, and it's gone with the statement.

[S4] hardcoded status colours, [S5] the dead getJiraProject mock (removed, and since the function had no remaining caller it's deleted too), [S6] ProjectEpic.projectKey (removed — you're right that it was a second derivation of project identity with nothing keeping the two honest), [S7] del() swallowing failures (now routed through the existing setError, and a failed delete keeps the confirm panel armed).

Also from the second review: ring stats no longer leak across boards (cleared alongside pendingTransitionsRef); the SWR fetcher lifts { error } out of the body so the deliberately-worded "No Jira projects configured…" message reaches the user instead of Error: 404; req.json() moved inside try on both [id] and the create route — note err instanceof SyntaxError does not hold there, since undici throws cross-realm, so the check is err?.name === 'SyntaxError'; the client's duplicate ProjectEpic is now a type-only import; and filters reset on project change (guarded so a ?project=…&goal=… deep link survives first render).

Scope / "Bloat" — agreed, acted on. Deleted docs/superpowers/plans/2026-08-14-glook-38-research-board.md (2,977 lines) and mockups/glook-38-research-board.html (1,338 lines). You were right that the 08-14 spec was removed in-branch and the plan and mockup should have followed it; annotating a file as stale in the same PR that adds it isn't a defensible position. Diff is now 6,480 insertions, down from 9,446 — planning artifacts fell from 73% to 40%. docs/projects-page.md rewritten (it described the env var as required and as the board query, both now false), and CLAUDE.md gained the jira_projects table, the named-status invariant, and the denylist rationale.

One finding I'm pushing back on

[I5] the project selector is not blank. Both reviews concluded select.selectedIndex is -1 because no <option> carries value="". In real Chrome it renders "Smartling Platform" — I screenshotted a cold load to check. HTML's selectedness-reset algorithm selects the first option for a size=1 select when none is selected, so the index is 0, not -1. The coupling you sensed is real — it displays the first option, which matches the server's configured[0] only because both order by position, project_key — but that's [I4], and the isLegacy flag now removes the client-side half of it. Happy to add an explicit placeholder option if you'd still prefer it belt-and-braces.

Answered

[I6] widened SPS board — intentional, and smaller than it reads. Of 41 SPS In Progress epics, exactly 1 lacks an Initiative parent, so the board gains one row, not a flood. I'm keeping parentless epics for both hierarchies deliberately: silently hiding in-progress work because it lacks a parent is a worse failure than showing it under . That's also what made RND render empty before this PR. Happy to gate it on hierarchy if you disagree, but I'd rather not thread config into the fetch layer for one row.

DONE_WINDOW_DAYS = 30 is deliberate — Ben's original ask was that finished work linger about two weeks, and 30 days covers that with margin. Per-project windows are a reasonable future knob but nobody has asked for one; noted in the follow-up.

Deferred, tracked in GLOOK-40

  • [S2] threading org through updateJiraProject / deleteJiraProject. Real, and I agree the schema's (org, project_key) identity should be enforced in the service — but it's inert while AUTH_ADMIN_GROUP grants global admin, and it belongs with the per-org admin scoping work rather than bolted on here. (The deleteJiraProject half of your comment is fixed: it now 404s instead of always reporting success.)
  • [S3] ensureSeedProject as a request-path migration. Agreed the shape is wrong. Removing one of the two call sites and memoizing the other bounds the cost; moving it to a real migration seam needs an org source at startup, which the request path is what currently supplies.
  • [S8] pre-PR ?status=Done links silently rendering the active tab.
  • The N+1 /stats requests with no concurrency cap or cancellation on project switch — pre-existing pattern, worth batching.

GLOOK-40 also carries the Jira-driven dropdowns for Settings → Projects, which would have prevented the misconfiguration that motivated several of these: SLIT was configured with a Backlog middle status when that project has no Backlog status and no Epic issue type at all, and the form accepted both.

Verified on a rebuilt container against production Jira: SPS 41/7/44, RND 10/46/7, unchanged from before the fixes.

@msogin
msogin merged commit a6ffeb8 into main Aug 17, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant