feat(missions): per-mission taskPrefix override for triaged task ids (#2347)
## Summary Maintainer re-land of [#2334](https://github.com/Runfusion/Fusion/pull/2334) (fork `flexi767:feat/per-mission-task-prefix`) after resolving merge conflicts with current `main`. Fork push was unavailable despite `maintainerCanModify`, so this branch carries the conflict resolution. ### Feature - Optional per-mission `taskPrefix` for triaged task ids (inherits project prefix when unset) - Dashboard MissionManager + routes + store/triage plumbing - Postgres migration for `project.missions.task_prefix` ### Conflict resolution - Main claimed migration **0026** (bigint counters) and **0027** (workflow IR pin) - Mission task-prefix migration renumbered **0026 → 0028** - Baseline `0000_initial.sql` includes `task_prefix` on missions - `legacy.ts` keeps code-org re-exports; `missions.ts` carries `taskPrefix` on create/update types ## Test plan - [ ] CI green (lint/typecheck/build/gate) - [ ] Create mission with custom prefix; triage feature → task ids use that prefix - [ ] Clear mission prefix via PATCH null; new tasks inherit project prefix Closes / supersedes #2334 once this lands (or re-point the fork PR). <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Missions can now set an optional per-mission task ID prefix (overriding the project default). * Added task prefix support to mission create/edit UI and dashboard APIs, including normalized uppercase values and validation. * **Bug Fixes** * Improved commit hook generation for custom prefixes and special characters, with safer shell handling to prevent unsafe interpretation. * **Chores** * Added PostgreSQL migration and schema-applier support to persist and propagate mission task prefixes, including upgrade/backfill coverage. * **Tests** * Added backend and UI/API test coverage for task-prefix creation, clearing, and ID minting behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
7
.changeset/mission-task-prefix.md
Normal file
7
.changeset/mission-task-prefix.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Allow missions to override the project task prefix for triaged task IDs.
|
||||||
|
category: feature
|
||||||
|
dev: Persists an optional mission prefix and threads it through distributed task-ID allocation and commit hooks.
|
||||||
@@ -107,6 +107,12 @@ Precedence order during triage:
|
|||||||
2. Mission `baseBranch`
|
2. Mission `baseBranch`
|
||||||
3. Project default branch resolution
|
3. Project default branch resolution
|
||||||
|
|
||||||
|
### Mission task prefix override
|
||||||
|
|
||||||
|
Missions support an optional `taskPrefix` field. When set, feature triage (`triageFeature` / `triageSlice`) passes it as a transient minting hint on `TaskCreateInput` so the distributed task-id allocator issues ids under that prefix. When unset or cleared, triage inherits the project-wide `settings.taskPrefix`.
|
||||||
|
|
||||||
|
The Mission Manager create/edit form exposes this as **Task prefix** (empty = project default). Clearing a previously saved prefix on edit sends `taskPrefix: null` so the stored override is removed.
|
||||||
|
|
||||||
### Mission branch strategy defaults
|
### Mission branch strategy defaults
|
||||||
|
|
||||||
Missions can also persist a `branchStrategy` used whenever triage is triggered without explicit branch options (manual triage and autopilot triage).
|
Missions can also persist a `branchStrategy` used whenever triage is triggered without explicit branch options (manual triage and autopilot triage).
|
||||||
|
|||||||
@@ -883,4 +883,41 @@ pgTest("MissionStore (PostgreSQL backend mode)", () => {
|
|||||||
expect(await m.getMissionWithHierarchy("M-DOES-NOT-EXIST")).toBeUndefined();
|
expect(await m.getMissionWithHierarchy("M-DOES-NOT-EXIST")).toBeUndefined();
|
||||||
expect(await m.getMissionHealth("M-DOES-NOT-EXIST")).toBeUndefined();
|
expect(await m.getMissionHealth("M-DOES-NOT-EXIST")).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:MissionTaskPrefix 2026-07-26-12:00:
|
||||||
|
Per-mission taskPrefix round-trips on create/update and clears to NULL so triage re-inherits the project prefix (PR #1930 / #2347).
|
||||||
|
*/
|
||||||
|
it("round-trips mission taskPrefix on create/update and clears to undefined", async () => {
|
||||||
|
const m = missions();
|
||||||
|
const mission = await m.createMission({ title: "Prefixed", taskPrefix: "ERR" });
|
||||||
|
expect((await m.getMission(mission.id))?.taskPrefix).toBe("ERR");
|
||||||
|
|
||||||
|
const updated = await m.updateMission(mission.id, { taskPrefix: "BUG" });
|
||||||
|
expect(updated.taskPrefix).toBe("BUG");
|
||||||
|
expect((await m.getMission(mission.id))?.taskPrefix).toBe("BUG");
|
||||||
|
|
||||||
|
const cleared = await m.updateMission(mission.id, { taskPrefix: undefined });
|
||||||
|
expect(cleared.taskPrefix).toBeUndefined();
|
||||||
|
expect((await m.getMission(mission.id))?.taskPrefix).toBeUndefined();
|
||||||
|
|
||||||
|
const raw = (await h.layer().db.execute(
|
||||||
|
sql`SELECT task_prefix FROM project.missions WHERE id = ${mission.id}`,
|
||||||
|
)) as unknown as Array<{ task_prefix: string | null }>;
|
||||||
|
expect(raw[0]?.task_prefix).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("mints triaged task ids with the mission's taskPrefix", async () => {
|
||||||
|
const m = missions();
|
||||||
|
await h.store().updateSettings({ taskPrefix: "FN" });
|
||||||
|
const mission = await m.createMission({ title: "Mission", taskPrefix: "ERR" });
|
||||||
|
const milestone = await m.addMilestone(mission.id, { title: "MS" });
|
||||||
|
const slice = await m.addSlice(milestone.id, { title: "SL" });
|
||||||
|
const feature = await m.addFeature(slice.id, { title: "Ship prefix", acceptanceCriteria: "id uses ERR" });
|
||||||
|
|
||||||
|
const triaged = await m.triageFeature(feature.id);
|
||||||
|
expect(triaged.taskId).toBeTruthy();
|
||||||
|
expect(triaged.taskId).toMatch(/^ERR-\d+$/);
|
||||||
|
});
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -80,6 +80,7 @@ import {
|
|||||||
MISSION_LINEAGE_STOP_VERSION,
|
MISSION_LINEAGE_STOP_VERSION,
|
||||||
CHAT_SESSION_TAGS_VERSION,
|
CHAT_SESSION_TAGS_VERSION,
|
||||||
DROP_GLOBAL_CONCURRENCY_VERSION,
|
DROP_GLOBAL_CONCURRENCY_VERSION,
|
||||||
|
MISSION_TASK_PREFIX_VERSION,
|
||||||
} from "../../postgres/schema-applier.js";
|
} from "../../postgres/schema-applier.js";
|
||||||
import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js";
|
import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.js";
|
||||||
import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js";
|
import type { PluginSchemaInitHook } from "../../postgres/plugin-schema-hook.js";
|
||||||
@@ -1677,6 +1678,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
|||||||
MISSION_LINEAGE_STOP_VERSION,
|
MISSION_LINEAGE_STOP_VERSION,
|
||||||
CHAT_SESSION_TAGS_VERSION,
|
CHAT_SESSION_TAGS_VERSION,
|
||||||
DROP_GLOBAL_CONCURRENCY_VERSION,
|
DROP_GLOBAL_CONCURRENCY_VERSION,
|
||||||
|
MISSION_TASK_PREFIX_VERSION,
|
||||||
]);
|
]);
|
||||||
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
|
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
|
||||||
});
|
});
|
||||||
@@ -1740,6 +1742,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
|||||||
MISSION_LINEAGE_STOP_VERSION,
|
MISSION_LINEAGE_STOP_VERSION,
|
||||||
CHAT_SESSION_TAGS_VERSION,
|
CHAT_SESSION_TAGS_VERSION,
|
||||||
DROP_GLOBAL_CONCURRENCY_VERSION,
|
DROP_GLOBAL_CONCURRENCY_VERSION,
|
||||||
|
MISSION_TASK_PREFIX_VERSION,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1936,6 +1939,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
|||||||
MISSION_LINEAGE_STOP_VERSION,
|
MISSION_LINEAGE_STOP_VERSION,
|
||||||
CHAT_SESSION_TAGS_VERSION,
|
CHAT_SESSION_TAGS_VERSION,
|
||||||
DROP_GLOBAL_CONCURRENCY_VERSION,
|
DROP_GLOBAL_CONCURRENCY_VERSION,
|
||||||
|
MISSION_TASK_PREFIX_VERSION,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2013,6 +2017,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
|||||||
MISSION_LINEAGE_STOP_VERSION,
|
MISSION_LINEAGE_STOP_VERSION,
|
||||||
CHAT_SESSION_TAGS_VERSION,
|
CHAT_SESSION_TAGS_VERSION,
|
||||||
DROP_GLOBAL_CONCURRENCY_VERSION,
|
DROP_GLOBAL_CONCURRENCY_VERSION,
|
||||||
|
MISSION_TASK_PREFIX_VERSION,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2090,6 +2095,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
|||||||
MISSION_LINEAGE_STOP_VERSION,
|
MISSION_LINEAGE_STOP_VERSION,
|
||||||
CHAT_SESSION_TAGS_VERSION,
|
CHAT_SESSION_TAGS_VERSION,
|
||||||
DROP_GLOBAL_CONCURRENCY_VERSION,
|
DROP_GLOBAL_CONCURRENCY_VERSION,
|
||||||
|
MISSION_TASK_PREFIX_VERSION,
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -124,6 +124,8 @@ interface MissionRow {
|
|||||||
interviewState: string;
|
interviewState: string;
|
||||||
baseBranch: string | null;
|
baseBranch: string | null;
|
||||||
branchStrategy: string | null;
|
branchStrategy: string | null;
|
||||||
|
/** Per-mission ticket id prefix; null/absent inherits project settings.taskPrefix. */
|
||||||
|
taskPrefix: string | null;
|
||||||
autoMerge: number | null;
|
autoMerge: number | null;
|
||||||
autoAdvance: number | null;
|
autoAdvance: number | null;
|
||||||
autopilotEnabled: number | null;
|
autopilotEnabled: number | null;
|
||||||
@@ -284,6 +286,7 @@ const missionColumns = {
|
|||||||
interviewState: schema.project.missions.interviewState,
|
interviewState: schema.project.missions.interviewState,
|
||||||
baseBranch: schema.project.missions.baseBranch,
|
baseBranch: schema.project.missions.baseBranch,
|
||||||
branchStrategy: schema.project.missions.branchStrategy,
|
branchStrategy: schema.project.missions.branchStrategy,
|
||||||
|
taskPrefix: schema.project.missions.taskPrefix,
|
||||||
autoMerge: schema.project.missions.autoMerge,
|
autoMerge: schema.project.missions.autoMerge,
|
||||||
autoAdvance: schema.project.missions.autoAdvance,
|
autoAdvance: schema.project.missions.autoAdvance,
|
||||||
autopilotEnabled: schema.project.missions.autopilotEnabled,
|
autopilotEnabled: schema.project.missions.autopilotEnabled,
|
||||||
@@ -438,6 +441,8 @@ function rowToMission(row: MissionRow): Mission {
|
|||||||
interviewState: row.interviewState as InterviewState,
|
interviewState: row.interviewState as InterviewState,
|
||||||
baseBranch: row.baseBranch ?? undefined,
|
baseBranch: row.baseBranch ?? undefined,
|
||||||
branchStrategy,
|
branchStrategy,
|
||||||
|
// FNXC:MissionTaskPrefix 2026-07-26-12:00: match other nullable text fields (?? not ||) so empty-string rows stay distinguishable if validation ever relaxes.
|
||||||
|
taskPrefix: row.taskPrefix ?? undefined,
|
||||||
autoMerge: row.autoMerge === null ? undefined : Boolean(row.autoMerge),
|
autoMerge: row.autoMerge === null ? undefined : Boolean(row.autoMerge),
|
||||||
autoAdvance: Boolean(row.autoAdvance ?? 0),
|
autoAdvance: Boolean(row.autoAdvance ?? 0),
|
||||||
autopilotEnabled: Boolean(row.autopilotEnabled ?? 0),
|
autopilotEnabled: Boolean(row.autopilotEnabled ?? 0),
|
||||||
@@ -642,6 +647,8 @@ export async function createMission(
|
|||||||
interviewState: input.interviewState,
|
interviewState: input.interviewState,
|
||||||
baseBranch: input.baseBranch ?? null,
|
baseBranch: input.baseBranch ?? null,
|
||||||
branchStrategy: serializeBranchStrategy(input.branchStrategy),
|
branchStrategy: serializeBranchStrategy(input.branchStrategy),
|
||||||
|
// FNXC:MissionTaskPrefix 2026-07-26-12:00: persist optional per-mission minting prefix; undefined/null stores NULL so triage inherits the project prefix.
|
||||||
|
taskPrefix: input.taskPrefix ?? null,
|
||||||
autoMerge: input.autoMerge === undefined ? null : input.autoMerge ? 1 : 0,
|
autoMerge: input.autoMerge === undefined ? null : input.autoMerge ? 1 : 0,
|
||||||
autoAdvance: input.autoAdvance ? 1 : 0,
|
autoAdvance: input.autoAdvance ? 1 : 0,
|
||||||
autopilotEnabled: input.autopilotEnabled ? 1 : 0,
|
autopilotEnabled: input.autopilotEnabled ? 1 : 0,
|
||||||
@@ -689,6 +696,8 @@ export async function updateMission(
|
|||||||
interviewState: mission.interviewState,
|
interviewState: mission.interviewState,
|
||||||
baseBranch: mission.baseBranch ?? null,
|
baseBranch: mission.baseBranch ?? null,
|
||||||
branchStrategy: serializeBranchStrategy(mission.branchStrategy),
|
branchStrategy: serializeBranchStrategy(mission.branchStrategy),
|
||||||
|
// FNXC:MissionTaskPrefix 2026-07-26-12:00: write NULL when cleared so the mission re-inherits the project prefix.
|
||||||
|
taskPrefix: mission.taskPrefix ?? null,
|
||||||
autoMerge: mission.autoMerge === undefined ? null : mission.autoMerge ? 1 : 0,
|
autoMerge: mission.autoMerge === undefined ? null : mission.autoMerge ? 1 : 0,
|
||||||
autoAdvance: mission.autoAdvance ? 1 : 0,
|
autoAdvance: mission.autoAdvance ? 1 : 0,
|
||||||
autopilotEnabled: mission.autopilotEnabled ? 1 : 0,
|
autopilotEnabled: mission.autopilotEnabled ? 1 : 0,
|
||||||
@@ -1878,6 +1887,7 @@ export async function upsertMission(handle: QueryHandle, mission: Mission): Prom
|
|||||||
interviewState: mission.interviewState,
|
interviewState: mission.interviewState,
|
||||||
baseBranch: mission.baseBranch ?? null,
|
baseBranch: mission.baseBranch ?? null,
|
||||||
branchStrategy: serializeBranchStrategy(mission.branchStrategy),
|
branchStrategy: serializeBranchStrategy(mission.branchStrategy),
|
||||||
|
taskPrefix: mission.taskPrefix ?? null,
|
||||||
autoMerge: mission.autoMerge === undefined ? null : mission.autoMerge ? 1 : 0,
|
autoMerge: mission.autoMerge === undefined ? null : mission.autoMerge ? 1 : 0,
|
||||||
autoAdvance: mission.autoAdvance ? 1 : 0,
|
autoAdvance: mission.autoAdvance ? 1 : 0,
|
||||||
autopilotEnabled: mission.autopilotEnabled ? 1 : 0,
|
autopilotEnabled: mission.autopilotEnabled ? 1 : 0,
|
||||||
@@ -1895,6 +1905,7 @@ export async function upsertMission(handle: QueryHandle, mission: Mission): Prom
|
|||||||
interviewState: sql`excluded.interview_state`,
|
interviewState: sql`excluded.interview_state`,
|
||||||
baseBranch: sql`excluded.base_branch`,
|
baseBranch: sql`excluded.base_branch`,
|
||||||
branchStrategy: sql`excluded.branch_strategy`,
|
branchStrategy: sql`excluded.branch_strategy`,
|
||||||
|
taskPrefix: sql`excluded.task_prefix`,
|
||||||
autoMerge: sql`excluded.auto_merge`,
|
autoMerge: sql`excluded.auto_merge`,
|
||||||
autoAdvance: sql`excluded.auto_advance`,
|
autoAdvance: sql`excluded.auto_advance`,
|
||||||
autopilotEnabled: sql`excluded.autopilot_enabled`,
|
autopilotEnabled: sql`excluded.autopilot_enabled`,
|
||||||
|
|||||||
@@ -261,6 +261,7 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
description: input.description,
|
description: input.description,
|
||||||
baseBranch: input.baseBranch,
|
baseBranch: input.baseBranch,
|
||||||
branchStrategy: input.branchStrategy,
|
branchStrategy: input.branchStrategy,
|
||||||
|
taskPrefix: input.taskPrefix,
|
||||||
autoMerge: input.autoMerge,
|
autoMerge: input.autoMerge,
|
||||||
status: "planning",
|
status: "planning",
|
||||||
interviewState: "not_started",
|
interviewState: "not_started",
|
||||||
@@ -2300,6 +2301,8 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
An autoMerge:false mission stamps each newly triaged task so its shared branch produces one PR instead of per-task auto-merges. Duplicate reuse intentionally bypasses this create-only override.
|
An autoMerge:false mission stamps each newly triaged task so its shared branch produces one PR instead of per-task auto-merges. Duplicate reuse intentionally bypasses this create-only override.
|
||||||
*/
|
*/
|
||||||
...(mission?.autoMerge === false ? { autoMerge: false } : {}),
|
...(mission?.autoMerge === false ? { autoMerge: false } : {}),
|
||||||
|
// FNXC:MissionTaskPrefix 2026-07-26-12:00: thread the mission's optional taskPrefix into TaskCreateInput so the distributed allocator mints ERR-N (etc.) instead of the project prefix.
|
||||||
|
...(mission?.taskPrefix ? { taskPrefix: mission.taskPrefix } : {}),
|
||||||
...(branchOptions?.workflowId !== undefined ? { workflowId: branchOptions.workflowId } : {}),
|
...(branchOptions?.workflowId !== undefined ? { workflowId: branchOptions.workflowId } : {}),
|
||||||
});
|
});
|
||||||
if (guard.fingerprint) {
|
if (guard.fingerprint) {
|
||||||
|
|||||||
@@ -234,6 +234,7 @@ interface MissionRow {
|
|||||||
interviewState: string;
|
interviewState: string;
|
||||||
baseBranch: string | null;
|
baseBranch: string | null;
|
||||||
branchStrategy: string | null;
|
branchStrategy: string | null;
|
||||||
|
taskPrefix: string | null;
|
||||||
autoMerge: number | null;
|
autoMerge: number | null;
|
||||||
autoAdvance: number;
|
autoAdvance: number;
|
||||||
autopilotEnabled: number;
|
autopilotEnabled: number;
|
||||||
@@ -406,6 +407,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
super();
|
super();
|
||||||
this.setMaxListeners(100);
|
this.setMaxListeners(100);
|
||||||
this.ensureMissionContractAssertionColumns();
|
this.ensureMissionContractAssertionColumns();
|
||||||
|
this.ensureMissionTaskPrefixColumn();
|
||||||
// Initialize sequence counter from existing events to ensure uniqueness across restarts
|
// Initialize sequence counter from existing events to ensure uniqueness across restarts
|
||||||
const lastEvent = this.db.prepare(`
|
const lastEvent = this.db.prepare(`
|
||||||
SELECT seq FROM mission_events ORDER BY seq DESC LIMIT 1
|
SELECT seq FROM mission_events ORDER BY seq DESC LIMIT 1
|
||||||
@@ -452,6 +454,24 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
`).run();
|
`).run();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:MissionTaskPrefix 2026-07-26-12:00:
|
||||||
|
Sync-store test doubles and residual SQLite surfaces need the optional column
|
||||||
|
before create/update write taskPrefix. Additive IF-missing ALTER keeps older
|
||||||
|
in-memory fixtures usable without a full schema rebuild.
|
||||||
|
*/
|
||||||
|
private ensureMissionTaskPrefixColumn(): void {
|
||||||
|
const schemaStatement = this.db.prepare("PRAGMA table_info(missions)") as unknown as {
|
||||||
|
all?: () => Array<{ name?: string }>;
|
||||||
|
};
|
||||||
|
const columns = schemaStatement.all?.();
|
||||||
|
if (!Array.isArray(columns) || columns.length === 0) return;
|
||||||
|
const names = new Set(columns.map((column) => column.name));
|
||||||
|
if (!names.has("taskPrefix")) {
|
||||||
|
this.db.prepare("ALTER TABLE missions ADD COLUMN taskPrefix TEXT").run();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Row-to-Object Converters ───────────────────────────────────────
|
// ── Row-to-Object Converters ───────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -475,6 +495,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
interviewState: row.interviewState as InterviewState,
|
interviewState: row.interviewState as InterviewState,
|
||||||
baseBranch: row.baseBranch || undefined,
|
baseBranch: row.baseBranch || undefined,
|
||||||
branchStrategy,
|
branchStrategy,
|
||||||
|
// FNXC:MissionTaskPrefix 2026-07-26-12:00: match nullable text fields (?? not ||); keep parity with async-mission-store-queries rowToMission.
|
||||||
|
taskPrefix: row.taskPrefix ?? undefined,
|
||||||
autoMerge: row.autoMerge === null ? undefined : Boolean(row.autoMerge),
|
autoMerge: row.autoMerge === null ? undefined : Boolean(row.autoMerge),
|
||||||
autoAdvance: Boolean(row.autoAdvance),
|
autoAdvance: Boolean(row.autoAdvance),
|
||||||
autopilotEnabled: Boolean(row.autopilotEnabled),
|
autopilotEnabled: Boolean(row.autopilotEnabled),
|
||||||
@@ -703,6 +725,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
interviewState: "not_started",
|
interviewState: "not_started",
|
||||||
baseBranch: input.baseBranch,
|
baseBranch: input.baseBranch,
|
||||||
branchStrategy: input.branchStrategy,
|
branchStrategy: input.branchStrategy,
|
||||||
|
taskPrefix: input.taskPrefix,
|
||||||
autoMerge: input.autoMerge,
|
autoMerge: input.autoMerge,
|
||||||
autoAdvance: false,
|
autoAdvance: false,
|
||||||
autopilotEnabled: false,
|
autopilotEnabled: false,
|
||||||
@@ -712,8 +735,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
INSERT INTO missions (id, title, description, status, interviewState, baseBranch, branchStrategy, autoMerge, autoAdvance, autopilotEnabled, autopilotState, createdAt, updatedAt)
|
INSERT INTO missions (id, title, description, status, interviewState, baseBranch, branchStrategy, taskPrefix, autoMerge, autoAdvance, autopilotEnabled, autopilotState, createdAt, updatedAt)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
mission.id,
|
mission.id,
|
||||||
mission.title,
|
mission.title,
|
||||||
@@ -722,6 +745,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
mission.interviewState,
|
mission.interviewState,
|
||||||
mission.baseBranch ?? null,
|
mission.baseBranch ?? null,
|
||||||
mission.branchStrategy ? JSON.stringify(mission.branchStrategy) : null,
|
mission.branchStrategy ? JSON.stringify(mission.branchStrategy) : null,
|
||||||
|
mission.taskPrefix ?? null,
|
||||||
mission.autoMerge === undefined ? null : (mission.autoMerge ? 1 : 0),
|
mission.autoMerge === undefined ? null : (mission.autoMerge ? 1 : 0),
|
||||||
mission.autoAdvance ? 1 : 0,
|
mission.autoAdvance ? 1 : 0,
|
||||||
mission.autopilotEnabled ? 1 : 0,
|
mission.autopilotEnabled ? 1 : 0,
|
||||||
@@ -1343,11 +1367,12 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
UPDATE missions SET
|
UPDATE missions SET
|
||||||
title = ?, description = ?, status = ?, interviewState = ?, baseBranch = ?, branchStrategy = ?,
|
title = ?, description = ?, status = ?, interviewState = ?, baseBranch = ?, branchStrategy = ?,
|
||||||
autoMerge = ?, autoAdvance = ?, autopilotEnabled = ?, autopilotState = ?,
|
taskPrefix = ?, autoMerge = ?, autoAdvance = ?, autopilotEnabled = ?, autopilotState = ?,
|
||||||
lastAutopilotActivityAt = ?, updatedAt = ? WHERE id = ?
|
lastAutopilotActivityAt = ?, updatedAt = ? WHERE id = ?
|
||||||
`).run(
|
`).run(
|
||||||
updated.title, updated.description ?? null, updated.status, updated.interviewState,
|
updated.title, updated.description ?? null, updated.status, updated.interviewState,
|
||||||
updated.baseBranch ?? null, updated.branchStrategy ? JSON.stringify(updated.branchStrategy) : null,
|
updated.baseBranch ?? null, updated.branchStrategy ? JSON.stringify(updated.branchStrategy) : null,
|
||||||
|
updated.taskPrefix ?? null,
|
||||||
updated.autoMerge === undefined ? null : (updated.autoMerge ? 1 : 0), updated.autoAdvance ? 1 : 0,
|
updated.autoMerge === undefined ? null : (updated.autoMerge ? 1 : 0), updated.autoAdvance ? 1 : 0,
|
||||||
updated.autopilotEnabled ? 1 : 0, updated.autopilotState ?? "inactive",
|
updated.autopilotEnabled ? 1 : 0, updated.autopilotState ?? "inactive",
|
||||||
updated.lastAutopilotActivityAt ?? null, updated.updatedAt, updated.id,
|
updated.lastAutopilotActivityAt ?? null, updated.updatedAt, updated.id,
|
||||||
@@ -4224,6 +4249,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
|||||||
An autoMerge:false mission stamps each newly triaged task so its shared branch produces one PR instead of per-task auto-merges. Duplicate reuse intentionally bypasses this create-only override.
|
An autoMerge:false mission stamps each newly triaged task so its shared branch produces one PR instead of per-task auto-merges. Duplicate reuse intentionally bypasses this create-only override.
|
||||||
*/
|
*/
|
||||||
...(mission?.autoMerge === false ? { autoMerge: false } : {}),
|
...(mission?.autoMerge === false ? { autoMerge: false } : {}),
|
||||||
|
// FNXC:MissionTaskPrefix 2026-07-26-12:00: thread the mission's optional taskPrefix into TaskCreateInput for distributed id minting.
|
||||||
|
...(mission?.taskPrefix ? { taskPrefix: mission.taskPrefix } : {}),
|
||||||
...(branchOptions?.workflowId !== undefined ? { workflowId: branchOptions.workflowId } : {}),
|
...(branchOptions?.workflowId !== undefined ? { workflowId: branchOptions.workflowId } : {}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -310,6 +310,11 @@ export interface Mission {
|
|||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
/** Mission triage branch strategy: auto-per-task => assignmentMode "per-task-derived"; existing/custom-new => shared branchName; project-default/absent => shared default behavior. */
|
/** Mission triage branch strategy: auto-per-task => assignmentMode "per-task-derived"; existing/custom-new => shared branchName; project-default/absent => shared default behavior. */
|
||||||
branchStrategy?: MissionBranchStrategy;
|
branchStrategy?: MissionBranchStrategy;
|
||||||
|
/**
|
||||||
|
* FNXC:MissionTaskPrefix 2026-07-26-12:00:
|
||||||
|
* Per-mission ticket id prefix for triaged tasks (e.g. "ERR"). Absent => inherit the project-wide taskPrefix setting.
|
||||||
|
*/
|
||||||
|
taskPrefix?: string;
|
||||||
/** State of the AI specification interview process */
|
/** State of the AI specification interview process */
|
||||||
interviewState: InterviewState;
|
interviewState: InterviewState;
|
||||||
/**
|
/**
|
||||||
@@ -579,6 +584,11 @@ export interface MissionCreateInput {
|
|||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
/** Optional branch strategy applied as the default for mission triage operations. */
|
/** Optional branch strategy applied as the default for mission triage operations. */
|
||||||
branchStrategy?: MissionBranchStrategy;
|
branchStrategy?: MissionBranchStrategy;
|
||||||
|
/**
|
||||||
|
* FNXC:MissionTaskPrefix 2026-07-26-12:00:
|
||||||
|
* Optional per-mission ticket id prefix for triaged tasks (e.g. "ERR"); inherits the project setting when absent.
|
||||||
|
*/
|
||||||
|
taskPrefix?: string;
|
||||||
/** Optional mission-level auto-merge override for linked task branches. */
|
/** Optional mission-level auto-merge override for linked task branches. */
|
||||||
autoMerge?: boolean;
|
autoMerge?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -872,6 +872,8 @@ CREATE TABLE IF NOT EXISTS project.missions (
|
|||||||
branch_strategy text,
|
branch_strategy text,
|
||||||
auto_advance integer DEFAULT 0,
|
auto_advance integer DEFAULT 0,
|
||||||
auto_merge integer,
|
auto_merge integer,
|
||||||
|
-- FNXC:MissionTaskPrefix 2026-07-26-12:00: optional per-mission ticket prefix; NULL inherits project settings.taskPrefix
|
||||||
|
task_prefix text,
|
||||||
autopilot_enabled integer NOT NULL DEFAULT 0,
|
autopilot_enabled integer NOT NULL DEFAULT 0,
|
||||||
autopilot_state text NOT NULL DEFAULT 'inactive',
|
autopilot_state text NOT NULL DEFAULT 'inactive',
|
||||||
last_autopilot_activity_at text,
|
last_autopilot_activity_at text,
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
/*
|
||||||
|
FNXC:MissionTaskPrefix 2026-07-26-12:00:
|
||||||
|
Per-mission taskPrefix override (PR #1930 / #2347). Missions may set a letter-led
|
||||||
|
alphanumeric prefix used when triaging features into tasks; NULL means inherit
|
||||||
|
the project-wide settings.taskPrefix. Additive only — no backfill.
|
||||||
|
Renumbered to 0037 after main claimed 0031–0036 (workflow continuations through
|
||||||
|
chat session tags). Existing databases that already applied 0000–0036 must receive
|
||||||
|
this column via an independent version so upgrade paths cannot skip it.
|
||||||
|
*/
|
||||||
|
|
||||||
|
DO $$
|
||||||
|
BEGIN
|
||||||
|
IF to_regclass('project.missions') IS NOT NULL THEN
|
||||||
|
ALTER TABLE project.missions
|
||||||
|
ADD COLUMN IF NOT EXISTS task_prefix text;
|
||||||
|
END IF;
|
||||||
|
END
|
||||||
|
$$;
|
||||||
@@ -49,8 +49,12 @@ FNXC:TaskWedgeNotifications 2026-07-23-00:00:
|
|||||||
Advance the PostgreSQL schema ceiling for the durable wedge episode column. The
|
Advance the PostgreSQL schema ceiling for the durable wedge episode column. The
|
||||||
forward migration must run before TaskStore writes the new field on fresh and
|
forward migration must run before TaskStore writes the new field on fresh and
|
||||||
upgraded databases.
|
upgraded databases.
|
||||||
|
|
||||||
|
FNXC:MissionTaskPrefix 2026-07-30-21:10 (rebase onto migrated main):
|
||||||
|
SCHEMA_BASELINE_VERSION advances to 0038 for optional per-mission task_prefix — 0037 is the
|
||||||
|
capacity-model table drop that landed while this PR was open.
|
||||||
*/
|
*/
|
||||||
export const SCHEMA_BASELINE_VERSION = "0037";
|
export const SCHEMA_BASELINE_VERSION = "0038";
|
||||||
/** FNXC:SymbolLock 2026-07-20-10:00: upgrades need durable task declarations before admission resolves symbols. */
|
/** FNXC:SymbolLock 2026-07-20-10:00: upgrades need durable task declarations before admission resolves symbols. */
|
||||||
export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
|
export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
|
||||||
const INITIAL_SCHEMA_VERSION = "0000";
|
const INITIAL_SCHEMA_VERSION = "0000";
|
||||||
@@ -160,6 +164,15 @@ runs — which is exactly what happened on the first attempt here: the file exis
|
|||||||
the model was updated, and the table was still present in a fresh database.
|
the model was updated, and the table was still present in a fresh database.
|
||||||
*/
|
*/
|
||||||
export const DROP_GLOBAL_CONCURRENCY_VERSION = "0037";
|
export const DROP_GLOBAL_CONCURRENCY_VERSION = "0037";
|
||||||
|
/*
|
||||||
|
FNXC:MissionTaskPrefix 2026-07-30-21:10 (rebase onto migrated main — RENUMBERED 0037 -> 0038):
|
||||||
|
Upgraded projects need the optional mission prefix before mission reads and triage task creation use
|
||||||
|
it. This shipped as 0037 when the PR was written; main has since taken 0037 for the capacity-model
|
||||||
|
table drop, and two migrations cannot share a number — the runner keys bookkeeping on it, so the
|
||||||
|
second would read as already-applied and silently never run. Renumbered rather than reordered: 0037
|
||||||
|
is landed on real databases and its identity is immutable, per the MONITOR_APPROVAL note above.
|
||||||
|
*/
|
||||||
|
export const MISSION_TASK_PREFIX_VERSION = "0038";
|
||||||
|
|
||||||
/** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */
|
/** SECURITY DEFINER helper that only inserts LEGACY_ADOPTION_DRAINED_MARKER. */
|
||||||
export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained";
|
export const LEGACY_ADOPTION_DRAINED_MARKER_FUNCTION = "fusion_mark_legacy_adoption_drained";
|
||||||
@@ -371,6 +384,7 @@ const MILESTONE_ASSERTION_PROVENANCE_MIGRATION_PATH = join(
|
|||||||
const MISSION_LINEAGE_STOP_MIGRATION_PATH = join(MIGRATIONS_DIR, "0035_fn_8543_mission_lineage_stop.sql");
|
const MISSION_LINEAGE_STOP_MIGRATION_PATH = join(MIGRATIONS_DIR, "0035_fn_8543_mission_lineage_stop.sql");
|
||||||
const CHAT_SESSION_TAGS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0036_chat_session_tags.sql");
|
const CHAT_SESSION_TAGS_MIGRATION_PATH = join(MIGRATIONS_DIR, "0036_chat_session_tags.sql");
|
||||||
const DROP_GLOBAL_CONCURRENCY_MIGRATION_PATH = join(MIGRATIONS_DIR, "0037_drop_global_concurrency.sql");
|
const DROP_GLOBAL_CONCURRENCY_MIGRATION_PATH = join(MIGRATIONS_DIR, "0037_drop_global_concurrency.sql");
|
||||||
|
const MISSION_TASK_PREFIX_MIGRATION_PATH = join(MIGRATIONS_DIR, "0038_mission_task_prefix.sql");
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ensure the migration bookkeeping table exists. Lives in the public schema so
|
* Ensure the migration bookkeeping table exists. Lives in the public schema so
|
||||||
@@ -478,6 +492,7 @@ export async function applySchemaBaseline(
|
|||||||
const missionLineageStopAlreadyApplied = applied.includes(MISSION_LINEAGE_STOP_VERSION);
|
const missionLineageStopAlreadyApplied = applied.includes(MISSION_LINEAGE_STOP_VERSION);
|
||||||
const chatSessionTagsAlreadyApplied = applied.includes(CHAT_SESSION_TAGS_VERSION);
|
const chatSessionTagsAlreadyApplied = applied.includes(CHAT_SESSION_TAGS_VERSION);
|
||||||
const dropGlobalConcurrencyAlreadyApplied = applied.includes(DROP_GLOBAL_CONCURRENCY_VERSION);
|
const dropGlobalConcurrencyAlreadyApplied = applied.includes(DROP_GLOBAL_CONCURRENCY_VERSION);
|
||||||
|
const missionTaskPrefixAlreadyApplied = applied.includes(MISSION_TASK_PREFIX_VERSION);
|
||||||
assertBinaryNotOlderThanDatabase(applied);
|
assertBinaryNotOlderThanDatabase(applied);
|
||||||
let schemaChanged = false;
|
let schemaChanged = false;
|
||||||
|
|
||||||
@@ -1013,6 +1028,20 @@ export async function applySchemaBaseline(
|
|||||||
schemaChanged = true;
|
schemaChanged = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:MissionTaskPrefix 2026-07-30-21:10 (rebase onto migrated main):
|
||||||
|
Apply missions.task_prefix independently so databases that already recorded an earlier version
|
||||||
|
gain the optional mission namespace before mission reads or task minting. Sequenced AFTER the
|
||||||
|
capacity-model drop rather than merged with it: the two are unrelated, and a shared guard would
|
||||||
|
make either one's bookkeeping row suppress the other's SQL.
|
||||||
|
*/
|
||||||
|
if (!missionTaskPrefixAlreadyApplied) {
|
||||||
|
const migrationSql = await readFile(MISSION_TASK_PREFIX_MIGRATION_PATH, "utf8");
|
||||||
|
await tx.execute(sql.raw(migrationSql));
|
||||||
|
await tx.execute(sql`INSERT INTO public.${sql.identifier(MIGRATION_BOOKKEEPING_TABLE)} (version) VALUES (${MISSION_TASK_PREFIX_VERSION}) ON CONFLICT (version) DO NOTHING`);
|
||||||
|
schemaChanged = true;
|
||||||
|
}
|
||||||
|
|
||||||
return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
|
return { applied: schemaChanged, pluginHooksRun: pluginHooks.length };
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1258,6 +1258,11 @@ export const missions = projectSchema.table("missions", {
|
|||||||
branchStrategy: text("branch_strategy"),
|
branchStrategy: text("branch_strategy"),
|
||||||
autoAdvance: integer("auto_advance").default(0),
|
autoAdvance: integer("auto_advance").default(0),
|
||||||
autoMerge: integer("auto_merge"),
|
autoMerge: integer("auto_merge"),
|
||||||
|
/*
|
||||||
|
FNXC:MissionTaskPrefix 2026-07-26-12:00:
|
||||||
|
Optional per-mission ticket id prefix (e.g. ERR). Absent/null inherits the project-wide taskPrefix setting so one mission can mint distinct ids without flipping the board-wide prefix (PR #1930 / #2347, ported onto PG after SQLite cutover).
|
||||||
|
*/
|
||||||
|
taskPrefix: text("task_prefix"),
|
||||||
// FNXC:MissionStore 2026-06-24-08:00:
|
// FNXC:MissionStore 2026-06-24-08:00:
|
||||||
// Autopilot columns were added via addColumnIfMissing in SQLite migrations
|
// Autopilot columns were added via addColumnIfMissing in SQLite migrations
|
||||||
// (db.ts SCHEMA_VERSION=128) but were missing from the initial U3 snapshot.
|
// (db.ts SCHEMA_VERSION=128) but were missing from the initial U3 snapshot.
|
||||||
|
|||||||
24
packages/core/src/task-store/__tests__/task-prefix.test.ts
Normal file
24
packages/core/src/task-store/__tests__/task-prefix.test.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
import { resolveTaskPrefix } from "../task-prefix.js";
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:MissionTaskPrefix 2026-07-26-12:00:
|
||||||
|
Shared helper used by createTaskWithDistributedReservation (FN fallback) and
|
||||||
|
createTaskBackend (KB fallback). Keep preference order: input → settings → fallback.
|
||||||
|
*/
|
||||||
|
describe("resolveTaskPrefix", () => {
|
||||||
|
it("prefers a non-empty mission input hint over settings and fallback", () => {
|
||||||
|
expect(resolveTaskPrefix(" err ", "FN", "KB")).toBe("ERR");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to settings when the input hint is blank", () => {
|
||||||
|
expect(resolveTaskPrefix(" ", "fn-board", "KB")).toBe("FN-BOARD");
|
||||||
|
expect(resolveTaskPrefix(undefined, "fn-board", "KB")).toBe("FN-BOARD");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses the path-specific fallback when neither hint nor settings are set", () => {
|
||||||
|
expect(resolveTaskPrefix(undefined, undefined, "FN")).toBe("FN");
|
||||||
|
expect(resolveTaskPrefix(undefined, undefined, "KB")).toBe("KB");
|
||||||
|
expect(resolveTaskPrefix(undefined, " ", "KB")).toBe("KB");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -36,6 +36,7 @@ import {resolveCreateDeclaredSymbols} from "../task-symbol-resolution.js";
|
|||||||
import {softDeleteTaskRow as softDeleteTaskRowAsync, insertTaskRowInTransaction, isTaskIdConflictError} from "../task-store/async-persistence.js";
|
import {softDeleteTaskRow as softDeleteTaskRowAsync, insertTaskRowInTransaction, isTaskIdConflictError} from "../task-store/async-persistence.js";
|
||||||
import {recordRunAuditEvent as recordRunAuditEventAsync} from "../task-store/async-audit.js";
|
import {recordRunAuditEvent as recordRunAuditEventAsync} from "../task-store/async-audit.js";
|
||||||
import type {DbTransaction} from "../postgres/data-layer.js";
|
import type {DbTransaction} from "../postgres/data-layer.js";
|
||||||
|
import { resolveTaskPrefix } from "./task-prefix.js";
|
||||||
|
|
||||||
type CreateTaskWithAfterInsert = TaskCreateInput & {
|
type CreateTaskWithAfterInsert = TaskCreateInput & {
|
||||||
/** Internal transaction hook; never persisted in task source metadata. */
|
/** Internal transaction hook; never persisted in task source metadata. */
|
||||||
@@ -292,7 +293,8 @@ export async function createTaskBackendImpl(store: TaskStore, input: TaskCreateI
|
|||||||
// failure it aborts the reservation so the sequence is not wasted.
|
// failure it aborts the reservation so the sequence is not wasted.
|
||||||
const allocator = store.getDistributedTaskIdAllocator();
|
const allocator = store.getDistributedTaskIdAllocator();
|
||||||
const settings = await store.getSettingsFast();
|
const settings = await store.getSettingsFast();
|
||||||
const prefix = (settings.taskPrefix || "KB").trim().toUpperCase();
|
// FNXC:MissionTaskPrefix 2026-07-26-12:00: backend task creation must honor the transient mission prefix hint before project settings and the KB fallback.
|
||||||
|
const prefix = resolveTaskPrefix(input.taskPrefix, settings.taskPrefix, "KB");
|
||||||
const nodeId = await store.resolveLocalNodeIdForTaskAllocation();
|
const nodeId = await store.resolveLocalNodeIdForTaskAllocation();
|
||||||
const reservation = await allocator.reserveDistributedTaskId({
|
const reservation = await allocator.reserveDistributedTaskId({
|
||||||
prefix,
|
prefix,
|
||||||
|
|||||||
14
packages/core/src/task-store/task-prefix.ts
Normal file
14
packages/core/src/task-store/task-prefix.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* FNXC:MissionTaskPrefix 2026-07-26-12:00:
|
||||||
|
* Shared minting-prefix resolution for createTask paths. Prefer the per-mission
|
||||||
|
* TaskCreateInput.taskPrefix hint (mission triage), then project settings.taskPrefix,
|
||||||
|
* then the path-specific fallback. Extracted so FN default (workflow-task-create-ops)
|
||||||
|
* and KB default (task-creation) cannot drift (CodeRabbit #2347 / PR #1930).
|
||||||
|
*/
|
||||||
|
export function resolveTaskPrefix(
|
||||||
|
taskPrefixHint: string | undefined,
|
||||||
|
settingsTaskPrefix: string | undefined,
|
||||||
|
fallback: string,
|
||||||
|
): string {
|
||||||
|
return (taskPrefixHint?.trim() || settingsTaskPrefix?.trim() || fallback).trim().toUpperCase();
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {randomUUID} from "node:crypto";
|
|||||||
import {and, eq, inArray, isNull} from "drizzle-orm";
|
import {and, eq, inArray, isNull} from "drizzle-orm";
|
||||||
import {filterArchived as filterArchivedAsync} from "../async-archive-db.js";
|
import {filterArchived as filterArchivedAsync} from "../async-archive-db.js";
|
||||||
import type {Task, TaskCreateInput, Column, ColumnId, TaskDocumentWithTask, RunMutationContext, TaskCommitAssociation, GoalCitation, GoalCitationInput, TaskBranchAssignmentMode, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind} from "../types.js";
|
import type {Task, TaskCreateInput, Column, ColumnId, TaskDocumentWithTask, RunMutationContext, TaskCommitAssociation, GoalCitation, GoalCitationInput, TaskBranchAssignmentMode, WorkflowWorkItem, WorkflowWorkItemDueFilter, WorkflowWorkItemKind} from "../types.js";
|
||||||
|
import { resolveTaskPrefix } from "./task-prefix.js";
|
||||||
import {COLUMNS} from "../types.js";
|
import {COLUMNS} from "../types.js";
|
||||||
import {parseWorkflowIr, serializeWorkflowIr} from "../workflow-ir.js";
|
import {parseWorkflowIr, serializeWorkflowIr} from "../workflow-ir.js";
|
||||||
import {resolveAllowedColumns, workflowHasColumn} from "../workflow-transitions.js";
|
import {resolveAllowedColumns, workflowHasColumn} from "../workflow-transitions.js";
|
||||||
@@ -149,7 +150,8 @@ export async function atomicWriteTaskJsonImpl2(store: TaskStore, dir: string, ta
|
|||||||
|
|
||||||
export async function createTaskWithDistributedReservationImpl(store: TaskStore, input: TaskCreateInput, options?: { onSummarize?: (description: string) => Promise<string | null>; settings?: { autoSummarizeTitles?: boolean }; createTaskWithId?: (taskId: string) => Promise<Task>; },): Promise<Task> {
|
export async function createTaskWithDistributedReservationImpl(store: TaskStore, input: TaskCreateInput, options?: { onSummarize?: (description: string) => Promise<string | null>; settings?: { autoSummarizeTitles?: boolean }; createTaskWithId?: (taskId: string) => Promise<Task>; },): Promise<Task> {
|
||||||
const settings = await store.getSettingsFast();
|
const settings = await store.getSettingsFast();
|
||||||
const prefix = (settings.taskPrefix || "FN").trim().toUpperCase();
|
// FNXC:MissionTaskPrefix 2026-07-26-12:00: prefer TaskCreateInput.taskPrefix (mission triage minting hint) over the project-wide settings.taskPrefix so a single mission can use e.g. ERR- while the board stays FN-.
|
||||||
|
const prefix = resolveTaskPrefix(input.taskPrefix, settings.taskPrefix, "FN");
|
||||||
const allocator = store.getDistributedTaskIdAllocator();
|
const allocator = store.getDistributedTaskIdAllocator();
|
||||||
const nodeId = await store.resolveLocalNodeIdForTaskAllocation();
|
const nodeId = await store.resolveLocalNodeIdForTaskAllocation();
|
||||||
const reservation = await allocator.reserveDistributedTaskId({
|
const reservation = await allocator.reserveDistributedTaskId({
|
||||||
|
|||||||
@@ -1306,6 +1306,12 @@ export interface TaskCreateInput {
|
|||||||
branch?: string;
|
branch?: string;
|
||||||
/** Optional planning/mission branch-group metadata carried across related tasks. */
|
/** Optional planning/mission branch-group metadata carried across related tasks. */
|
||||||
branchContext?: TaskBranchContext;
|
branchContext?: TaskBranchContext;
|
||||||
|
/**
|
||||||
|
* FNXC:MissionTaskPrefix 2026-07-26-12:00:
|
||||||
|
* Transient minting hint: overrides the project taskPrefix for this task's id
|
||||||
|
* reservation (used by mission triage to honor a per-mission prefix). Not persisted.
|
||||||
|
*/
|
||||||
|
taskPrefix?: string;
|
||||||
/** Optional per-task auto-merge override. Undefined means no task-level override. */
|
/** Optional per-task auto-merge override. Undefined means no task-level override. */
|
||||||
autoMerge?: boolean;
|
autoMerge?: boolean;
|
||||||
/** Durable source provenance for the originating external issue. */
|
/** Durable source provenance for the originating external issue. */
|
||||||
|
|||||||
@@ -47,6 +47,11 @@ export interface Mission {
|
|||||||
status: MissionStatus;
|
status: MissionStatus;
|
||||||
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
|
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
|
||||||
autoAdvance?: boolean;
|
autoAdvance?: boolean;
|
||||||
|
/**
|
||||||
|
* FNXC:MissionTaskPrefix 2026-07-26-12:00:
|
||||||
|
* Optional per-mission ticket id prefix for triaged tasks. Absent/null inherits project settings.taskPrefix.
|
||||||
|
*/
|
||||||
|
taskPrefix?: string | null;
|
||||||
/**
|
/**
|
||||||
* FNXC:MissionAutoMerge 2026-07-19-12:30:
|
* FNXC:MissionAutoMerge 2026-07-19-12:30:
|
||||||
* Mission-level auto-merge override (create/update payloads + list/detail responses).
|
* Mission-level auto-merge override (create/update payloads + list/detail responses).
|
||||||
@@ -141,7 +146,7 @@ export function fetchMissions(projectId?: string): Promise<MissionWithSummary[]>
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Create a new mission */
|
/** Create a new mission */
|
||||||
export function createMission(input: { title: string; description?: string; autoAdvance?: boolean; autopilotEnabled?: boolean; autoMerge?: boolean; baseBranch?: string; branchStrategy?: Mission["branchStrategy"] }, projectId?: string): Promise<Mission> {
|
export function createMission(input: { title: string; description?: string; autoAdvance?: boolean; autopilotEnabled?: boolean; autoMerge?: boolean; baseBranch?: string; branchStrategy?: Mission["branchStrategy"]; taskPrefix?: string | null }, projectId?: string): Promise<Mission> {
|
||||||
return api<Mission>(withProjectId("/missions", projectId), {
|
return api<Mission>(withProjectId("/missions", projectId), {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify(input),
|
body: JSON.stringify(input),
|
||||||
@@ -154,7 +159,7 @@ export function fetchMission(missionId: string, projectId?: string): Promise<Mis
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Update mission */
|
/** Update mission */
|
||||||
export function updateMission(missionId: string, updates: Partial<Mission>, projectId?: string): Promise<Mission> {
|
export function updateMission(missionId: string, updates: Partial<Omit<Mission, "taskPrefix" | "autoMerge">> & { taskPrefix?: string | null; autoMerge?: boolean | null }, projectId?: string): Promise<Mission> {
|
||||||
return api<Mission>(withProjectId(`/missions/${encodeURIComponent(missionId)}`, projectId), {
|
return api<Mission>(withProjectId(`/missions/${encodeURIComponent(missionId)}`, projectId), {
|
||||||
method: "PATCH",
|
method: "PATCH",
|
||||||
body: JSON.stringify(updates),
|
body: JSON.stringify(updates),
|
||||||
|
|||||||
@@ -302,6 +302,7 @@ interface MissionFormData {
|
|||||||
autoMergeOverride: MissionAutoMergeOverride;
|
autoMergeOverride: MissionAutoMergeOverride;
|
||||||
baseBranch: string;
|
baseBranch: string;
|
||||||
branchStrategy: MissionBranchStrategy;
|
branchStrategy: MissionBranchStrategy;
|
||||||
|
taskPrefix: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MilestoneFormData {
|
interface MilestoneFormData {
|
||||||
@@ -335,6 +336,7 @@ const EMPTY_MISSION_FORM: MissionFormData = {
|
|||||||
branchStrategy: {
|
branchStrategy: {
|
||||||
mode: "project-default",
|
mode: "project-default",
|
||||||
},
|
},
|
||||||
|
taskPrefix: "",
|
||||||
};
|
};
|
||||||
|
|
||||||
const EMPTY_MILESTONE_FORM: MilestoneFormData = {
|
const EMPTY_MILESTONE_FORM: MilestoneFormData = {
|
||||||
@@ -1694,6 +1696,13 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
]);
|
]);
|
||||||
|
|
||||||
// Mission handlers
|
// Mission handlers
|
||||||
|
const handleMissionTaskPrefixChange = useCallback((rawValue: string) => {
|
||||||
|
const taskPrefix = rawValue.toUpperCase();
|
||||||
|
/** FNXC:MissionTaskPrefix 2026-07-26-12:00: keep all mission forms aligned with server validation so invalid prefixes never enter client state. */
|
||||||
|
if (taskPrefix !== "" && !/^[A-Z][A-Z0-9]*$/.test(taskPrefix)) return;
|
||||||
|
setMissionForm((current) => ({ ...current, taskPrefix }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleEditMission = useCallback((mission: Mission) => {
|
const handleEditMission = useCallback((mission: Mission) => {
|
||||||
setEditingMissionId(mission.id);
|
setEditingMissionId(mission.id);
|
||||||
setIsCreatingMission(false);
|
setIsCreatingMission(false);
|
||||||
@@ -1705,6 +1714,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
autoMergeOverride: missionAutoMergeOverride(mission.autoMerge),
|
autoMergeOverride: missionAutoMergeOverride(mission.autoMerge),
|
||||||
baseBranch: mission.baseBranch ?? "",
|
baseBranch: mission.baseBranch ?? "",
|
||||||
branchStrategy: normalizeMissionBranchStrategy(mission.branchStrategy),
|
branchStrategy: normalizeMissionBranchStrategy(mission.branchStrategy),
|
||||||
|
taskPrefix: mission.taskPrefix ?? "",
|
||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1745,6 +1755,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
: {}),
|
: {}),
|
||||||
baseBranch: missionForm.baseBranch.trim() || undefined,
|
baseBranch: missionForm.baseBranch.trim() || undefined,
|
||||||
branchStrategy,
|
branchStrategy,
|
||||||
|
taskPrefix: missionForm.taskPrefix.trim() || undefined,
|
||||||
}, projectId);
|
}, projectId);
|
||||||
addToast(t("missions.created", "Mission created"), "success");
|
addToast(t("missions.created", "Mission created"), "success");
|
||||||
} else if (editingMissionId) {
|
} else if (editingMissionId) {
|
||||||
@@ -1759,6 +1770,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
autoMerge: resolveMissionAutoMerge(missionForm.autoMergeOverride) ?? null,
|
autoMerge: resolveMissionAutoMerge(missionForm.autoMergeOverride) ?? null,
|
||||||
baseBranch: missionForm.baseBranch.trim() || "",
|
baseBranch: missionForm.baseBranch.trim() || "",
|
||||||
branchStrategy,
|
branchStrategy,
|
||||||
|
/*
|
||||||
|
FNXC:MissionTaskPrefix 2026-07-26-12:00:
|
||||||
|
Edit-save must send taskPrefix:null when the field is cleared. Empty input must not map to undefined: JSON.stringify drops undefined keys, the PATCH route treats a missing key as "no change".
|
||||||
|
*/
|
||||||
|
taskPrefix: missionForm.taskPrefix.trim() || null,
|
||||||
};
|
};
|
||||||
if (missionForm.autopilotEnabled) {
|
if (missionForm.autopilotEnabled) {
|
||||||
updates.autoAdvance = true;
|
updates.autoAdvance = true;
|
||||||
@@ -2932,6 +2948,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
aria-label={t("missions.missionTargetBranchAriaLabel", "Mission target branch")}
|
aria-label={t("missions.missionTargetBranchAriaLabel", "Mission target branch")}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("missions.taskPrefix", "Task prefix")}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("missions.taskPrefixPlaceholder", "e.g. ERR (defaults to project prefix)")}
|
||||||
|
value={missionForm.taskPrefix}
|
||||||
|
onChange={(e) => handleMissionTaskPrefixChange(e.target.value)}
|
||||||
|
aria-label={t("missions.taskPrefixAriaLabel", "Mission task prefix")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("missions.branchStrategy", "Branch strategy")}
|
{t("missions.branchStrategy", "Branch strategy")}
|
||||||
<select
|
<select
|
||||||
@@ -4699,6 +4725,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
aria-label={t("missions.missionTargetBranchAriaLabel", "Mission target branch")}
|
aria-label={t("missions.missionTargetBranchAriaLabel", "Mission target branch")}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("missions.taskPrefix", "Task prefix")}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("missions.taskPrefixPlaceholder", "e.g. ERR (defaults to project prefix)")}
|
||||||
|
value={missionForm.taskPrefix}
|
||||||
|
onChange={(e) => handleMissionTaskPrefixChange(e.target.value)}
|
||||||
|
aria-label={t("missions.taskPrefixAriaLabel", "Mission task prefix")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("missions.branchStrategy", "Branch strategy")}
|
{t("missions.branchStrategy", "Branch strategy")}
|
||||||
<select
|
<select
|
||||||
@@ -4808,6 +4844,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
|||||||
aria-label={t("missions.missionTargetBranchAriaLabel", "Mission target branch")}
|
aria-label={t("missions.missionTargetBranchAriaLabel", "Mission target branch")}
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
|
<label>
|
||||||
|
{t("missions.taskPrefix", "Task prefix")}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t("missions.taskPrefixPlaceholder", "e.g. ERR (defaults to project prefix)")}
|
||||||
|
value={missionForm.taskPrefix}
|
||||||
|
onChange={(e) => handleMissionTaskPrefixChange(e.target.value)}
|
||||||
|
aria-label={t("missions.taskPrefixAriaLabel", "Mission task prefix")}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
<label>
|
<label>
|
||||||
{t("missions.branchStrategy", "Branch strategy")}
|
{t("missions.branchStrategy", "Branch strategy")}
|
||||||
<select
|
<select
|
||||||
|
|||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/*
|
||||||
|
FNXC:MissionTaskPrefix 2026-07-14-12:00:
|
||||||
|
Regression for greptile P1 on PR #1930: clearing a mission taskPrefix on edit must PATCH null so the stored override is removed and triage re-inherits the project prefix.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { MissionManager } from "../MissionManager";
|
||||||
|
import { ConfirmDialogProvider } from "../../hooks/useConfirm";
|
||||||
|
|
||||||
|
const mockFetchMissions = vi.fn();
|
||||||
|
const mockFetchMission = vi.fn();
|
||||||
|
const mockFetchMissionsHealth = vi.fn();
|
||||||
|
const mockFetchMissionEvents = vi.fn();
|
||||||
|
const mockFetchAssertions = vi.fn();
|
||||||
|
const mockFetchMilestoneValidation = vi.fn();
|
||||||
|
const mockFetchMilestoneValidationTelemetry = vi.fn();
|
||||||
|
const mockFetchValidationLoopState = vi.fn();
|
||||||
|
const mockFetchValidationRuns = vi.fn();
|
||||||
|
const mockFetchAiSessions = vi.fn();
|
||||||
|
const mockFetchAiSession = vi.fn();
|
||||||
|
const mockFetchMissionInterviewDrafts = vi.fn();
|
||||||
|
const mockUpdateMission = vi.fn();
|
||||||
|
const mockSubscribeSse = vi.fn(() => vi.fn());
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useViewportMode", () => ({
|
||||||
|
MOBILE_MEDIA_QUERY: "(max-width: 768px), (max-height: 480px)",
|
||||||
|
getViewportMode: () => "desktop",
|
||||||
|
isMobileViewport: () => false,
|
||||||
|
useViewportMode: () => "desktop",
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../hooks/useNavigationHistory", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../../hooks/useNavigationHistory")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
useNavigationHistoryContext: () => ({ pushNav: vi.fn(), replaceCurrent: vi.fn() }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
vi.mock("../../sse-bus", () => ({
|
||||||
|
subscribeSse: (...args: unknown[]) => mockSubscribeSse(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../MissionInterviewModal", () => ({
|
||||||
|
MissionInterviewModal: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../MilestoneSliceInterviewModal", () => ({
|
||||||
|
MilestoneSliceInterviewModal: () => null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../../api", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("../../api")>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
fetchMissions: (...args: unknown[]) => mockFetchMissions(...args),
|
||||||
|
fetchMission: (...args: unknown[]) => mockFetchMission(...args),
|
||||||
|
fetchMissionsHealth: (...args: unknown[]) => mockFetchMissionsHealth(...args),
|
||||||
|
fetchMissionEvents: (...args: unknown[]) => mockFetchMissionEvents(...args),
|
||||||
|
fetchAssertions: (...args: unknown[]) => mockFetchAssertions(...args),
|
||||||
|
fetchMilestoneValidation: (...args: unknown[]) => mockFetchMilestoneValidation(...args),
|
||||||
|
fetchMilestoneValidationTelemetry: (...args: unknown[]) => mockFetchMilestoneValidationTelemetry(...args),
|
||||||
|
fetchValidationLoopState: (...args: unknown[]) => mockFetchValidationLoopState(...args),
|
||||||
|
fetchValidationRuns: (...args: unknown[]) => mockFetchValidationRuns(...args),
|
||||||
|
fetchAiSessions: (...args: unknown[]) => mockFetchAiSessions(...args),
|
||||||
|
fetchAiSession: (...args: unknown[]) => mockFetchAiSession(...args),
|
||||||
|
fetchMissionInterviewDrafts: (...args: unknown[]) => mockFetchMissionInterviewDrafts(...args),
|
||||||
|
updateMission: (...args: unknown[]) => mockUpdateMission(...args),
|
||||||
|
fetchModels: vi.fn().mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] }),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const projectId = "project-1";
|
||||||
|
const mission = {
|
||||||
|
id: "M-001",
|
||||||
|
title: "Prefixed Mission",
|
||||||
|
description: "Has a mission-level prefix",
|
||||||
|
status: "planning",
|
||||||
|
interviewState: "not_started",
|
||||||
|
taskPrefix: "ERR",
|
||||||
|
milestones: [],
|
||||||
|
createdAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||||
|
};
|
||||||
|
|
||||||
|
function setupMocks() {
|
||||||
|
mockFetchMissions.mockResolvedValue([mission]);
|
||||||
|
mockFetchMission.mockResolvedValue(mission);
|
||||||
|
mockFetchMissionsHealth.mockResolvedValue({});
|
||||||
|
mockFetchMissionEvents.mockResolvedValue([]);
|
||||||
|
mockFetchAssertions.mockResolvedValue([]);
|
||||||
|
mockFetchMilestoneValidation.mockResolvedValue(null);
|
||||||
|
mockFetchMilestoneValidationTelemetry.mockResolvedValue(null);
|
||||||
|
mockFetchValidationLoopState.mockResolvedValue(null);
|
||||||
|
mockFetchValidationRuns.mockResolvedValue([]);
|
||||||
|
mockFetchAiSessions.mockResolvedValue([]);
|
||||||
|
mockFetchAiSession.mockResolvedValue(null);
|
||||||
|
mockFetchMissionInterviewDrafts.mockResolvedValue([]);
|
||||||
|
mockUpdateMission.mockResolvedValue({ ...mission, taskPrefix: undefined });
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("MissionManager taskPrefix clear", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
localStorage.clear();
|
||||||
|
setupMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("PATCHes taskPrefix:null when the edit field is cleared", async () => {
|
||||||
|
render(
|
||||||
|
<ConfirmDialogProvider>
|
||||||
|
<MissionManager isInline isOpen onClose={() => {}} addToast={vi.fn()} projectId={projectId} />
|
||||||
|
</ConfirmDialogProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const listItem = (await screen.findByText("Prefixed Mission")).closest(".mission-list__item");
|
||||||
|
expect(listItem).not.toBeNull();
|
||||||
|
fireEvent.click(listItem as HTMLElement);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockFetchMission).toHaveBeenCalledWith("M-001", projectId);
|
||||||
|
});
|
||||||
|
|
||||||
|
const editButtons = screen.getAllByRole("button", { name: "Edit mission" });
|
||||||
|
fireEvent.click(editButtons[0]);
|
||||||
|
|
||||||
|
const prefixInput = await screen.findByLabelText("Mission task prefix");
|
||||||
|
expect(prefixInput).toHaveValue("ERR");
|
||||||
|
fireEvent.change(prefixInput, { target: { value: "" } });
|
||||||
|
expect(prefixInput).toHaveValue("");
|
||||||
|
|
||||||
|
const formCard = prefixInput.closest(".mission-form-card");
|
||||||
|
expect(formCard).not.toBeNull();
|
||||||
|
fireEvent.click(within(formCard as HTMLElement).getByRole("button", { name: /Update/i }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(mockUpdateMission).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
const [missionId, updates, calledProjectId] = mockUpdateMission.mock.calls[0] as [
|
||||||
|
string,
|
||||||
|
Record<string, unknown>,
|
||||||
|
string?,
|
||||||
|
];
|
||||||
|
expect(missionId).toBe("M-001");
|
||||||
|
expect(calledProjectId).toBe(projectId);
|
||||||
|
expect(updates).toEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
taskPrefix: null,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
// JSON wire format must retain the key; undefined would drop it and skip the clear.
|
||||||
|
expect(JSON.stringify(updates)).toContain('"taskPrefix":null');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uppercases valid prefixes and rejects invalid characters before they enter form state", async () => {
|
||||||
|
render(
|
||||||
|
<ConfirmDialogProvider>
|
||||||
|
<MissionManager isInline isOpen onClose={() => {}} addToast={vi.fn()} projectId={projectId} />
|
||||||
|
</ConfirmDialogProvider>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const listItem = (await screen.findByText("Prefixed Mission")).closest(".mission-list__item");
|
||||||
|
fireEvent.click(listItem as HTMLElement);
|
||||||
|
await waitFor(() => expect(mockFetchMission).toHaveBeenCalledWith("M-001", projectId));
|
||||||
|
fireEvent.click(screen.getAllByRole("button", { name: "Edit mission" })[0]);
|
||||||
|
|
||||||
|
const prefixInput = await screen.findByLabelText("Mission task prefix");
|
||||||
|
fireEvent.change(prefixInput, { target: { value: "err2" } });
|
||||||
|
expect(prefixInput).toHaveValue("ERR2");
|
||||||
|
|
||||||
|
fireEvent.change(prefixInput, { target: { value: "1ERR" } });
|
||||||
|
expect(prefixInput).toHaveValue("ERR2");
|
||||||
|
|
||||||
|
fireEvent.change(prefixInput, { target: { value: "ERR-2" } });
|
||||||
|
expect(prefixInput).toHaveValue("ERR2");
|
||||||
|
|
||||||
|
fireEvent.change(prefixInput, { target: { value: "" } });
|
||||||
|
expect(prefixInput).toHaveValue("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -61,6 +61,12 @@ export interface Mission {
|
|||||||
};
|
};
|
||||||
status: MissionStatus;
|
status: MissionStatus;
|
||||||
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
|
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
|
||||||
|
/**
|
||||||
|
* FNXC:MissionTaskPrefix 2026-07-26-12:00:
|
||||||
|
* Optional per-mission ticket id prefix for triaged tasks. Absent/null inherits project settings.taskPrefix.
|
||||||
|
* Keep in sync with app/api/missions.ts Mission.taskPrefix.
|
||||||
|
*/
|
||||||
|
taskPrefix?: string | null;
|
||||||
/**
|
/**
|
||||||
* FNXC:MissionAutoMerge 2026-07-19-12:30:
|
* FNXC:MissionAutoMerge 2026-07-19-12:30:
|
||||||
* Mission-level auto-merge override for linked task branches.
|
* Mission-level auto-merge override for linked task branches.
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
// @vitest-environment node
|
||||||
|
/*
|
||||||
|
FNXC:MissionTaskPrefix 2026-07-14-19:00:
|
||||||
|
Route regression for greptile P1 on PR #1930: PATCH with taskPrefix null/empty must clear a stored mission override so triage inherits the project prefix; omitting the key must leave it unchanged.
|
||||||
|
|
||||||
|
FNXC:MissionTaskPrefix 2026-07-14-19:05:
|
||||||
|
Ported off the deleted SQLite inMemoryDb TaskStore fixture (VAL-REMOVAL-005). Mock the mission store so the test asserts route validation/normalization without requiring PostgreSQL.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import express from "express";
|
||||||
|
import type { TaskStore } from "@fusion/core";
|
||||||
|
import { createMissionRouter } from "../mission-routes.js";
|
||||||
|
import { request } from "../test-request.js";
|
||||||
|
|
||||||
|
type MissionRecord = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
description?: string;
|
||||||
|
status: string;
|
||||||
|
interviewState: string;
|
||||||
|
taskPrefix?: string;
|
||||||
|
autoAdvance: boolean;
|
||||||
|
autopilotEnabled: boolean;
|
||||||
|
autopilotState: string;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createFixture() {
|
||||||
|
const missions = new Map<string, MissionRecord>();
|
||||||
|
let seq = 0;
|
||||||
|
|
||||||
|
const missionStore = {
|
||||||
|
createMission: vi.fn(async (input: { title: string; taskPrefix?: string }) => {
|
||||||
|
const now = new Date().toISOString();
|
||||||
|
const mission: MissionRecord = {
|
||||||
|
id: `M-${++seq}`,
|
||||||
|
title: input.title,
|
||||||
|
status: "planning",
|
||||||
|
interviewState: "not_started",
|
||||||
|
taskPrefix: input.taskPrefix,
|
||||||
|
autoAdvance: false,
|
||||||
|
autopilotEnabled: false,
|
||||||
|
autopilotState: "inactive",
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
missions.set(mission.id, mission);
|
||||||
|
return mission;
|
||||||
|
}),
|
||||||
|
getMission: vi.fn(async (id: string) => missions.get(id)),
|
||||||
|
updateMission: vi.fn(async (id: string, updates: Partial<MissionRecord>) => {
|
||||||
|
const existing = missions.get(id);
|
||||||
|
if (!existing) throw new Error(`Mission ${id} not found`);
|
||||||
|
const updated: MissionRecord = {
|
||||||
|
...existing,
|
||||||
|
...updates,
|
||||||
|
id,
|
||||||
|
createdAt: existing.createdAt,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
// Explicit undefined clears the override (same as AsyncMissionStore).
|
||||||
|
if ("taskPrefix" in updates && updates.taskPrefix === undefined) {
|
||||||
|
delete updated.taskPrefix;
|
||||||
|
}
|
||||||
|
missions.set(id, updated);
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
listMissions: vi.fn(async () => [...missions.values()]),
|
||||||
|
listMissionsWithSummaries: vi.fn(async () => []),
|
||||||
|
getMissionWithHierarchy: vi.fn(async () => undefined),
|
||||||
|
listGoalIdsForMission: vi.fn(async () => []),
|
||||||
|
linkGoal: vi.fn(),
|
||||||
|
unlinkGoal: vi.fn(),
|
||||||
|
on: vi.fn(),
|
||||||
|
off: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const store = {
|
||||||
|
getMissionStore: () => missionStore,
|
||||||
|
getGoalStore: () => ({
|
||||||
|
getGoal: vi.fn(async () => undefined),
|
||||||
|
listGoals: vi.fn(async () => []),
|
||||||
|
}),
|
||||||
|
getRootDir: () => "/tmp/mission-task-prefix-routes",
|
||||||
|
getSettings: vi.fn(async () => ({})),
|
||||||
|
backendMode: true,
|
||||||
|
} as unknown as TaskStore;
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
app.use(express.json());
|
||||||
|
app.use("/api/missions", createMissionRouter(store));
|
||||||
|
|
||||||
|
return { app, missionStore, missions };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("mission taskPrefix routes", () => {
|
||||||
|
let app: express.Express;
|
||||||
|
let missionStore: ReturnType<typeof createFixture>["missionStore"];
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
({ app, missionStore } = createFixture());
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a stored taskPrefix when PATCH sends null", async () => {
|
||||||
|
const mission = await missionStore.createMission({ title: "Prefixed", taskPrefix: "ERR" });
|
||||||
|
expect((await missionStore.getMission(mission.id))?.taskPrefix).toBe("ERR");
|
||||||
|
|
||||||
|
const response = await request(
|
||||||
|
app,
|
||||||
|
"PATCH",
|
||||||
|
`/api/missions/${mission.id}`,
|
||||||
|
JSON.stringify({ taskPrefix: null }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect((response.body as { taskPrefix?: string }).taskPrefix).toBeUndefined();
|
||||||
|
expect((await missionStore.getMission(mission.id))?.taskPrefix).toBeUndefined();
|
||||||
|
expect(missionStore.updateMission).toHaveBeenCalledWith(
|
||||||
|
mission.id,
|
||||||
|
expect.objectContaining({ taskPrefix: undefined }),
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears a stored taskPrefix when PATCH sends an empty string", async () => {
|
||||||
|
const mission = await missionStore.createMission({ title: "Prefixed", taskPrefix: "BUG" });
|
||||||
|
|
||||||
|
const response = await request(
|
||||||
|
app,
|
||||||
|
"PATCH",
|
||||||
|
`/api/missions/${mission.id}`,
|
||||||
|
JSON.stringify({ taskPrefix: " " }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect((await missionStore.getMission(mission.id))?.taskPrefix).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves taskPrefix unchanged when the PATCH body omits the key", async () => {
|
||||||
|
const mission = await missionStore.createMission({ title: "Prefixed", taskPrefix: "ERR" });
|
||||||
|
|
||||||
|
const response = await request(
|
||||||
|
app,
|
||||||
|
"PATCH",
|
||||||
|
`/api/missions/${mission.id}`,
|
||||||
|
JSON.stringify({ title: "Renamed only" }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect((response.body as { title: string; taskPrefix?: string }).title).toBe("Renamed only");
|
||||||
|
expect((response.body as { taskPrefix?: string }).taskPrefix).toBe("ERR");
|
||||||
|
expect((await missionStore.getMission(mission.id))?.taskPrefix).toBe("ERR");
|
||||||
|
// updateMission must not receive a taskPrefix key when the body omitted it.
|
||||||
|
const updateArg = missionStore.updateMission.mock.calls.at(-1)?.[1] as Record<string, unknown>;
|
||||||
|
expect(updateArg).not.toHaveProperty("taskPrefix");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uppercases a non-empty taskPrefix on PATCH", async () => {
|
||||||
|
const mission = await missionStore.createMission({ title: "Plain" });
|
||||||
|
|
||||||
|
const ok = await request(
|
||||||
|
app,
|
||||||
|
"PATCH",
|
||||||
|
`/api/missions/${mission.id}`,
|
||||||
|
JSON.stringify({ taskPrefix: "err2" }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
expect(ok.status).toBe(200);
|
||||||
|
expect((ok.body as { taskPrefix?: string }).taskPrefix).toBe("ERR2");
|
||||||
|
expect((await missionStore.getMission(mission.id))?.taskPrefix).toBe("ERR2");
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
[123, "taskPrefix must be a string or null"],
|
||||||
|
["1ERR", "taskPrefix must start with a letter and contain only letters and digits"],
|
||||||
|
])("returns 400 for invalid taskPrefix %j", async (taskPrefix, message) => {
|
||||||
|
const mission = await missionStore.createMission({ title: "Plain" });
|
||||||
|
|
||||||
|
const response = await request(
|
||||||
|
app,
|
||||||
|
"PATCH",
|
||||||
|
`/api/missions/${mission.id}`,
|
||||||
|
JSON.stringify({ taskPrefix }),
|
||||||
|
{ "content-type": "application/json" },
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(response.body).toEqual({ error: message });
|
||||||
|
expect(missionStore.updateMission).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -248,6 +248,23 @@ function validateMissionBranchStrategy(value: unknown): MissionBranchStrategy |
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:MissionTaskPrefix 2026-07-26-12:00:
|
||||||
|
PATCH/POST accept taskPrefix as a string, empty string, or null. null/empty normalizes to undefined so MissionStore writes NULL and the mission inherits the project-wide prefix. The key must still be present on PATCH (null, not omitted) so clearing is distinct from "leave unchanged" (greptile P1 on PR #1930).
|
||||||
|
*/
|
||||||
|
function validateTaskPrefix(value: unknown): string | undefined {
|
||||||
|
if (value === undefined || value === null) return undefined;
|
||||||
|
if (typeof value !== "string") {
|
||||||
|
throw badRequest("taskPrefix must be a string or null");
|
||||||
|
}
|
||||||
|
const trimmed = value.trim().toUpperCase();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
if (!/^[A-Z][A-Z0-9]*$/.test(trimmed)) {
|
||||||
|
throw badRequest("taskPrefix must start with a letter and contain only letters and digits");
|
||||||
|
}
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
function validateOrderedIds(body: unknown): string[] {
|
function validateOrderedIds(body: unknown): string[] {
|
||||||
if (!body || typeof body !== "object") {
|
if (!body || typeof body !== "object") {
|
||||||
throw new Error("Request body must contain orderedIds array");
|
throw new Error("Request body must contain orderedIds array");
|
||||||
@@ -506,7 +523,7 @@ export function createMissionRouter(
|
|||||||
router.post(
|
router.post(
|
||||||
"/",
|
"/",
|
||||||
catchTypedHandler(async (req, res) => {
|
catchTypedHandler(async (req, res) => {
|
||||||
const { title, description, autoAdvance, autoMerge, baseBranch, branchStrategy, goalIds } = req.body;
|
const { title, description, autoAdvance, autoMerge, baseBranch, branchStrategy, taskPrefix, goalIds } = req.body;
|
||||||
|
|
||||||
const validatedTitle = validateTitle(title);
|
const validatedTitle = validateTitle(title);
|
||||||
const validatedDescription = validateDescription(description);
|
const validatedDescription = validateDescription(description);
|
||||||
@@ -517,6 +534,7 @@ export function createMissionRouter(
|
|||||||
description: validatedDescription,
|
description: validatedDescription,
|
||||||
baseBranch: validateDescription(baseBranch),
|
baseBranch: validateDescription(baseBranch),
|
||||||
branchStrategy: validateMissionBranchStrategy(branchStrategy),
|
branchStrategy: validateMissionBranchStrategy(branchStrategy),
|
||||||
|
taskPrefix: validateTaskPrefix(taskPrefix),
|
||||||
...(autoMerge !== undefined
|
...(autoMerge !== undefined
|
||||||
? {
|
? {
|
||||||
// FNXC:MissionAutoMerge 2026-07-18-12:00: Create accepts only a real boolean; null is reserved for PATCH clear-to-inherited.
|
// FNXC:MissionAutoMerge 2026-07-18-12:00: Create accepts only a real boolean; null is reserved for PATCH clear-to-inherited.
|
||||||
@@ -1158,7 +1176,7 @@ export function createMissionRouter(
|
|||||||
"/:missionId",
|
"/:missionId",
|
||||||
catchTypedHandler(async (req, res) => {
|
catchTypedHandler(async (req, res) => {
|
||||||
const { missionId } = req.params;
|
const { missionId } = req.params;
|
||||||
const { title, description, status, autoAdvance, autoMerge, autopilotEnabled, baseBranch, branchStrategy, goalIds } = req.body;
|
const { title, description, status, autoAdvance, autoMerge, autopilotEnabled, baseBranch, branchStrategy, taskPrefix, goalIds } = req.body;
|
||||||
|
|
||||||
if (!validateMissionId(missionId)) {
|
if (!validateMissionId(missionId)) {
|
||||||
throw badRequest("Invalid mission ID format");
|
throw badRequest("Invalid mission ID format");
|
||||||
@@ -1199,6 +1217,9 @@ export function createMissionRouter(
|
|||||||
if (branchStrategy !== undefined) {
|
if (branchStrategy !== undefined) {
|
||||||
updates.branchStrategy = validateMissionBranchStrategy(branchStrategy);
|
updates.branchStrategy = validateMissionBranchStrategy(branchStrategy);
|
||||||
}
|
}
|
||||||
|
if (taskPrefix !== undefined) {
|
||||||
|
updates.taskPrefix = validateTaskPrefix(taskPrefix);
|
||||||
|
}
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0 && validatedGoalIds === undefined) {
|
if (Object.keys(updates).length === 0 && validatedGoalIds === undefined) {
|
||||||
throw badRequest("No valid fields to update");
|
throw badRequest("No valid fields to update");
|
||||||
|
|||||||
@@ -60,7 +60,7 @@ describe("worktree-hooks", () => {
|
|||||||
expect(hook).toContain("--if-exists doNothing");
|
expect(hook).toContain("--if-exists doNothing");
|
||||||
expect(hook).toContain("--trailer \"$TRAILER_NAME: $TASK_ID\"");
|
expect(hook).toContain("--trailer \"$TRAILER_NAME: $TASK_ID\"");
|
||||||
expect(hook).toContain("--if-exists addIfDifferent");
|
expect(hook).toContain("--if-exists addIfDifferent");
|
||||||
expect(hook).toContain('CO_AUTHOR_TRAILER="Co-authored-by: Fusion <noreply@runfusion.ai>"');
|
expect(hook).toContain("CO_AUTHOR_TRAILER='Co-authored-by: Fusion <noreply@runfusion.ai>'");
|
||||||
expect(hook).toContain("s/^FN-//i");
|
expect(hook).toContain("s/^FN-//i");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ describe("worktree-hooks", () => {
|
|||||||
commitAuthorName: "Fusion Bot",
|
commitAuthorName: "Fusion Bot",
|
||||||
commitAuthorEmail: "bot@example.com",
|
commitAuthorEmail: "bot@example.com",
|
||||||
});
|
});
|
||||||
expect(customHook).toContain('CO_AUTHOR_TRAILER="Co-authored-by: Fusion Bot <bot@example.com>"');
|
expect(customHook).toContain("CO_AUTHOR_TRAILER='Co-authored-by: Fusion Bot <bot@example.com>'");
|
||||||
expect(customHook).toContain("--if-exists addIfDifferent");
|
expect(customHook).toContain("--if-exists addIfDifferent");
|
||||||
|
|
||||||
const disabledHook = buildCommitMsgTrailerHook("FN-42", { commitAuthorEnabled: false });
|
const disabledHook = buildCommitMsgTrailerHook("FN-42", { commitAuthorEnabled: false });
|
||||||
@@ -79,11 +79,58 @@ describe("worktree-hooks", () => {
|
|||||||
|
|
||||||
it("parameterizes commit-msg hook for custom prefix and trailer name", () => {
|
it("parameterizes commit-msg hook for custom prefix and trailer name", () => {
|
||||||
const hook = buildCommitMsgTrailerHook("KB-9", { taskPrefix: "KB", trailerName: "Task-Id" });
|
const hook = buildCommitMsgTrailerHook("KB-9", { taskPrefix: "KB", trailerName: "Task-Id" });
|
||||||
expect(hook).toContain('PREFIX="KB"');
|
expect(hook).toContain("PREFIX='KB'");
|
||||||
expect(hook).toContain('TRAILER_NAME="Task-Id"');
|
expect(hook).toContain("TRAILER_NAME='Task-Id'");
|
||||||
expect(hook).toContain("s/^KB-//i");
|
expect(hook).toContain("s/^KB-//i");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("derives the strip prefix from the task id, ignoring a mismatched options.taskPrefix", () => {
|
||||||
|
// A per-mission ticket (ERR-5) whose project-wide options.taskPrefix is still "FN" must strip its own ERR- prefix, not "FN-".
|
||||||
|
const hook = buildCommitMsgTrailerHook("ERR-5", { taskPrefix: "FN" });
|
||||||
|
expect(hook).toContain("PREFIX='ERR'");
|
||||||
|
expect(hook).toContain("s/^ERR-//i");
|
||||||
|
expect(hook).not.toContain("PREFIX='FN'");
|
||||||
|
});
|
||||||
|
|
||||||
|
// FNXC:WorktreeHooks 2026-07-26-12:00: fallback options.taskPrefix is quoted in case and escaped for sed -E so metacharacters cannot break the hook (greptile P2 on PR #1930).
|
||||||
|
it("quotes the case prefix and escapes sed ERE metacharacters on the fallback taskPrefix path", () => {
|
||||||
|
const hook = buildCommitMsgTrailerHook("not-a-numeric-id", { taskPrefix: "A.B+C" });
|
||||||
|
expect(hook).toContain("PREFIX='A.B+C'");
|
||||||
|
expect(hook).toContain('"$PREFIX"-*) ;;');
|
||||||
|
expect(hook).toContain("s/^A\\.B\\+C-//i");
|
||||||
|
});
|
||||||
|
|
||||||
|
// FNXC:WorktreeHooks 2026-07-26-12:00: `/` must be escaped too so `/`-delimited sed stays valid.
|
||||||
|
it("escapes slash in the fallback taskPrefix so sed delimiters stay intact", () => {
|
||||||
|
const hook = buildCommitMsgTrailerHook("not-a-numeric-id", { taskPrefix: "TEAM/API" });
|
||||||
|
expect(hook).toContain("PREFIX='TEAM/API'");
|
||||||
|
expect(hook).toContain("s/^TEAM\\/API-//i");
|
||||||
|
});
|
||||||
|
|
||||||
|
// FNXC:WorktreeHooks 2026-07-26-12:00: PREFIX must be a single-quoted shell literal so $(...) cannot expand (greptile P1 security).
|
||||||
|
it("single-quotes PREFIX so shell command substitution cannot expand on the fallback path", () => {
|
||||||
|
const cmdSub = buildCommitMsgTrailerHook("not-a-numeric-id", { taskPrefix: "$(id)" });
|
||||||
|
expect(cmdSub).toContain("PREFIX='$(id)'");
|
||||||
|
expect(cmdSub).not.toMatch(/PREFIX="\$\(id\)"/);
|
||||||
|
expect(cmdSub).not.toMatch(/PREFIX="[^"]*\$\(/);
|
||||||
|
|
||||||
|
const injection = buildCommitMsgTrailerHook("not-a-numeric-id", {
|
||||||
|
taskPrefix: "; rm -rf /; #",
|
||||||
|
});
|
||||||
|
expect(injection).toContain("PREFIX='; rm -rf /; #'");
|
||||||
|
expect(injection).not.toMatch(/PREFIX="[^']*;/);
|
||||||
|
|
||||||
|
const withQuote = buildCommitMsgTrailerHook("not-a-numeric-id", { taskPrefix: "O'Brien" });
|
||||||
|
// Embedded ' → '\'' inside the outer single-quoted literal.
|
||||||
|
expect(withQuote).toContain("PREFIX='O'\\''Brien'");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("single-quotes the fallback sed expression so backticks cannot execute", () => {
|
||||||
|
const hook = buildCommitMsgTrailerHook("not-a-numeric-id", { taskPrefix: "`id`" });
|
||||||
|
expect(hook).toContain("PREFIX='`id`'");
|
||||||
|
expect(hook).toContain('"$PREFIX"-*) ;;');
|
||||||
|
});
|
||||||
|
|
||||||
it("installs metadata and pre-commit + commit-msg hooks in linked worktree", async () => {
|
it("installs metadata and pre-commit + commit-msg hooks in linked worktree", async () => {
|
||||||
const root = mkdtempSync(join(tmpdir(), "wt-hook-root-"));
|
const root = mkdtempSync(join(tmpdir(), "wt-hook-root-"));
|
||||||
execFileSync("git", ["init"], { cwd: root });
|
execFileSync("git", ["init"], { cwd: root });
|
||||||
|
|||||||
@@ -199,6 +199,22 @@ fi
|
|||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:WorktreeHooks 2026-07-26-12:00:
|
||||||
|
options.taskPrefix is a fallback when taskId is not a well-formed "<PREFIX>-<n>" id. It is interpolated into the generated commit-msg hook's case arm and sed -E strip. Escape ERE metacharacters for sed and quote the case pattern via "$PREFIX" so a misconfigured prefix cannot break the hook or strip the wrong id (greptile P2 on PR #1930). Valid UI prefixes remain letter-led alphanumeric; escaping is defense-in-depth for the fallback path.
|
||||||
|
|
||||||
|
FNXC:WorktreeHooks 2026-07-26-12:00:
|
||||||
|
Also escape `/` so a fallback prefix like `TEAM/API` cannot break the `/`-delimited sed substitution. PREFIX= must use a POSIX single-quoted shell literal, not JSON double quotes, so command substitution cannot expand when the commit-msg hook runs under /bin/sh (greptile P1 security on PR #1930).
|
||||||
|
*/
|
||||||
|
function escapeSedEre(value: string): string {
|
||||||
|
return value.replace(/[\\/.^$*+?()[\]{}|]/g, "\\$&");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POSIX single-quoted shell string literal (no expansion of $, `, \\, etc.). */
|
||||||
|
function shellSingleQuote(value: string): string {
|
||||||
|
return "'" + value.replace(/'/g, "'\\''") + "'";
|
||||||
|
}
|
||||||
|
|
||||||
export function buildCommitMsgTrailerHook(
|
export function buildCommitMsgTrailerHook(
|
||||||
taskId: string,
|
taskId: string,
|
||||||
options: {
|
options: {
|
||||||
@@ -209,7 +225,14 @@ export function buildCommitMsgTrailerHook(
|
|||||||
commitAuthorEmail?: string;
|
commitAuthorEmail?: string;
|
||||||
} = {}
|
} = {}
|
||||||
): string {
|
): string {
|
||||||
const taskPrefix = (options.taskPrefix ?? "FN").trim() || "FN";
|
// Derive the strip prefix from the task id itself so per-mission prefixes
|
||||||
|
// (e.g. ERR-5 while the project taskPrefix is still "FN") strip correctly.
|
||||||
|
const trimmedTaskId = taskId.trim();
|
||||||
|
const derivedPrefix = /^[A-Za-z][A-Za-z0-9]*-\d+$/.test(trimmedTaskId)
|
||||||
|
? trimmedTaskId.replace(/-\d+$/, "").toUpperCase()
|
||||||
|
: "";
|
||||||
|
const taskPrefix = (derivedPrefix || options.taskPrefix || "FN").trim() || "FN";
|
||||||
|
const taskPrefixSedEre = escapeSedEre(taskPrefix);
|
||||||
const trailerName = (options.trailerName ?? "Fusion-Task-Id").trim() || "Fusion-Task-Id";
|
const trailerName = (options.trailerName ?? "Fusion-Task-Id").trim() || "Fusion-Task-Id";
|
||||||
const commitAuthorName = (options.commitAuthorName ?? DEFAULT_COMMIT_AUTHOR_NAME).trim() || DEFAULT_COMMIT_AUTHOR_NAME;
|
const commitAuthorName = (options.commitAuthorName ?? DEFAULT_COMMIT_AUTHOR_NAME).trim() || DEFAULT_COMMIT_AUTHOR_NAME;
|
||||||
const commitAuthorEmail = (options.commitAuthorEmail ?? DEFAULT_COMMIT_AUTHOR_EMAIL).trim() || DEFAULT_COMMIT_AUTHOR_EMAIL;
|
const commitAuthorEmail = (options.commitAuthorEmail ?? DEFAULT_COMMIT_AUTHOR_EMAIL).trim() || DEFAULT_COMMIT_AUTHOR_EMAIL;
|
||||||
@@ -219,7 +242,7 @@ export function buildCommitMsgTrailerHook(
|
|||||||
|
|
||||||
# FNXC:CommitAttribution 2026-06-26-12:40:
|
# FNXC:CommitAttribution 2026-06-26-12:40:
|
||||||
# Co-author attribution must be deterministic in the worktree hook, not dependent on an AI agent remembering a prompt-supplied git commit -m flag. addIfDifferent keeps an identical agent-added trailer from duplicating while preserving distinct human-provided co-authors.
|
# Co-author attribution must be deterministic in the worktree hook, not dependent on an AI agent remembering a prompt-supplied git commit -m flag. addIfDifferent keeps an identical agent-added trailer from duplicating while preserving distinct human-provided co-authors.
|
||||||
CO_AUTHOR_TRAILER=${JSON.stringify(`Co-authored-by: ${commitAuthorName} <${commitAuthorEmail}>`)}
|
CO_AUTHOR_TRAILER=${shellSingleQuote(`Co-authored-by: ${commitAuthorName} <${commitAuthorEmail}>`)}
|
||||||
git interpret-trailers \\
|
git interpret-trailers \\
|
||||||
--in-place \\
|
--in-place \\
|
||||||
--if-exists addIfDifferent \\
|
--if-exists addIfDifferent \\
|
||||||
@@ -236,13 +259,13 @@ TASK_FILE=$(git rev-parse --git-path fusion-task-id)
|
|||||||
TASK_ID=$(cat "$TASK_FILE")
|
TASK_ID=$(cat "$TASK_FILE")
|
||||||
[ -n "$TASK_ID" ] || exit 0
|
[ -n "$TASK_ID" ] || exit 0
|
||||||
|
|
||||||
PREFIX=${JSON.stringify(taskPrefix)}
|
PREFIX=${shellSingleQuote(taskPrefix)}
|
||||||
case "$TASK_ID" in
|
case "$TASK_ID" in
|
||||||
${taskPrefix}-*) ;;
|
"$PREFIX"-*) ;;
|
||||||
*) TASK_ID="$PREFIX-$(printf '%s' "$TASK_ID" | sed -E "s/^${taskPrefix}-//i")" ;;
|
*) TASK_ID="$PREFIX-$(printf '%s' "$TASK_ID" | sed -E ${shellSingleQuote(`s/^${taskPrefixSedEre}-//i`)})" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
TRAILER_NAME=${JSON.stringify(trailerName)}
|
TRAILER_NAME=${shellSingleQuote(trailerName)}
|
||||||
|
|
||||||
git interpret-trailers \
|
git interpret-trailers \
|
||||||
--in-place \
|
--in-place \
|
||||||
|
|||||||
Reference in New Issue
Block a user