Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
106 commits
Select commit Hold shift + click to select a range
da0e69a
feat(claude/mcp): optional capability filter for genesis-tools MCP
genesiscz Jul 13, 2026
c26a6df
fix(agents): survive repeated shutdown signals without orphaning the …
genesiscz Jul 13, 2026
bc15f8e
fix(agents): bump listener sanity cap to 8h
genesiscz Jul 13, 2026
de07733
fix(jenkins-mcp): stage event duration/status + parallel branch context
genesiscz Jul 13, 2026
81efb07
feat(agents): add --body-file to message command
genesiscz Jul 13, 2026
0003c2d
feat(agents): echo body_length in message command's JSON output
genesiscz Jul 13, 2026
62290fd
docs(agents): document idle-teammate wake asymmetry + dual-channel pr…
genesiscz Jul 13, 2026
bf6f20e
docs(agents): monitor events are dropped for idle teammates, not queued
genesiscz Jul 13, 2026
6db0de8
docs(agents): refine idle-teammate monitor semantics (no wake, no wak…
genesiscz Jul 13, 2026
19006f0
feat(cli): shared port-style box tables in @app/utils/table
genesiscz Jul 14, 2026
349a889
fix(tools): strip socks proxy env when spawning tool children
genesiscz Jul 14, 2026
6c7d370
feat(ai-proxy): xai-api-key provider, live catalogs, cleaner model list
genesiscz Jul 14, 2026
76d8e34
feat(github): stack-safe merge command and smarter 403 rate-limit det…
genesiscz Jul 14, 2026
d067c01
feat(ai): grok-sub account type, env-referenced API keys, explicit su…
genesiscz Jul 15, 2026
ac0b854
feat(youtube): extension side-panel overhaul — shadow-DOM fixes, dev …
genesiscz Jul 15, 2026
1823d38
chore: add puppeteer-core dev-dep for headless extension verification
genesiscz Jul 15, 2026
49a794d
feat(ai): authFile reference mode for openai-sub accounts (Codex CLI …
genesiscz Jul 15, 2026
31338c4
feat(youtube): ask tab, live job progress, shadow-DOM select dismiss,…
genesiscz Jul 15, 2026
8bffeaa
fix(ai): resolve subscription tokens per-request in sub resolvers (40…
genesiscz Jul 15, 2026
85d9376
feat(youtube): themed markdown Ask answers + strip LiteLLM routing pr…
genesiscz Jul 15, 2026
fa0bc35
feat(youtube): users/credits/qa-history DB schema and methods
genesiscz Jul 15, 2026
07a34aa
feat(youtube): users lib with argon2id register/login
genesiscz Jul 15, 2026
22443ad
feat(youtube): user auth guard, users routes, credit-gated LLM endpoints
genesiscz Jul 15, 2026
abd38b7
feat(youtube-ext): user auth messages, token storage, account hooks
genesiscz Jul 15, 2026
7ac1ddb
feat(youtube-ext): settings dialog, diamonds chip, server-backed ask …
genesiscz Jul 15, 2026
067d76f
style(youtube): biome formatting for users tests and types barrel
genesiscz Jul 15, 2026
8934c32
feat(youtube/billing): diamond packs, stripe env accessors, signature…
genesiscz Jul 15, 2026
bcd1dad
feat(youtube/billing): checkout session creation + webhook grant/refu…
genesiscz Jul 15, 2026
b846302
feat(youtube/server): checkout + stripe webhook routes
genesiscz Jul 15, 2026
b41b588
feat(youtube/extension): checkout message/hook + diamonds pack picker UI
genesiscz Jul 15, 2026
2d35604
feat(youtube/usage): ledger-views aggregation lib for the activity da…
genesiscz Jul 15, 2026
7035376
feat(youtube): ledger + usage-summary routes, messages, hooks
genesiscz Jul 15, 2026
8f37b06
feat(youtube/extension): settings "activity · last 30 days" compact b…
genesiscz Jul 15, 2026
6cf5b35
feat(youtube/extension): panel Activity view with reason filters + in…
genesiscz Jul 15, 2026
ce8652b
feat(youtube/shares): migration + shares lib (snapshot, slug, HTML te…
genesiscz Jul 15, 2026
5282fc8
feat(youtube/server): shares CRUD routes + public share page
genesiscz Jul 15, 2026
1d4d655
feat(youtube/extension): share buttons + settings Shares management
genesiscz Jul 15, 2026
9fbb051
feat(youtube/presets): prompt_presets table + CRUD lib
genesiscz Jul 15, 2026
3069477
feat(youtube/presets): prompt injection + presets CRUD routes + prese…
genesiscz Jul 15, 2026
24bd2ad
feat(youtube/extension): Style preset picker in Summary + Ask, preset…
genesiscz Jul 15, 2026
6524a96
feat(youtube/extension): settings Presets section with edit/delete/ex…
genesiscz Jul 15, 2026
7c05da0
feat(youtube): artifact_access table, reuse pricing lib, one-time bac…
genesiscz Jul 15, 2026
14ae9ca
feat(youtube): reuse-priced summary routes — locked GET envelope, POS…
genesiscz Jul 15, 2026
806d181
feat(youtube-ext): locked-artifact envelope plumbing through messages…
genesiscz Jul 15, 2026
2bb177f
feat(youtube-ext): reuse unlock UI — Sparkles confirm variant, locked…
genesiscz Jul 15, 2026
a10f9b5
feat(youtube): qa chunk source tagging, comment thread chunker, polym…
genesiscz Jul 15, 2026
3c42643
feat(youtube): comments-aware qa indexing, merged retrieval with tran…
genesiscz Jul 15, 2026
651c3a1
feat(youtube): qa sources through route, 409 on unfetched comments, e…
genesiscz Jul 15, 2026
ffc226d
feat(youtube-ext): ask scope selector, comment author chips with jump…
genesiscz Jul 15, 2026
de94c61
feat(youtube): channel-ask candidate selection with FTS pre-filter an…
genesiscz Jul 15, 2026
147699e
feat(youtube): channel-scope ask — 10-diamond price, cross-video attr…
genesiscz Jul 15, 2026
b0d6945
feat(youtube-ext): channel ask scope UI, grouped per-video citations,…
genesiscz Jul 15, 2026
504eae1
feat(youtube): multi-video reports — reportSynthesize stage, reports …
genesiscz Jul 15, 2026
dd661b9
feat(youtube-ext): playlist report panel — generate confirm, polling …
genesiscz Jul 15, 2026
16c5b71
style(youtube): biome formatting sync after staged-fix pre-commit runs
genesiscz Jul 15, 2026
294d47b
fix(youtube): keep qa:channel whole in ledger reason grouping
genesiscz Jul 15, 2026
78858b4
feat(youtube): add latest-wins partial throttle helper
genesiscz Jul 15, 2026
4fc47c9
feat(youtube): stream structured summaries via streamObject with thro…
genesiscz Jul 15, 2026
5a09bde
feat(youtube-ext): thread streamed summary partials into the side panel
genesiscz Jul 15, 2026
920c38b
feat(youtube-ext): partial-tolerant summary rendering with skeletons …
genesiscz Jul 15, 2026
2f38a93
feat(youtube): anchor long-summary chapters to transcript timestamps …
genesiscz Jul 15, 2026
3561061
feat(youtube-ext): chapter tick overlay module for the player progres…
genesiscz Jul 15, 2026
dd9f83d
feat(youtube-ext): chapter ticks lifecycle + panel/content window bri…
genesiscz Jul 15, 2026
be01db4
feat(youtube-ext): chapter card seek pills + playing state synced to …
genesiscz Jul 15, 2026
f5ab3ab
feat(youtube): persist Deepgram speaker diarization + speaker-aware p…
genesiscz Jul 15, 2026
6d64a48
feat(youtube): video_speakers label table + PUT /videos/:id/speakers …
genesiscz Jul 15, 2026
42cc631
docs(youtube): reflect chapter startSec/endSec and segment speaker in…
genesiscz Jul 15, 2026
c43a5f8
feat(youtube-ext): editor-grade transcript search (counter, next/prev…
genesiscz Jul 15, 2026
360cf71
feat(youtube-ext): transcript follow mode synced to playback with man…
genesiscz Jul 15, 2026
d144f63
feat(youtube): speaker chips with inline rename wired through PUT /sp…
genesiscz Jul 15, 2026
2d66170
style(youtube): biome line-width formatting in summary-partials
genesiscz Jul 15, 2026
0e381a5
style(youtube): biome formatting after rebase re-integration
genesiscz Jul 15, 2026
6d6e6da
style(youtube): biome formatting after second rebase re-integration
genesiscz Jul 15, 2026
3049291
feat(youtube): output-language preference + lang plumbing through sum…
genesiscz Jul 15, 2026
282861a
feat(youtube): line-anchored transcript translation endpoint
genesiscz Jul 15, 2026
7840cf4
feat(youtube): generation + transcript language UI (extension)
genesiscz Jul 15, 2026
b681abf
feat(youtube): settings language section + Czech UI catalog
genesiscz Jul 15, 2026
a06ff53
feat(youtube): Audio TL;DR narration, cache/synthesis, Range-capable …
genesiscz Jul 15, 2026
087a7af
feat(youtube): Audio TL;DR mini player + exclusivity bridge
genesiscz Jul 15, 2026
dd78a66
fix(youtube): use \u0000 escapes instead of raw NUL bytes in audio ca…
genesiscz Jul 15, 2026
67b7540
fix(youtube-ext): don't inline-inject panel when #secondary is out-of…
genesiscz Jul 15, 2026
157d611
fix(youtube-ext): stack panel above the live chat, retreat off the pl…
genesiscz Jul 15, 2026
edf3f62
style(youtube-ext): biome one-line format for player selector
genesiscz Jul 15, 2026
9d68e6e
fix(youtube-ext): pin flex:0 0 auto so above-chat panel keeps natural…
genesiscz Jul 15, 2026
8b01f4c
fix(youtube): gate dev top-up endpoint behind YOUTUBE_ALLOW_DEV_TOPUP
genesiscz Jul 15, 2026
e895468
fix(youtube): enforce long-summary artifact access on audio routes
genesiscz Jul 15, 2026
c6740f4
fix(youtube): debit credits atomically before LLM/TTS work, refund on…
genesiscz Jul 15, 2026
19989f7
fix(youtube): review a11y/theme-token/logging batch for extension + s…
genesiscz Jul 15, 2026
9b50884
fix(youtube): per-video lang reset, interval follow mode, streamed ch…
genesiscz Jul 15, 2026
21056d2
fix(youtube): extension dev watcher — shared UI deps, posix paths, de…
genesiscz Jul 15, 2026
ef5bfda
fix(youtube): shared strict safeJsonBody with logging + full-segment …
genesiscz Jul 15, 2026
02db08d
fix(youtube): voice-aware audio URL, lang-aware estimate, preset null…
genesiscz Jul 15, 2026
6f64e57
fix(youtube): track QA jobs under a dedicated qa stage (was mislabele…
genesiscz Jul 15, 2026
d580ff2
fix(ask): make DetectedProvider.subscription required and populate it…
genesiscz Jul 15, 2026
3c524d9
fix(youtube): translate lines mapped by [#id] tags, not array position
genesiscz Jul 15, 2026
eea848f
fix(youtube): merged comment chunks keep every thread root for citations
genesiscz Jul 15, 2026
33eb63e
chore: align @types/jsdom with jsdom 29
genesiscz Jul 15, 2026
59b864f
style(youtube): biome formatting in qa.ts
genesiscz Jul 15, 2026
5eaf47d
fix(youtube): wire confirm dialog's live language into the estimate q…
genesiscz Jul 15, 2026
604675c
feat(ui): register WEB_SERVICES and matchProcess on all ports
genesiscz Jul 15, 2026
9cd2bde
feat(dev-dashboard/ports): batch resolve, kind classify, and identity…
genesiscz Jul 15, 2026
edc130e
feat(dev-dashboard): unified /api/live SSE bus for ports and pulse
genesiscz Jul 15, 2026
7beb84d
feat(dev-dashboard/ui): ports filters, sort, live stream, stable kind…
genesiscz Jul 15, 2026
5c25f43
style: biome format after ports/live commits
genesiscz Jul 15, 2026
67b89b5
feat(utils): expose @genesis/tools-utils pure export map
genesiscz Jul 15, 2026
d312eac
feat(github-merge): add --ff-only true fast-forward with stack retarget
genesiscz Jul 16, 2026
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
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,16 @@ Answer a question, then preserve the Q→A to the local question store so it can

Captured via the `question_answer` MCP tool, falling back to the `tools question record` CLI.

#### genesis-tools MCP server (`tools claude mcp`)

The plugin's own MCP server (stdio) exposes two capabilities: `question_answer` (the Q&A capture tool above) and `boards` (dev-dashboard annotation boards — `boards_list_boards`, `boards_wait_for_work`, `boards_compose_board`, etc.). Both are enabled by default. To restrict which capabilities are registered, set `GENESIS_TOOLS_MCP_CAPABILITIES` to a comma-delimited list of capability names in the server's environment (e.g. in `~/.claude.json`'s `mcpServers.genesis-tools.env`):

```bash
GENESIS_TOOLS_MCP_CAPABILITIES=question_answer,boards # both (default when unset)
GENESIS_TOOLS_MCP_CAPABILITIES=question_answer # only the Q&A tool
GENESIS_TOOLS_MCP_CAPABILITIES=boards # only boards tools
```

---

### Skill Details
Expand Down
2 changes: 1 addition & 1 deletion plugins/genesis-tools/skills/agents-talk/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ Each event is a JSON line. The most important `type` values:

| | `--stream` (default, CC) | `--once` (any host) |
|---|---|---|
| Lifetime | Long-running, ~1h sanity cap | Returns when a message arrives or harness kills it |
| Lifetime | Long-running, ~8h sanity cap | Returns when a message arrives or harness kills it |
| Harness tool | `Monitor` follows stdout | Caller re-invokes in a loop |
| Resume after exit | Continues where it left off | Continues where it left off |
| Cost | One held process per agent | One short-lived call per receive |
Expand Down
23 changes: 17 additions & 6 deletions src/agents/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { resolveSession } from "../lib/session-resolve";
import { readSlotPayload, releaseSlot, runStaleSweep, slotLockPath, tryAcquireSlot } from "../lib/slot-lock";
import type { AgentRecord, FeedEvent, SessionPaths, SlotLockPayload } from "../lib/types";

const ONE_HOUR_MS = 60 * 60 * 1000;
const LISTEN_CAP_MS = 8 * 60 * 60 * 1000;

const log = logger.child({ component: "agents:login" });

Expand Down Expand Up @@ -426,6 +426,12 @@ async function runLoginImpl(opts: LoginOpts): Promise<void> {
onShutdown(async (reason) => {
exitReason = reason;

// Release the PID lock BEFORE any feed I/O: if the process dies
// mid-drain (force-kill, lock timeout), the slot must not stay
// orphaned. Worst case of the reversed order is a same-agent relogin
// racing the final cursor write — duplicate delivery, never loss.
releaseSlot(lockPath);

try {
await drainPending(active);
} catch (err) {
Expand All @@ -438,7 +444,6 @@ async function runLoginImpl(opts: LoginOpts): Promise<void> {
log.warn({ err }, "logged_out emit failed during shutdown");
}

releaseSlot(lockPath);
emitResumeHint(active.record, mode);
});

Expand All @@ -451,23 +456,29 @@ async function runLoginImpl(opts: LoginOpts): Promise<void> {
const initialEmitted = await drainPending(active);

if (initialEmitted === 0) {
const deadline = Date.now() + ONE_HOUR_MS;
const deadline = Date.now() + LISTEN_CAP_MS;
await watchUntilDeadline(active, deadline, true);
}
} else {
const deadline = Date.now() + ONE_HOUR_MS;
const deadline = Date.now() + LISTEN_CAP_MS;
await watchUntilDeadline(active, deadline, false);
exitReason = "cap";
}
} finally {
releaseSlot(lockPath);

try {
await drainPending(active);
} catch (err) {
log.warn({ err }, "final drain failed");
}

await emitLoggedOut({ paths, record: active.record, reason: exitReason, mode });
releaseSlot(lockPath);
try {
await emitLoggedOut({ paths, record: active.record, reason: exitReason, mode });
} catch (err) {
log.warn({ err }, "logged_out emit failed on exit");
}

emitResumeHint(active.record, mode);
}
}
Expand Down
36 changes: 21 additions & 15 deletions src/agents/lib/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,23 +8,29 @@ export type ShutdownHandler = (reason: ShutdownReason) => Promise<void> | void;

const handlers: ShutdownHandler[] = [];
let signalsInstalled = false;
let shuttingDown = false;

async function runHandlers(reason: ShutdownReason): Promise<void> {
if (shuttingDown) {
return;
let shutdownRun: Promise<void> | null = null;

// A single shared promise (not a boolean guard) so a second signal delivered
// mid-shutdown awaits the in-flight handlers instead of returning immediately
// and calling process.exit() while they're still running — that killed the
// process before releaseSlot(), orphaning the PID lock until an unrelated
// agent's stale-lock sweep reaped it.
function runHandlers(reason: ShutdownReason): Promise<void> {
if (!shutdownRun) {
shutdownRun = (async () => {
const ordered = [...handlers].reverse();

for (const handler of ordered) {
try {
await handler(reason);
} catch (err) {
log.warn({ err }, "shutdown handler threw");
}
}
})();
}

shuttingDown = true;
const ordered = [...handlers].reverse();

for (const handler of ordered) {
try {
await handler(reason);
} catch (err) {
log.warn({ err }, "shutdown handler threw");
}
}
return shutdownRun;
}
Comment on lines +11 to 34

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Shared-promise fix looks correct; add a regression test.

The synchronous assignment of shutdownRun before any await can interleave correctly prevents the race this replaces (a second signal short-circuiting past in-flight handlers). No functional issue found.

Since this fixes a subtle concurrency bug, consider adding a unit test (e.g., push two async handlers, call runHandlers twice concurrently, assert the second call awaits the same promise and handlers run exactly once in reverse order) to guard against regression.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/agents/lib/lifecycle.ts` around lines 11 - 34, The shared-promise
behavior in runHandlers lacks regression coverage for concurrent shutdown calls.
Add a unit test that registers two asynchronous handlers, invokes runHandlers
twice before completion, asserts both calls await the same in-flight promise,
and verifies handlers execute exactly once in reverse registration order.


function installSignals(): void {
Expand Down
5 changes: 4 additions & 1 deletion src/claude/commands/mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ const log = logger.child({ component: "claude:mcp-cmd" });
export function registerMcpCommand(program: Command): void {
const mcp = program
.command("mcp")
.description("Run the genesis-tools MCP server (stdio) — exposes question_answer")
.description(
"Run the genesis-tools MCP server (stdio) — exposes question_answer + boards. " +
"Set GENESIS_TOOLS_MCP_CAPABILITIES (comma-delimited, e.g. question_answer,boards) to restrict."
)
.action(async () => {
log.info("starting MCP server");
await startMcpServer();
Expand Down
32 changes: 31 additions & 1 deletion src/claude/mcp/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { logger } from "@app/logger";
import { env } from "@app/utils/env/envVariables";
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
Expand Down Expand Up @@ -351,8 +352,37 @@ function buildToolRegistry(): Record<string, ToolEntry> {
};
}

/** Known capability names, keyed to the tool-name prefix that identifies membership. */

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Remove the redundant capability-map comment.

CAPABILITY_PREFIXES and its values already describe this mapping. As per coding guidelines, “Do not add comments that merely restate what the code already says.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/claude/mcp/server.ts` at line 355, Remove the redundant comment
immediately preceding CAPABILITY_PREFIXES, leaving the capability map and its
values unchanged.

Source: Coding guidelines

const CAPABILITY_PREFIXES: Record<string, string> = {
question_answer: "question_answer",
boards: "boards_",
};

/**
* Filters the tool registry by GENESIS_TOOLS_MCP_CAPABILITIES (comma-delimited, e.g.
* "question_answer,boards"). Unset or empty -> every capability enabled (unchanged default).
*/
function filterRegistryByCapabilities(registry: Record<string, ToolEntry>): Record<string, ToolEntry> {
const capabilities = env.tools.getMcpCapabilities();
if (capabilities === undefined) {
return registry;
}

const enabledPrefixes = capabilities
.map((capability) => CAPABILITY_PREFIXES[capability])
.filter((prefix): prefix is string => prefix !== undefined);

return Object.fromEntries(
Object.entries(registry).filter(([name]) => enabledPrefixes.some((prefix) => name.startsWith(prefix)))
);
Comment on lines +371 to +377

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

If GENESIS_TOOLS_MCP_CAPABILITIES is set but contains only invalid or unknown capability names, enabledPrefixes will be empty. This causes the server to start with an empty tool registry (0 tools), making it completely non-functional and hard to debug.

According to the PR description, "empty list after parse behaves as 'all'". To align with this behavior and prevent a silently broken server, we should fall back to the full registry and log a warning when no valid capabilities are resolved.

    const enabledPrefixes = capabilities
        .map((capability) => CAPABILITY_PREFIXES[capability])
        .filter((prefix): prefix is string => prefix !== undefined);

    if (enabledPrefixes.length === 0) {
        log.warn(
            { capabilities },
            "None of the specified GENESIS_TOOLS_MCP_CAPABILITIES are valid. Falling back to all capabilities."
        );
        return registry;
    }

    return Object.fromEntries(
        Object.entries(registry).filter(([name]) => enabledPrefixes.some((prefix) => name.startsWith(prefix)))
    );
References
  1. For non-fatal operations that can result in empty data sets (e.g., filtering), check for emptiness and log a warning or skip message instead of crashing or failing silently.

}

export async function startMcpServer(): Promise<void> {
const registry = buildToolRegistry();
const registry = filterRegistryByCapabilities(buildToolRegistry());
log.info(
{ capabilities: env.tools.getMcpCapabilities() ?? "all", tools: Object.keys(registry) },
"genesis-tools MCP tool registry resolved"
);
const server = new Server(
{ name: "genesis-tools", version: "1.0.0" },
{ capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS }
Expand Down
101 changes: 101 additions & 0 deletions src/jenkins-mcp/lib/blue-ocean.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it } from "bun:test";
import {
type BlueOceanNode,
blueOceanNodesUrls,
buildContextMap,
contextFromBlueOcean,
dedupeConsecutive,
} from "./blue-ocean";

describe("blueOceanNodesUrls", () => {
it("prefers multibranch branch URL then full pipeline fallback", () => {
const urls = blueOceanNodesUrls("job/Digi22/job/col/job/FE/job/col-fe-multibranch-build/job/develop", "7180");
expect(urls).toHaveLength(2);
expect(urls[0]).toContain("/pipelines/Digi22/pipelines/col/pipelines/FE/pipelines/col-fe-multibranch-build/");
expect(urls[0]).toContain("/branches/develop/runs/7180/nodes/");
expect(urls[1]).toContain("/pipelines/develop/runs/7180/nodes/");
expect(urls[1]).not.toContain("/branches/");
});

it("percent-encodes branch segments (Jenkins stores feature/foo as one segment)", () => {
// Classic URL already has one path segment for the branch; % must be re-encoded for Blue Ocean.
const urls = blueOceanNodesUrls("job/pipe/job/feature%2Ffoo", "3");
expect(urls[0]).toContain("/branches/feature%252Ffoo/");
});
});

describe("dedupeConsecutive", () => {
it("collapses PARALLEL+STAGE duplicate names", () => {
expect(dedupeConsecutive(["Build affected apps", "fee-web", "fee-web", "Tests"])).toEqual([
"Build affected apps",
"fee-web",
"Tests",
]);
});
});

describe("contextFromBlueOcean", () => {
const nodes: BlueOceanNode[] = [
{ id: "12", displayName: "Clone", type: "STAGE", firstParent: null },
{ id: "58", displayName: "Repo QA", type: "STAGE", firstParent: "12" },
{ id: "62", displayName: "Type check", type: "PARALLEL", firstParent: "58" },
{ id: "75", displayName: "Build affected apps", type: "STAGE", firstParent: null },
{ id: "82", displayName: "fee-web", type: "PARALLEL", firstParent: "75" },
{ id: "91", displayName: "fee-web", type: "STAGE", firstParent: "82" },
{ id: "110", displayName: "Tests", type: "STAGE", firstParent: "91" },
{ id: "166", displayName: "Build", type: "STAGE", firstParent: "110" },
{ id: "499", displayName: "SonarQube", type: "STAGE", firstParent: "480" },
{ id: "480", displayName: "deploy", type: "STAGE", firstParent: "346" },
{ id: "346", displayName: "Build image", type: "STAGE", firstParent: "166" },
];
const byId = new Map(nodes.map((n) => [n.id, n]));

it("labels Tests with fee-web context", () => {
const ctx = contextFromBlueOcean("110", byId)!;
expect(ctx.context).toBe("fee-web");
expect(ctx.label).toBe("fee-web · Tests");
expect(ctx.path).toEqual(["Build affected apps", "fee-web", "Tests"]);
});

it("labels nested Build under fee-web", () => {
const ctx = contextFromBlueOcean("166", byId)!;
expect(ctx.context).toBe("fee-web");
expect(ctx.label).toBe("fee-web · Build");
});

it("labels SonarQube with fee-web (not deploy)", () => {
const ctx = contextFromBlueOcean("499", byId)!;
expect(ctx.context).toBe("fee-web");
expect(ctx.label).toBe("fee-web · SonarQube");
expect(ctx.path).toContain("fee-web");
expect(ctx.path[ctx.path.length - 1]).toBe("SonarQube");
});

it("labels parallel QA under Repo QA", () => {
const ctx = contextFromBlueOcean("62", byId)!;
expect(ctx.context).toBe("Repo QA");
expect(ctx.label).toBe("Repo QA · Type check");
});

it("keeps top-level Clone plain", () => {
const ctx = contextFromBlueOcean("12", byId)!;
expect(ctx.context).toBeUndefined();
expect(ctx.label).toBe("Clone");
expect(ctx.path).toEqual(["Clone"]);
});

it("keeps fee-web shell label plain (no fee-web · fee-web)", () => {
const ctx = contextFromBlueOcean("91", byId)!;
expect(ctx.label).toBe("fee-web");
});
});

describe("buildContextMap", () => {
it("indexes every node id", () => {
const map = buildContextMap([
{ id: "1", displayName: "A", type: "STAGE", firstParent: null },
{ id: "2", displayName: "B", type: "STAGE", firstParent: "1" },
]);
expect(map.get("2")?.label).toBe("A · B");
});
});
Loading
Loading