Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,24 @@ the listen port is `$PORT` (default 7930); the server always binds `127.0.0.1` -

the board lists open, non-draft MRs authored by any configured member in one of `projects`. a left sidebar switches between **All** (the whole team) and a single member; the **All** view (and each member view) can be grouped by age / author / status / pipeline and sorted by oldest / pipeline / review progress. the current member, grouping, and sort live in the URL (shareable) and are remembered across visits.

## tabs

each tab shows a filtered view of MRs sourced from one of two kinds. tabs have a unique id, a sidebar label, and optional overrides for slack and review routing. define tabs in your team settings (`rt settings set board.tabs --scope team`) as an array of tab objects.

**no tabs config**: the board creates a single implicit "Team" tab sourcing MRs from team members (the `authors` kind).

**two source kinds**:
- `"authors"`: MRs authored by configured team members (the default)
- `"codeowners"`: MRs blocked on approval from a specific codeowners section. section name comes from your repo's `.gitlab/codeowners` (e.g. `Acme`, `Billing`), and excludeMembers (when true) hides MRs authored by team members so the queue shows work assigned to the team, not self-reviews. no excludeMembers = show all MRs (the section's full queue including team-authored ones)

a codeowners tab needs `@mattstack/rt-client` >= 0.5.0 in the board and an rt daemon running the sections-aware `project-mrs:read` handler; an older daemon reports no codeowner sections at all, so the tab just renders empty with no badge explaining why.

**per-tab overrides**:
- `slackChannel`: posts/reactions for this tab go to a different channel (instead of config.slack.channel)
- `reviewSkill`: skill binding for review launches from this tab (instead of the manifest binding or empty fallback)

use `rt settings set` to edit tabs on the team scope -- `config.json` carries them until then, and a settings-store edit needs a board restart (settings are boot-read, not watched).

## tokens

