fix(cli): derive projectId from prefixed issue id on spawn (#1330)

* fix(cli): derive projectId from prefixed issue id on spawn

When `ao spawn <projectId>/<issue>` is used in a multi-project config,
route the spawn to the prefixed project and strip the prefix from the
issue id. Previously the projectId fell back to whichever project
`ao start` was running for, tagging cross-project sessions with the
wrong project (and session prefix).

Applies to both `ao spawn` and `ao batch-spawn`; batch-spawn errors
out if issues mix multiple project prefixes.

Fixes #1329

* refactor(core,cli): lift spawn target resolution into core

Move the issue-prefix → project routing from the CLI into a reusable
core utility, `resolveSpawnTarget(projects, issueRef, fallback?)`.

- Exposes routing to any spawn entry point (CLI, web API, programmatic).
- Accepts either a project id or sessionPrefix as the prefix; project id
  wins on collision.
- `ao batch-spawn` now groups issues by resolved project and preflights
  once per group instead of erroring on mixed prefixes.

Tests:
- 8 unit tests for `resolveSpawnTarget` in core.
- CLI spawn.test.ts: adds a sessionPrefix routing test (23 tests total).

Refs #1329

* test(cli): cover batch-spawn grouping; tighten resolveSpawnTarget API

Self-review found three gaps:

- `resolveSpawnTarget` accepted `undefined` issueRef and returned
  `{ issueId: "" }` with a fallback project. Dead path — both callers
  guard against undefined. Drop it and make issueRef required.
- No tests for `batch-spawn`. Added two:
  - routes cross-project prefixed issues to the correct project and
    lists each project's sessions separately
  - skips a prefixed issue when the target project already has an
    active session for it
- `spawn` and `batch-spawn` help text didn't document the
  `<projectId>/<issue>` or `<sessionPrefix>/<issue>` forms.

* fix(core): guard resolveSpawnTarget against prototype-key matches

Second review pass found a latent bug:

Plain JS objects inherit `__proto__`, `constructor`, `toString`,
`hasOwnProperty`, etc. from Object.prototype. The previous
`if (projects[prefix])` check entered the routing branch for any of
these keys, mis-routing a user typing `ao spawn __proto__/42` with
`projectId: "__proto__"`. Downstream session-manager would then receive
a junk project object (Object.prototype itself) and fail with a
non-obvious error.

Fix: use `Object.prototype.hasOwnProperty.call(projects, prefix)` so
only actual configured project ids match.

Also:
- Document the case-sensitive matching semantic explicitly.
- Lock in the batch-spawn grouping contract by asserting exact
  `list()` and `spawn()` call counts in the cross-project test.
This commit is contained in:
Harsh Batheja 2026-04-22 22:18:39 +05:30 committed by GitHub
parent 331f1ce568
commit 616c56cea2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 529 additions and 80 deletions

View File

@ -71,7 +71,7 @@ let cwdSpy: ReturnType<typeof vi.spyOn> | undefined;
const STORAGE_KEY = "111111111113";
import { Command } from "commander";
import { registerSpawn } from "../../src/commands/spawn.js";
import { registerSpawn, registerBatchSpawn } from "../../src/commands/spawn.js";
let program: Command;
let consoleSpy: ReturnType<typeof vi.spyOn>;
@ -245,6 +245,140 @@ describe("spawn command", () => {
});
});
it("routes a <projectId>/<issue> identifier to the prefixed project", async () => {
// Multi-project config where AO is running for the default project
// but the issue belongs to a different project.
(mockConfigRef.current as Record<string, unknown>).projects = {
"agent-orchestrator": {
name: "Agent Orchestrator",
repo: "org/agent-orchestrator",
path: join(tmpDir, "agent-orchestrator"),
defaultBranch: "main",
sessionPrefix: "ao",
},
"x402-identity": {
name: "x402 Identity",
repo: "harsh-batheja/x402-identity",
path: join(tmpDir, "x402-identity"),
defaultBranch: "main",
sessionPrefix: "xid",
},
};
mkdirSync(join(tmpDir, "agent-orchestrator"), { recursive: true });
mkdirSync(join(tmpDir, "x402-identity"), { recursive: true });
// The cwd shouldn't change the result — prefix takes priority.
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(join(tmpDir, "agent-orchestrator"));
mockGetRunning.mockResolvedValue({
pid: 1234,
port: 3000,
startedAt: "",
projects: ["agent-orchestrator"],
});
const fakeSession: Session = {
id: "xid-1",
projectId: "x402-identity",
status: "spawning",
activity: null,
branch: "feat/issue-1",
issueId: "1",
pr: null,
workspacePath: "/tmp/wt",
runtimeHandle: { id: "hash-xid-1", runtimeName: "tmux", data: {} },
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
};
mockSessionManager.spawn.mockResolvedValue(fakeSession);
await program.parseAsync(["node", "test", "spawn", "x402-identity/1"]);
expect(mockSessionManager.spawn).toHaveBeenCalledWith({
projectId: "x402-identity",
issueId: "1",
});
});
it("routes via sessionPrefix when that matches instead of project id", async () => {
(mockConfigRef.current as Record<string, unknown>).projects = {
"agent-orchestrator": {
name: "Agent Orchestrator",
repo: "org/agent-orchestrator",
path: join(tmpDir, "agent-orchestrator"),
defaultBranch: "main",
sessionPrefix: "ao",
},
"x402-identity": {
name: "x402 Identity",
repo: "harsh-batheja/x402-identity",
path: join(tmpDir, "x402-identity"),
defaultBranch: "main",
sessionPrefix: "xid",
},
};
mkdirSync(join(tmpDir, "agent-orchestrator"), { recursive: true });
mkdirSync(join(tmpDir, "x402-identity"), { recursive: true });
cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(join(tmpDir, "agent-orchestrator"));
mockGetRunning.mockResolvedValue({
pid: 1234,
port: 3000,
startedAt: "",
projects: ["agent-orchestrator"],
});
const fakeSession: Session = {
id: "xid-2",
projectId: "x402-identity",
status: "spawning",
activity: null,
branch: "feat/issue-7",
issueId: "7",
pr: null,
workspacePath: "/tmp/wt",
runtimeHandle: { id: "hash-xid-2", runtimeName: "tmux", data: {} },
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
};
mockSessionManager.spawn.mockResolvedValue(fakeSession);
await program.parseAsync(["node", "test", "spawn", "xid/7"]);
expect(mockSessionManager.spawn).toHaveBeenCalledWith({
projectId: "x402-identity",
issueId: "7",
});
});
it("leaves the issueId untouched when the prefix is not a configured project", async () => {
const fakeSession: Session = {
id: "app-1",
projectId: "my-app",
status: "spawning",
activity: null,
branch: "feat/some-org-42",
issueId: "some-org/42",
pr: null,
workspacePath: "/tmp/wt",
runtimeHandle: { id: "hash-app-1", runtimeName: "tmux", data: {} },
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
};
mockSessionManager.spawn.mockResolvedValue(fakeSession);
await program.parseAsync(["node", "test", "spawn", "some-org/42"]);
expect(mockSessionManager.spawn).toHaveBeenCalledWith({
projectId: "my-app",
issueId: "some-org/42",
});
});
it("spawns without issueId when none provided", async () => {
const fakeSession: Session = {
id: "app-1",
@ -746,3 +880,142 @@ describe("spawn pre-flight checks", () => {
expect(errors).not.toContain("not authenticated");
});
});
describe("batch-spawn command", () => {
function setupBatch(): Command {
const cmd = new Command();
cmd.exitOverride();
registerBatchSpawn(cmd);
return cmd;
}
function makeFakeSession(overrides: Partial<Session> & Pick<Session, "id" | "projectId">): Session {
return {
status: "spawning",
activity: null,
branch: null,
issueId: null,
pr: null,
workspacePath: "/tmp/wt",
runtimeHandle: { id: `hash-${overrides.id}`, runtimeName: "tmux", data: {} },
agentInfo: null,
createdAt: new Date(),
lastActivityAt: new Date(),
metadata: {},
...overrides,
} as Session;
}
beforeEach(() => {
mockSessionManager.list.mockResolvedValue([]);
});
it("groups cross-project issues and routes each to the correct project", async () => {
(mockConfigRef.current as Record<string, unknown>).projects = {
"agent-orchestrator": {
name: "Agent Orchestrator",
repo: "org/agent-orchestrator",
path: join(tmpDir, "agent-orchestrator"),
defaultBranch: "main",
sessionPrefix: "ao",
},
"x402-identity": {
name: "x402 Identity",
repo: "harsh-batheja/x402-identity",
path: join(tmpDir, "x402-identity"),
defaultBranch: "main",
sessionPrefix: "xid",
},
};
mkdirSync(join(tmpDir, "agent-orchestrator"), { recursive: true });
mkdirSync(join(tmpDir, "x402-identity"), { recursive: true });
mockGetRunning.mockResolvedValue({
pid: 1234,
port: 3000,
startedAt: "",
projects: ["agent-orchestrator", "x402-identity"],
});
mockSessionManager.spawn
.mockResolvedValueOnce(makeFakeSession({ id: "ao-1", projectId: "agent-orchestrator" }))
.mockResolvedValueOnce(makeFakeSession({ id: "xid-1", projectId: "x402-identity" }));
const program = setupBatch();
await program.parseAsync([
"node",
"test",
"batch-spawn",
"agent-orchestrator/10",
"x402-identity/20",
]);
const spawnCalls = mockSessionManager.spawn.mock.calls.map((call) => call[0]);
expect(spawnCalls).toEqual(
expect.arrayContaining([
{ projectId: "agent-orchestrator", issueId: "10" },
{ projectId: "x402-identity", issueId: "20" },
]),
);
expect(mockSessionManager.list).toHaveBeenCalledWith("agent-orchestrator");
expect(mockSessionManager.list).toHaveBeenCalledWith("x402-identity");
// Exactly one list() per project group — locks the grouping contract so a
// regression that lists every project for every issue is caught.
expect(mockSessionManager.list).toHaveBeenCalledTimes(2);
expect(mockSessionManager.spawn).toHaveBeenCalledTimes(2);
});
it("skips a prefixed issue that already has an active session in the target project", async () => {
(mockConfigRef.current as Record<string, unknown>).projects = {
"agent-orchestrator": {
name: "Agent Orchestrator",
repo: "org/agent-orchestrator",
path: join(tmpDir, "agent-orchestrator"),
defaultBranch: "main",
sessionPrefix: "ao",
},
"x402-identity": {
name: "x402 Identity",
repo: "harsh-batheja/x402-identity",
path: join(tmpDir, "x402-identity"),
defaultBranch: "main",
sessionPrefix: "xid",
},
};
mkdirSync(join(tmpDir, "agent-orchestrator"), { recursive: true });
mkdirSync(join(tmpDir, "x402-identity"), { recursive: true });
// Pre-existing active session in x402-identity for issue 20
mockSessionManager.list.mockImplementation(async (pid: string) => {
if (pid === "x402-identity") {
return [
makeFakeSession({
id: "xid-9",
projectId: "x402-identity",
status: "working",
issueId: "20",
}),
];
}
return [];
});
mockSessionManager.spawn.mockResolvedValueOnce(
makeFakeSession({ id: "ao-2", projectId: "agent-orchestrator" }),
);
const program = setupBatch();
await program.parseAsync([
"node",
"test",
"batch-spawn",
"agent-orchestrator/10",
"x402-identity/20",
]);
expect(mockSessionManager.spawn).toHaveBeenCalledTimes(1);
expect(mockSessionManager.spawn).toHaveBeenCalledWith({
projectId: "agent-orchestrator",
issueId: "10",
});
});
});

View File

@ -4,6 +4,7 @@ import type { Command } from "commander";
import { resolve } from "node:path";
import {
loadConfig,
resolveSpawnTarget,
TERMINAL_STATUSES,
type OrchestratorConfig,
} from "@aoagents/ao-core";
@ -191,7 +192,10 @@ export function registerSpawn(program: Command): void {
program
.command("spawn")
.description("Spawn a single agent session")
.argument("[first]", "Issue identifier (project is auto-detected)")
.argument(
"[first]",
"Issue identifier. Accepts bare ids (42, INT-100) or prefixed forms (x402-identity/42, xid/42) to target a specific project by id or sessionPrefix.",
)
.argument("[second]", "" /* hidden second arg to catch old two-arg usage */)
.option("--open", "Open session in terminal tab")
.option("--agent <name>", "Override the agent plugin (e.g. codex, claude-code)")
@ -228,12 +232,18 @@ export function registerSpawn(program: Command): void {
let issueId: string | undefined;
if (first) {
issueId = first;
try {
projectId = autoDetectProject(config);
} catch (err) {
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
process.exit(1);
const prefixed = resolveSpawnTarget(config.projects, first);
if (prefixed) {
projectId = prefixed.projectId;
issueId = prefixed.issueId;
} else {
issueId = first;
try {
projectId = autoDetectProject(config);
} catch (err) {
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
process.exit(1);
}
}
} else {
// No args: auto-detect project, no issue
@ -272,97 +282,124 @@ export function registerBatchSpawn(program: Command): void {
program
.command("batch-spawn")
.description("Spawn sessions for multiple issues with duplicate detection")
.argument("<issues...>", "Issue identifiers (project is auto-detected)")
.argument(
"<issues...>",
"Issue identifiers. Accepts bare ids or prefixed forms (x402-identity/42, xid/42); mixed projects are grouped automatically.",
)
.option("--open", "Open sessions in terminal tabs")
.action(async (issues: string[], opts: { open?: boolean }) => {
const config = loadConfig();
let projectId: string;
try {
projectId = autoDetectProject(config);
} catch (err) {
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
process.exit(1);
// Resolve each issue to its target project. Issues without a prefix fall
// back to auto-detection; prefixed issues route to the matched project.
let fallbackProjectId: string | null = null;
const needsFallback = issues.some(
(issue) => resolveSpawnTarget(config.projects, issue) === null,
);
if (needsFallback) {
try {
fallbackProjectId = autoDetectProject(config);
} catch (err) {
console.error(chalk.red(err instanceof Error ? err.message : String(err)));
process.exit(1);
}
}
if (!config.projects[projectId]) {
console.error(
chalk.red(
`Unknown project: ${projectId}\nAvailable: ${Object.keys(config.projects).join(", ")}`,
),
);
process.exit(1);
// Group issues by resolved project so each group preflights once.
const groups = new Map<string, Array<{ original: string; resolved: string }>>();
for (const issue of issues) {
const target = resolveSpawnTarget(config.projects, issue, fallbackProjectId ?? undefined);
if (!target) {
console.error(chalk.red(`Could not resolve project for issue: ${issue}`));
process.exit(1);
}
if (!config.projects[target.projectId]) {
console.error(
chalk.red(
`Unknown project: ${target.projectId}\nAvailable: ${Object.keys(config.projects).join(", ")}`,
),
);
process.exit(1);
}
if (!groups.has(target.projectId)) groups.set(target.projectId, []);
groups.get(target.projectId)!.push({ original: issue, resolved: target.issueId });
}
console.log(banner("BATCH SESSION SPAWNER"));
console.log();
console.log(` Project: ${chalk.bold(projectId)}`);
console.log(` Issues: ${issues.join(", ")}`);
for (const [pid, items] of groups) {
console.log(
` ${chalk.bold(pid)}: ${items.map((i) => i.original).join(", ")}`,
);
}
console.log();
// Pre-flight once before the loop so a missing prerequisite fails fast
try {
await runSpawnPreflight(config, projectId);
await warnIfAONotRunning(projectId);
} catch (err) {
console.error(chalk.red(`${err instanceof Error ? err.message : String(err)}`));
process.exit(1);
}
const sm = await getSessionManager(config);
const created: Array<{ session: string; issue: string }> = [];
const skipped: Array<{ issue: string; existing: string }> = [];
const failed: Array<{ issue: string; error: string }> = [];
const spawnedIssues = new Set<string>();
// Load existing sessions once before the loop to avoid repeated reads + enrichment.
// Exclude terminal sessions so completed/merged sessions don't block respawning
// (e.g. when an issue is reopened after its PR was merged).
const existingSessions = await sm.list(projectId);
const existingIssueMap = new Map(
existingSessions
.filter((s) => s.issueId && !TERMINAL_STATUSES.has(s.status))
.map((s) => [s.issueId!.toLowerCase(), s.id]),
);
for (const issue of issues) {
// Duplicate detection — check both existing sessions and same-run duplicates
if (spawnedIssues.has(issue.toLowerCase())) {
console.log(chalk.yellow(` Skip ${issue} — duplicate in this batch`));
skipped.push({ issue, existing: "(this batch)" });
continue;
}
// Check existing sessions (pre-loaded before loop)
const existingSessionId = existingIssueMap.get(issue.toLowerCase());
if (existingSessionId) {
console.log(chalk.yellow(` Skip ${issue} — already has session ${existingSessionId}`));
skipped.push({ issue, existing: existingSessionId });
continue;
}
const sm = await getSessionManager(config);
for (const [groupProjectId, items] of groups) {
// Pre-flight once per project group so a missing prerequisite fails fast.
try {
const session = await sm.spawn({ projectId, issueId: issue });
created.push({ session: session.id, issue });
spawnedIssues.add(issue.toLowerCase());
console.log(chalk.green(` Created ${session.id} for ${issue}`));
if (opts.open) {
try {
const tmuxTarget = session.runtimeHandle?.id ?? session.id;
await exec("open-iterm-tab", [tmuxTarget]);
} catch {
// best effort
}
}
await runSpawnPreflight(config, groupProjectId);
await warnIfAONotRunning(groupProjectId);
} catch (err) {
failed.push({
issue,
error: err instanceof Error ? err.message : String(err),
});
console.log(
chalk.red(` Failed ${issue}${err instanceof Error ? err.message : String(err)}`),
);
console.error(chalk.red(`${err instanceof Error ? err.message : String(err)}`));
process.exit(1);
}
// Load existing sessions once per group (exclude terminal sessions so
// merged/completed sessions don't block respawning a reopened issue).
const existingSessions = await sm.list(groupProjectId);
const existingIssueMap = new Map(
existingSessions
.filter((s) => s.issueId && !TERMINAL_STATUSES.has(s.status))
.map((s) => [s.issueId!.toLowerCase(), s.id]),
);
const spawnedIssues = new Set<string>();
for (const { original, resolved } of items) {
if (spawnedIssues.has(resolved.toLowerCase())) {
console.log(chalk.yellow(` Skip ${original} — duplicate in this batch`));
skipped.push({ issue: original, existing: "(this batch)" });
continue;
}
const existingSessionId = existingIssueMap.get(resolved.toLowerCase());
if (existingSessionId) {
console.log(
chalk.yellow(` Skip ${original} — already has session ${existingSessionId}`),
);
skipped.push({ issue: original, existing: existingSessionId });
continue;
}
try {
const session = await sm.spawn({ projectId: groupProjectId, issueId: resolved });
created.push({ session: session.id, issue: original });
spawnedIssues.add(resolved.toLowerCase());
console.log(chalk.green(` Created ${session.id} for ${original}`));
if (opts.open) {
try {
const tmuxTarget = session.runtimeHandle?.id ?? session.id;
await exec("open-iterm-tab", [tmuxTarget]);
} catch {
// best effort
}
}
} catch (err) {
failed.push({
issue: original,
error: err instanceof Error ? err.message : String(err),
});
console.log(
chalk.red(
` Failed ${original}${err instanceof Error ? err.message : String(err)}`,
),
);
}
}
}

View File

@ -0,0 +1,89 @@
import { describe, expect, it } from "vitest";
import { resolveSpawnTarget } from "../spawn-target.js";
import type { ProjectConfig } from "../types.js";
const baseProject: Omit<ProjectConfig, "name" | "path" | "sessionPrefix"> = {
defaultBranch: "main",
};
function makeProjects(): Record<string, ProjectConfig> {
return {
"agent-orchestrator": {
...baseProject,
name: "Agent Orchestrator",
path: "/tmp/ao",
sessionPrefix: "ao",
},
"x402-identity": {
...baseProject,
name: "x402 Identity",
path: "/tmp/xid",
sessionPrefix: "xid",
},
};
}
describe("resolveSpawnTarget", () => {
it("routes to the project matched by id prefix", () => {
const target = resolveSpawnTarget(makeProjects(), "x402-identity/1");
expect(target).toEqual({ projectId: "x402-identity", issueId: "1" });
});
it("routes to the project matched by sessionPrefix", () => {
const target = resolveSpawnTarget(makeProjects(), "xid/42");
expect(target).toEqual({ projectId: "x402-identity", issueId: "42" });
});
it("prefers project-id match over sessionPrefix collision", () => {
const projects = makeProjects();
// Give the other project a sessionPrefix that collides with the first project's id
projects["x402-identity"].sessionPrefix = "agent-orchestrator";
const target = resolveSpawnTarget(projects, "agent-orchestrator/9");
expect(target).toEqual({ projectId: "agent-orchestrator", issueId: "9" });
});
it("falls back to the fallback project when the prefix doesn't match", () => {
const target = resolveSpawnTarget(makeProjects(), "some-org/42", "agent-orchestrator");
expect(target).toEqual({ projectId: "agent-orchestrator", issueId: "some-org/42" });
});
it("returns null without a fallback when nothing matches", () => {
expect(resolveSpawnTarget(makeProjects(), "some-org/42")).toBeNull();
});
it("treats a bare issue id as plain identifier", () => {
const target = resolveSpawnTarget(makeProjects(), "INT-100", "x402-identity");
expect(target).toEqual({ projectId: "x402-identity", issueId: "INT-100" });
});
it("does not match inherited prototype keys", () => {
// Regular plain objects inherit `__proto__`, `constructor`, `toString`, etc.
// from Object.prototype — a truthy `projects[prefix]` check without hasOwn
// would mis-route these.
const projects = makeProjects();
expect(resolveSpawnTarget(projects, "__proto__/42", "agent-orchestrator")).toEqual({
projectId: "agent-orchestrator",
issueId: "__proto__/42",
});
expect(resolveSpawnTarget(projects, "constructor/42", "agent-orchestrator")).toEqual({
projectId: "agent-orchestrator",
issueId: "constructor/42",
});
expect(resolveSpawnTarget(projects, "toString/42", "agent-orchestrator")).toEqual({
projectId: "agent-orchestrator",
issueId: "toString/42",
});
});
it("ignores leading-slash and trailing-slash inputs", () => {
expect(resolveSpawnTarget(makeProjects(), "/42", "agent-orchestrator")).toEqual({
projectId: "agent-orchestrator",
issueId: "/42",
});
expect(resolveSpawnTarget(makeProjects(), "x402-identity/", "agent-orchestrator")).toEqual({
projectId: "agent-orchestrator",
issueId: "x402-identity/",
});
});
});

View File

@ -156,6 +156,8 @@ export {
writeWorkspaceOpenCodeAgentsMd,
} from "./opencode-agents-md.js";
export { normalizeOrchestratorSessionStrategy } from "./orchestrator-session-strategy.js";
export { resolveSpawnTarget } from "./spawn-target.js";
export type { SpawnTarget } from "./spawn-target.js";
// Activity log — JSONL activity tracking for agents without native JSONL
export {

View File

@ -0,0 +1,48 @@
import type { ProjectConfig } from "./types.js";
export interface SpawnTarget {
projectId: string;
issueId: string;
}
/**
* Parse a possibly-prefixed issue reference into a `{ projectId, issueId }` pair.
*
* When the reference is of the form `<prefix>/<rest>` and `<prefix>` matches a
* configured project id or `sessionPrefix`, the spawn is routed to that project
* with `<rest>` as the issue id. Otherwise the reference is treated as a plain
* issue id and the fallback project is used.
*
* - Matching is case-sensitive yaml keys and `sessionPrefix` values are
* compared literally.
* - Exact project-id match wins over `sessionPrefix` match (yaml keys are
* unique; `sessionPrefix` values are not guaranteed to be).
* - Returns `null` when no prefix routing matches and no fallback is provided.
* - Empty `<rest>` (trailing slash) is treated as no prefix the full string
* is passed through as the issue id.
*/
export function resolveSpawnTarget(
projects: Record<string, ProjectConfig>,
issueRef: string,
fallbackProjectId?: string,
): SpawnTarget | null {
const slashIdx = issueRef.indexOf("/");
if (slashIdx > 0 && slashIdx < issueRef.length - 1) {
const prefix = issueRef.slice(0, slashIdx);
const rest = issueRef.slice(slashIdx + 1);
// hasOwn guards against prototype keys (`__proto__`, `constructor`, …)
// incorrectly matching via inheritance from Object.prototype.
if (Object.prototype.hasOwnProperty.call(projects, prefix)) {
return { projectId: prefix, issueId: rest };
}
for (const [pid, project] of Object.entries(projects)) {
if (project.sessionPrefix === prefix) {
return { projectId: pid, issueId: rest };
}
}
}
if (!fallbackProjectId) return null;
return { projectId: fallbackProjectId, issueId: issueRef };
}