`bun run setup` handles both. `.env` becomes optional with the daemon fallback below, not retired -- an env var still wins first when it's set, `bun run setup` and `/peer/join` both still write to it (`SWITCHBOARD_TOKEN` in particular), and it stays the simplest path for a solo/local install with no rt daemon at all. under the hood:
Expand Down
4 changes: 2 additions & 2 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -54,5 +54,8 @@
},
"switchboard": {
"url": ""
}
},
"tabs": [
{ "id": "team", "label": "Team", "source": { "kind": "authors" } }
]
}
8 changes: 7 additions & 1 deletion config.team.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,5 +35,11 @@
"approved": "white_check_mark"
}
},
"switchboard": { "url": "" }
"switchboard": { "url": "" },
"tabs": [
{ "id": "team", "label": "Team", "source": { "kind": "authors" } },
{ "id": "codeowner-queue", "label": "Codeowner Queue",
"source": { "kind": "codeowners", "section": "Acme", "excludeMembers": true },
"slackChannel": "team-codeowners" }
]
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
},
"dependencies": {
"@mattstack/glance": "^0.19.0",
"@mattstack/rt-client": "^0.4.1",
"@mattstack/rt-client": "^0.5.0",
"@mattstack/settings-kit": "^0.1.2",
"@mattstack/tui-kit": "file:../tui-kit",
"invadrs": "^0.2.0",
Expand Down
198 changes: 194 additions & 4 deletions src/__tests__/board.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,20 @@
import { describe, expect, test } from "bun:test";
import type { PullRequest } from "@mattstack/glance";
import { aggregateSyncScope, boardDemand, buildBoard, buildRoster, projectPathFromWebUrl, stripDraftPrefix, type BoardMR } from "../data.ts";
import {
aggregateSyncScope,
boardDemand,
buildBoard,
buildRoster,
channelForMR,
configuredSlackChannels,
projectPathFromWebUrl,
reviewSkillForTab,
stripDraftPrefix,
visibleMrsFor,
type BoardMR,
} from "../data.ts";
import { SnapshotCache, type FetchResult } from "../cache.ts";
import { DEFAULT_SLACK_EMOJI, type BoardConfig } from "../config.ts";
import { DEFAULT_SLACK_EMOJI, IMPLICIT_TABS, type BoardConfig, type TabConfig } from "../config.ts";
import { extractTicketId } from "../ticket.ts";

const config: BoardConfig = {
Expand Down Expand Up @@ -32,8 +44,16 @@ const config: BoardConfig = {
emoji: DEFAULT_SLACK_EMOJI,
},
switchboard: { url: "" },
tabs: IMPLICIT_TABS,
};

/** A team tab plus one codeowners tab watching "Acme" -- used by the
boardDemand and tagged-row buildBoard tests below. */
const tabsWithCodeowners: TabConfig[] = [
{ id: "t", label: "T", source: { kind: "authors" } },
{ id: "q", label: "Q", source: { kind: "codeowners", section: "Acme" } },
];

function pr(overrides: Partial<PullRequest>): PullRequest {
return {
id: "gitlab:1",
Expand Down Expand Up @@ -258,6 +278,88 @@ describe("buildBoard", () => {
});
});

describe("buildBoard tagged rows (codeowner tabs)", () => {
const withTabs: BoardConfig = { ...config, tabs: tabsWithCodeowners };
const now = Date.parse("2026-07-11T00:00:00Z");

test("keeps a tagged stranger and stamps codeownerSections", () => {
const stranger = pr({ id: "gitlab:900", iid: 9, author: { id: "gitlab:99", username: "outsider", name: "Outsider", avatarUrl: null } });
const tags = new Map([[stranger.id, ["Acme"]]]);
const out = buildBoard([stranger], withTabs, now, tags);
expect(out).toHaveLength(1);
expect(out[0]!.codeownerSections).toEqual(["Acme"]);
});

test("stamps codeownerSections on a roster member's own tagged MR too", () => {
// Mirrors fetchMemberMRs' scoped refresh: the row is kept on isMember
// alone, but a tags map must still be passed for codeownerSections to
// land -- an omitted map (server.ts's prior bug) silently zeroes it.
const memberMr = pr({ id: "gitlab:904", iid: 13 }); // default author: alice, a roster member
const tags = new Map([[memberMr.id, ["Acme"]]]);
const out = buildBoard([memberMr], withTabs, now, tags);
expect(out).toHaveLength(1);
expect(out[0]!.codeownerSections).toEqual(["Acme"]);
});

test("still drops an untagged stranger, and tag-kept rows skip the prefix filter", () => {
const withPrefixes: BoardConfig = { ...withTabs, ticketPrefixes: ["CV"] };
// untagged stranger -> dropped
const untaggedStranger = pr({ id: "gitlab:901", iid: 10, author: { id: "gitlab:100", username: "ghost", name: "Ghost", avatarUrl: null } });
// tagged stranger with no ticket prefix while ticketPrefixes=["CV"] -> kept
const taggedNoPrefix = pr({
id: "gitlab:902",
iid: 11,
author: { id: "gitlab:101", username: "outsider", name: "Outsider", avatarUrl: null },
sourceBranch: "no-ticket",
title: "no ticket here",
});
// tagged MR from a section no tab declares -> dropped
const taggedWrongSection = pr({
id: "gitlab:903",
iid: 12,
author: { id: "gitlab:102", username: "outsider2", name: "Outsider2", avatarUrl: null },
sourceBranch: "no-ticket",
title: "no ticket either",
});
const tags = new Map([
[taggedNoPrefix.id, ["Acme"]],
[taggedWrongSection.id, ["OtherSection"]],
]);
const out = buildBoard([untaggedStranger, taggedNoPrefix, taggedWrongSection], withPrefixes, now, tags);
expect(out.map((m) => m.iid)).toEqual([11]);
});
});

describe("visibleMrsFor (the /data.json payload gate)", () => {
test("keeps a tagged stranger's MR, drops a hidden member's own MR", () => {
const withHiddenAndTabs: BoardConfig = {
...config,
members: [{ username: "alice" }, { username: "carol", hidden: true }],
tabs: tabsWithCodeowners,
};
const now = Date.parse("2026-07-11T00:00:00Z");
const hiddenMemberMr = pr({
id: "gitlab:910",
iid: 20,
author: { id: "gitlab:110", username: "carol", name: "Carol", avatarUrl: null },
});
const taggedStranger = pr({
id: "gitlab:911",
iid: 21,
author: { id: "gitlab:111", username: "outsider", name: "Outsider", avatarUrl: null },
});
const tags = new Map([[taggedStranger.id, ["Acme"]]]);
const snapshotMrs = buildBoard([hiddenMemberMr, taggedStranger], withHiddenAndTabs, now, tags);
// Both reach the snapshot -- buildBoard doesn't know about "hidden"; only
// visibleMrsFor (the /data.json gate) does.
expect(snapshotMrs.map((m) => m.iid)).toEqual([20, 21]);

const visibleMembers = withHiddenAndTabs.members.filter((m) => !m.hidden);
const served = visibleMrsFor(snapshotMrs, visibleMembers);
expect(served.map((m) => m.iid)).toEqual([21]);
});
});

describe("buildRoster", () => {
const members = [{ username: "alice" }, { username: "bob", name: "Bobby" }, { username: "carol" }];

Expand Down Expand Up @@ -293,6 +395,81 @@ describe("boardDemand", () => {
expect(d.authors).toEqual(["a", "b"]); // hidden is a display state, not a demand state
expect(d.declaredAt).toBeGreaterThan(0);
});

test("declares the union of tab sections, and omits the field when no tab is codeowners", () => {
const withTabs: BoardConfig = { ...config, tabs: tabsWithCodeowners };
expect(boardDemand(withTabs, 1).codeownerSections).toEqual(["Acme"]);
expect(boardDemand(config, 1).codeownerSections).toBeUndefined();
});
});

describe("channelForMR", () => {
const tabsWithSlackChannel: TabConfig[] = [
{ id: "t", label: "T", source: { kind: "authors" } },
{ id: "q", label: "Q", source: { kind: "codeowners", section: "Acme", excludeMembers: true }, slackChannel: "team-codeowners" },
];
const withSlackTab: BoardConfig = { ...config, tabs: tabsWithSlackChannel };

test("routes a roster MR to the default channel", () => {
expect(
channelForMR(withSlackTab, { author: { id: "gitlab:1", username: "alice", name: "Alice", avatarUrl: null }, codeownerSections: [] }),
).toBe("code-review");
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});

test("routes a tagged stranger to their codeowners tab's channel", () => {
expect(
channelForMR(withSlackTab, {
author: { id: "gitlab:2", username: "outsider", name: "Outsider", avatarUrl: null },
codeownerSections: ["Acme"],
}),
).toBe("team-codeowners");
});

test("falls back to the default channel when a stranger's tags match no tab's slackChannel", () => {
expect(
channelForMR(withSlackTab, {
author: { id: "gitlab:3", username: "outsider", name: "Outsider", avatarUrl: null },
codeownerSections: ["SomeOtherSection"],
}),
).toBe("code-review");
});

test("a stranger with no tags falls back to the default channel", () => {
expect(
channelForMR(withSlackTab, { author: { id: "gitlab:4", username: "outsider", name: "Outsider", avatarUrl: null }, codeownerSections: [] }),
).toBe("code-review");
});
});

describe("configuredSlackChannels", () => {
test("is the default channel plus every distinct tab slackChannel", () => {
const withTabs: BoardConfig = {
...config,
tabs: [
{ id: "t", label: "T", source: { kind: "authors" } },
{ id: "q", label: "Q", source: { kind: "codeowners", section: "Acme" }, slackChannel: "team-codeowners" },
{ id: "r", label: "R", source: { kind: "codeowners", section: "Billing" }, slackChannel: "team-codeowners" },
],
};
expect(configuredSlackChannels(withTabs).sort()).toEqual(["code-review", "team-codeowners"]);
});

test("is just the default channel when no tab overrides it", () => {
expect(configuredSlackChannels(config)).toEqual(["code-review"]);
});
});

describe("reviewSkillForTab", () => {
test("prefers the tab's reviewSkill and falls back to normal resolution", () => {
const cfg = { ...config, tabs: [
{ id: "t", label: "T", source: { kind: "authors" as const } },
{ id: "q", label: "Q", source: { kind: "codeowners" as const, section: "Acme" }, reviewSkill: "external:review" },
] };
const fallback = () => "acme:review";
expect(reviewSkillForTab(cfg, "q", "u", fallback)).toBe("external:review");
expect(reviewSkillForTab(cfg, "t", "u", fallback)).toBe("acme:review");
expect(reviewSkillForTab(cfg, undefined, "u", fallback)).toBe("acme:review");
});
});

describe("aggregateSyncScope", () => {
Expand All @@ -302,7 +479,12 @@ describe("aggregateSyncScope", () => {
});

test("no reads yields null syncedAt/windowDays and an empty uncovered list", () => {
expect(aggregateSyncScope([])).toEqual({ dataSyncedAt: null, scopeUncovered: [], scopeWindowDays: null });
expect(aggregateSyncScope([])).toEqual({
dataSyncedAt: null,
scopeUncovered: [],
scopeWindowDays: null,
scopeUncoveredSections: [],
});
});

test("unions scope.uncovered across reads and takes the min windowDays", () => {
Expand All @@ -319,12 +501,20 @@ describe("aggregateSyncScope", () => {
expect(agg.scopeWindowDays).toBeNull();
expect(agg.scopeUncovered).toEqual([]);
});

test("unions uncoveredSections", () => {
const agg = aggregateSyncScope([
{ syncedAt: 1, scope: { authors: [], windowDays: 30, uncovered: [], sections: [], uncoveredSections: ["Acme"] } },
{ syncedAt: 2 },
]);
expect(agg.scopeUncoveredSections).toEqual(["Acme"]);
});
});

/** Wrap a bare mrs array as the FetchResult shape SnapshotCache now expects,
for tests that only care about the mrs field. */
function fetchResult(mrs: unknown[]): FetchResult {
return { mrs: mrs as BoardMR[], dataSyncedAt: null, scopeUncovered: [], scopeWindowDays: null };
return { mrs: mrs as BoardMR[], dataSyncedAt: null, scopeUncovered: [], scopeWindowDays: null, scopeUncoveredSections: [] };
}

describe("SnapshotCache", () => {
Expand Down
2 changes: 1 addition & 1 deletion src/__tests__/cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { SnapshotCache, type FetchResult } from "../cache.ts";

/** Wrap a bare mrs array as the FetchResult shape SnapshotCache expects. */
function fetchResult(mrs: unknown[]): FetchResult {
return { mrs: mrs as FetchResult["mrs"], dataSyncedAt: null, scopeUncovered: [], scopeWindowDays: null };
return { mrs: mrs as FetchResult["mrs"], dataSyncedAt: null, scopeUncovered: [], scopeWindowDays: null, scopeUncoveredSections: [] };
}

describe("SnapshotCache forced-refresh failure", () => {
Expand Down
11 changes: 11 additions & 0 deletions src/__tests__/config-store-latch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,17 @@ describe("loadConfigFrom: config.json-optional boot once the team store owns the
});
});

describe("loadConfigFrom: board.tabs overlay", () => {
test("board.tabs store value overlays config.json", () => {
const p = tmpConfig();
const resolve = fakeResolve({
"board.tabs": [{ id: "q", label: "Q", source: { kind: "codeowners", section: "Acme", excludeMembers: true }, slackChannel: "team-codeowners" }],
});
const cfg = loadConfigFrom(p, resolve);
expect(cfg.tabs[0]!.id).toBe("q");
});
});

describe("saveMemberHidden: latch-gated writer", () => {
test("unowned: writes config.json's inline hidden flag, store untouched", () => {
const p = tmpConfig({ ...base, members: [{ username: "alice" }, { username: "bob" }] });
Expand Down
19 changes: 19 additions & 0 deletions src/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,25 @@ describe("parseConfig", () => {
expect(parseConfig(JSON.stringify(base)).rtRepos).toEqual({});
});

test("config without tabs gets the implicit authors tab", () => {
const cfg = parseConfig(JSON.stringify(base));
expect(cfg.tabs).toEqual([{ id: "team", label: "Team", source: { kind: "authors" } }]);
});

test("throws on an empty tabs array instead of silently producing a zero-tab board", () => {
expect(() => parseConfig(JSON.stringify({ ...base, tabs: [] }))).toThrow(/tabs.*must not be empty/);
});

test("tabs validate: unique ids, codeowners needs a section", () => {
expect(() => parseConfig(JSON.stringify({ ...base, tabs: [
{ id: "a", label: "A", source: { kind: "codeowners" } },
] }))).toThrow(/section/);
expect(() => parseConfig(JSON.stringify({ ...base, tabs: [
{ id: "a", label: "A", source: { kind: "authors" } },
{ id: "a", label: "B", source: { kind: "authors" } },
] }))).toThrow(/duplicate tab id/);
});

test("port, host, reviewSkill, respondSkill, teamClone are gone from the parsed shape", () => {
const cfg = parseConfig(JSON.stringify({
...base, port: 9999, host: "0.0.0.0", reviewSkill: "x:review", respondSkill: "x:respond", teamClone: "~/team",
Expand Down
Loading