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`
|
||||
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
|
||||
|
||||
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.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,
|
||||
CHAT_SESSION_TAGS_VERSION,
|
||||
DROP_GLOBAL_CONCURRENCY_VERSION,
|
||||
MISSION_TASK_PREFIX_VERSION,
|
||||
} from "../../postgres/schema-applier.js";
|
||||
import { ProjectPartitionRekeyError, rekeyFallbackProjectPartition } from "../../postgres/migration-stamping.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,
|
||||
CHAT_SESSION_TAGS_VERSION,
|
||||
DROP_GLOBAL_CONCURRENCY_VERSION,
|
||||
MISSION_TASK_PREFIX_VERSION,
|
||||
]);
|
||||
expect((await applySchemaBaseline(ctx.db, { pluginHooks: [] })).applied).toBe(false);
|
||||
});
|
||||
@@ -1740,6 +1742,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
MISSION_LINEAGE_STOP_VERSION,
|
||||
CHAT_SESSION_TAGS_VERSION,
|
||||
DROP_GLOBAL_CONCURRENCY_VERSION,
|
||||
MISSION_TASK_PREFIX_VERSION,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -1936,6 +1939,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
MISSION_LINEAGE_STOP_VERSION,
|
||||
CHAT_SESSION_TAGS_VERSION,
|
||||
DROP_GLOBAL_CONCURRENCY_VERSION,
|
||||
MISSION_TASK_PREFIX_VERSION,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -2013,6 +2017,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
MISSION_LINEAGE_STOP_VERSION,
|
||||
CHAT_SESSION_TAGS_VERSION,
|
||||
DROP_GLOBAL_CONCURRENCY_VERSION,
|
||||
MISSION_TASK_PREFIX_VERSION,
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -2090,6 +2095,7 @@ pgDescribe("schema-applier: automation project-isolation upgrade", () => {
|
||||
MISSION_LINEAGE_STOP_VERSION,
|
||||
CHAT_SESSION_TAGS_VERSION,
|
||||
DROP_GLOBAL_CONCURRENCY_VERSION,
|
||||
MISSION_TASK_PREFIX_VERSION,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -124,6 +124,8 @@ interface MissionRow {
|
||||
interviewState: string;
|
||||
baseBranch: string | null;
|
||||
branchStrategy: string | null;
|
||||
/** Per-mission ticket id prefix; null/absent inherits project settings.taskPrefix. */
|
||||
taskPrefix: string | null;
|
||||
autoMerge: number | null;
|
||||
autoAdvance: number | null;
|
||||
autopilotEnabled: number | null;
|
||||
@@ -284,6 +286,7 @@ const missionColumns = {
|
||||
interviewState: schema.project.missions.interviewState,
|
||||
baseBranch: schema.project.missions.baseBranch,
|
||||
branchStrategy: schema.project.missions.branchStrategy,
|
||||
taskPrefix: schema.project.missions.taskPrefix,
|
||||
autoMerge: schema.project.missions.autoMerge,
|
||||
autoAdvance: schema.project.missions.autoAdvance,
|
||||
autopilotEnabled: schema.project.missions.autopilotEnabled,
|
||||
@@ -438,6 +441,8 @@ function rowToMission(row: MissionRow): Mission {
|
||||
interviewState: row.interviewState as InterviewState,
|
||||
baseBranch: row.baseBranch ?? undefined,
|
||||
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),
|
||||
autoAdvance: Boolean(row.autoAdvance ?? 0),
|
||||
autopilotEnabled: Boolean(row.autopilotEnabled ?? 0),
|
||||
@@ -642,6 +647,8 @@ export async function createMission(
|
||||
interviewState: input.interviewState,
|
||||
baseBranch: input.baseBranch ?? null,
|
||||
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,
|
||||
autoAdvance: input.autoAdvance ? 1 : 0,
|
||||
autopilotEnabled: input.autopilotEnabled ? 1 : 0,
|
||||
@@ -689,6 +696,8 @@ export async function updateMission(
|
||||
interviewState: mission.interviewState,
|
||||
baseBranch: mission.baseBranch ?? null,
|
||||
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,
|
||||
autoAdvance: mission.autoAdvance ? 1 : 0,
|
||||
autopilotEnabled: mission.autopilotEnabled ? 1 : 0,
|
||||
@@ -1878,6 +1887,7 @@ export async function upsertMission(handle: QueryHandle, mission: Mission): Prom
|
||||
interviewState: mission.interviewState,
|
||||
baseBranch: mission.baseBranch ?? null,
|
||||
branchStrategy: serializeBranchStrategy(mission.branchStrategy),
|
||||
taskPrefix: mission.taskPrefix ?? null,
|
||||
autoMerge: mission.autoMerge === undefined ? null : mission.autoMerge ? 1 : 0,
|
||||
autoAdvance: mission.autoAdvance ? 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`,
|
||||
baseBranch: sql`excluded.base_branch`,
|
||||
branchStrategy: sql`excluded.branch_strategy`,
|
||||
taskPrefix: sql`excluded.task_prefix`,
|
||||
autoMerge: sql`excluded.auto_merge`,
|
||||
autoAdvance: sql`excluded.auto_advance`,
|
||||
autopilotEnabled: sql`excluded.autopilot_enabled`,
|
||||
|
||||
@@ -261,6 +261,7 @@ export class AsyncMissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
description: input.description,
|
||||
baseBranch: input.baseBranch,
|
||||
branchStrategy: input.branchStrategy,
|
||||
taskPrefix: input.taskPrefix,
|
||||
autoMerge: input.autoMerge,
|
||||
status: "planning",
|
||||
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.
|
||||
*/
|
||||
...(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 } : {}),
|
||||
});
|
||||
if (guard.fingerprint) {
|
||||
|
||||
@@ -234,6 +234,7 @@ interface MissionRow {
|
||||
interviewState: string;
|
||||
baseBranch: string | null;
|
||||
branchStrategy: string | null;
|
||||
taskPrefix: string | null;
|
||||
autoMerge: number | null;
|
||||
autoAdvance: number;
|
||||
autopilotEnabled: number;
|
||||
@@ -406,6 +407,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
super();
|
||||
this.setMaxListeners(100);
|
||||
this.ensureMissionContractAssertionColumns();
|
||||
this.ensureMissionTaskPrefixColumn();
|
||||
// Initialize sequence counter from existing events to ensure uniqueness across restarts
|
||||
const lastEvent = this.db.prepare(`
|
||||
SELECT seq FROM mission_events ORDER BY seq DESC LIMIT 1
|
||||
@@ -452,6 +454,24 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
`).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 ───────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -475,6 +495,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
interviewState: row.interviewState as InterviewState,
|
||||
baseBranch: row.baseBranch || undefined,
|
||||
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),
|
||||
autoAdvance: Boolean(row.autoAdvance),
|
||||
autopilotEnabled: Boolean(row.autopilotEnabled),
|
||||
@@ -703,6 +725,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
interviewState: "not_started",
|
||||
baseBranch: input.baseBranch,
|
||||
branchStrategy: input.branchStrategy,
|
||||
taskPrefix: input.taskPrefix,
|
||||
autoMerge: input.autoMerge,
|
||||
autoAdvance: false,
|
||||
autopilotEnabled: false,
|
||||
@@ -712,8 +735,8 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
};
|
||||
|
||||
this.db.prepare(`
|
||||
INSERT INTO missions (id, title, description, status, interviewState, baseBranch, branchStrategy, autoMerge, autoAdvance, autopilotEnabled, autopilotState, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
INSERT INTO missions (id, title, description, status, interviewState, baseBranch, branchStrategy, taskPrefix, autoMerge, autoAdvance, autopilotEnabled, autopilotState, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`).run(
|
||||
mission.id,
|
||||
mission.title,
|
||||
@@ -722,6 +745,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
mission.interviewState,
|
||||
mission.baseBranch ?? null,
|
||||
mission.branchStrategy ? JSON.stringify(mission.branchStrategy) : null,
|
||||
mission.taskPrefix ?? null,
|
||||
mission.autoMerge === undefined ? null : (mission.autoMerge ? 1 : 0),
|
||||
mission.autoAdvance ? 1 : 0,
|
||||
mission.autopilotEnabled ? 1 : 0,
|
||||
@@ -1343,11 +1367,12 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
|
||||
this.db.prepare(`
|
||||
UPDATE missions SET
|
||||
title = ?, description = ?, status = ?, interviewState = ?, baseBranch = ?, branchStrategy = ?,
|
||||
autoMerge = ?, autoAdvance = ?, autopilotEnabled = ?, autopilotState = ?,
|
||||
taskPrefix = ?, autoMerge = ?, autoAdvance = ?, autopilotEnabled = ?, autopilotState = ?,
|
||||
lastAutopilotActivityAt = ?, updatedAt = ? WHERE id = ?
|
||||
`).run(
|
||||
updated.title, updated.description ?? null, updated.status, updated.interviewState,
|
||||
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.autopilotEnabled ? 1 : 0, updated.autopilotState ?? "inactive",
|
||||
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.
|
||||
*/
|
||||
...(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 } : {}),
|
||||
});
|
||||
|
||||
|
||||
@@ -310,6 +310,11 @@ export interface Mission {
|
||||
baseBranch?: string;
|
||||
/** Mission triage branch strategy: auto-per-task => assignmentMode "per-task-derived"; existing/custom-new => shared branchName; project-default/absent => shared default behavior. */
|
||||
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 */
|
||||
interviewState: InterviewState;
|
||||
/**
|
||||
@@ -579,6 +584,11 @@ export interface MissionCreateInput {
|
||||
baseBranch?: string;
|
||||
/** Optional branch strategy applied as the default for mission triage operations. */
|
||||
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. */
|
||||
autoMerge?: boolean;
|
||||
}
|
||||
|
||||
@@ -872,6 +872,8 @@ CREATE TABLE IF NOT EXISTS project.missions (
|
||||
branch_strategy text,
|
||||
auto_advance integer DEFAULT 0,
|
||||
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_state text NOT NULL DEFAULT 'inactive',
|
||||
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
|
||||
forward migration must run before TaskStore writes the new field on fresh and
|
||||
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. */
|
||||
export const TASK_DECLARED_SYMBOLS_VERSION = "0028";
|
||||
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.
|
||||
*/
|
||||
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. */
|
||||
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 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 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
|
||||
@@ -478,6 +492,7 @@ export async function applySchemaBaseline(
|
||||
const missionLineageStopAlreadyApplied = applied.includes(MISSION_LINEAGE_STOP_VERSION);
|
||||
const chatSessionTagsAlreadyApplied = applied.includes(CHAT_SESSION_TAGS_VERSION);
|
||||
const dropGlobalConcurrencyAlreadyApplied = applied.includes(DROP_GLOBAL_CONCURRENCY_VERSION);
|
||||
const missionTaskPrefixAlreadyApplied = applied.includes(MISSION_TASK_PREFIX_VERSION);
|
||||
assertBinaryNotOlderThanDatabase(applied);
|
||||
let schemaChanged = false;
|
||||
|
||||
@@ -1013,6 +1028,20 @@ export async function applySchemaBaseline(
|
||||
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 };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1258,6 +1258,11 @@ export const missions = projectSchema.table("missions", {
|
||||
branchStrategy: text("branch_strategy"),
|
||||
autoAdvance: integer("auto_advance").default(0),
|
||||
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:
|
||||
// Autopilot columns were added via addColumnIfMissing in SQLite migrations
|
||||
// (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 {recordRunAuditEvent as recordRunAuditEventAsync} from "../task-store/async-audit.js";
|
||||
import type {DbTransaction} from "../postgres/data-layer.js";
|
||||
import { resolveTaskPrefix } from "./task-prefix.js";
|
||||
|
||||
type CreateTaskWithAfterInsert = TaskCreateInput & {
|
||||
/** 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.
|
||||
const allocator = store.getDistributedTaskIdAllocator();
|
||||
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 reservation = await allocator.reserveDistributedTaskId({
|
||||
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 {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 { resolveTaskPrefix } from "./task-prefix.js";
|
||||
import {COLUMNS} from "../types.js";
|
||||
import {parseWorkflowIr, serializeWorkflowIr} from "../workflow-ir.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> {
|
||||
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 nodeId = await store.resolveLocalNodeIdForTaskAllocation();
|
||||
const reservation = await allocator.reserveDistributedTaskId({
|
||||
|
||||
@@ -1306,6 +1306,12 @@ export interface TaskCreateInput {
|
||||
branch?: string;
|
||||
/** Optional planning/mission branch-group metadata carried across related tasks. */
|
||||
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. */
|
||||
autoMerge?: boolean;
|
||||
/** Durable source provenance for the originating external issue. */
|
||||
|
||||
@@ -47,6 +47,11 @@ export interface Mission {
|
||||
status: MissionStatus;
|
||||
interviewState: "not_started" | "in_progress" | "completed" | "needs_update";
|
||||
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:
|
||||
* 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 */
|
||||
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), {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
@@ -154,7 +159,7 @@ export function fetchMission(missionId: string, projectId?: string): Promise<Mis
|
||||
}
|
||||
|
||||
/** 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), {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(updates),
|
||||
|
||||
@@ -302,6 +302,7 @@ interface MissionFormData {
|
||||
autoMergeOverride: MissionAutoMergeOverride;
|
||||
baseBranch: string;
|
||||
branchStrategy: MissionBranchStrategy;
|
||||
taskPrefix: string;
|
||||
}
|
||||
|
||||
interface MilestoneFormData {
|
||||
@@ -335,6 +336,7 @@ const EMPTY_MISSION_FORM: MissionFormData = {
|
||||
branchStrategy: {
|
||||
mode: "project-default",
|
||||
},
|
||||
taskPrefix: "",
|
||||
};
|
||||
|
||||
const EMPTY_MILESTONE_FORM: MilestoneFormData = {
|
||||
@@ -1694,6 +1696,13 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
]);
|
||||
|
||||
// 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) => {
|
||||
setEditingMissionId(mission.id);
|
||||
setIsCreatingMission(false);
|
||||
@@ -1705,6 +1714,7 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
autoMergeOverride: missionAutoMergeOverride(mission.autoMerge),
|
||||
baseBranch: mission.baseBranch ?? "",
|
||||
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,
|
||||
branchStrategy,
|
||||
taskPrefix: missionForm.taskPrefix.trim() || undefined,
|
||||
}, projectId);
|
||||
addToast(t("missions.created", "Mission created"), "success");
|
||||
} else if (editingMissionId) {
|
||||
@@ -1759,6 +1770,11 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
autoMerge: resolveMissionAutoMerge(missionForm.autoMergeOverride) ?? null,
|
||||
baseBranch: missionForm.baseBranch.trim() || "",
|
||||
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) {
|
||||
updates.autoAdvance = true;
|
||||
@@ -2932,6 +2948,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
aria-label={t("missions.missionTargetBranchAriaLabel", "Mission target branch")}
|
||||
/>
|
||||
</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>
|
||||
{t("missions.branchStrategy", "Branch strategy")}
|
||||
<select
|
||||
@@ -4699,6 +4725,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
aria-label={t("missions.missionTargetBranchAriaLabel", "Mission target branch")}
|
||||
/>
|
||||
</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>
|
||||
{t("missions.branchStrategy", "Branch strategy")}
|
||||
<select
|
||||
@@ -4808,6 +4844,16 @@ export function MissionManager({ isOpen, isInline = false, onClose, addToast, pr
|
||||
aria-label={t("missions.missionTargetBranchAriaLabel", "Mission target branch")}
|
||||
/>
|
||||
</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>
|
||||
{t("missions.branchStrategy", "Branch strategy")}
|
||||
<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;
|
||||
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:
|
||||
* 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[] {
|
||||
if (!body || typeof body !== "object") {
|
||||
throw new Error("Request body must contain orderedIds array");
|
||||
@@ -506,7 +523,7 @@ export function createMissionRouter(
|
||||
router.post(
|
||||
"/",
|
||||
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 validatedDescription = validateDescription(description);
|
||||
@@ -517,6 +534,7 @@ export function createMissionRouter(
|
||||
description: validatedDescription,
|
||||
baseBranch: validateDescription(baseBranch),
|
||||
branchStrategy: validateMissionBranchStrategy(branchStrategy),
|
||||
taskPrefix: validateTaskPrefix(taskPrefix),
|
||||
...(autoMerge !== undefined
|
||||
? {
|
||||
// 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",
|
||||
catchTypedHandler(async (req, res) => {
|
||||
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)) {
|
||||
throw badRequest("Invalid mission ID format");
|
||||
@@ -1199,6 +1217,9 @@ export function createMissionRouter(
|
||||
if (branchStrategy !== undefined) {
|
||||
updates.branchStrategy = validateMissionBranchStrategy(branchStrategy);
|
||||
}
|
||||
if (taskPrefix !== undefined) {
|
||||
updates.taskPrefix = validateTaskPrefix(taskPrefix);
|
||||
}
|
||||
|
||||
if (Object.keys(updates).length === 0 && validatedGoalIds === undefined) {
|
||||
throw badRequest("No valid fields to update");
|
||||
|
||||
@@ -60,7 +60,7 @@ describe("worktree-hooks", () => {
|
||||
expect(hook).toContain("--if-exists doNothing");
|
||||
expect(hook).toContain("--trailer \"$TRAILER_NAME: $TASK_ID\"");
|
||||
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");
|
||||
});
|
||||
|
||||
@@ -69,7 +69,7 @@ describe("worktree-hooks", () => {
|
||||
commitAuthorName: "Fusion Bot",
|
||||
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");
|
||||
|
||||
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", () => {
|
||||
const hook = buildCommitMsgTrailerHook("KB-9", { taskPrefix: "KB", trailerName: "Task-Id" });
|
||||
expect(hook).toContain('PREFIX="KB"');
|
||||
expect(hook).toContain('TRAILER_NAME="Task-Id"');
|
||||
expect(hook).toContain("PREFIX='KB'");
|
||||
expect(hook).toContain("TRAILER_NAME='Task-Id'");
|
||||
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 () => {
|
||||
const root = mkdtempSync(join(tmpdir(), "wt-hook-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(
|
||||
taskId: string,
|
||||
options: {
|
||||
@@ -209,7 +225,14 @@ export function buildCommitMsgTrailerHook(
|
||||
commitAuthorEmail?: 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 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;
|
||||
@@ -219,7 +242,7 @@ export function buildCommitMsgTrailerHook(
|
||||
|
||||
# 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_TRAILER=${JSON.stringify(`Co-authored-by: ${commitAuthorName} <${commitAuthorEmail}>`)}
|
||||
CO_AUTHOR_TRAILER=${shellSingleQuote(`Co-authored-by: ${commitAuthorName} <${commitAuthorEmail}>`)}
|
||||
git interpret-trailers \\
|
||||
--in-place \\
|
||||
--if-exists addIfDifferent \\
|
||||
@@ -236,13 +259,13 @@ TASK_FILE=$(git rev-parse --git-path fusion-task-id)
|
||||
TASK_ID=$(cat "$TASK_FILE")
|
||||
[ -n "$TASK_ID" ] || exit 0
|
||||
|
||||
PREFIX=${JSON.stringify(taskPrefix)}
|
||||
PREFIX=${shellSingleQuote(taskPrefix)}
|
||||
case "$TASK_ID" in
|
||||
${taskPrefix}-*) ;;
|
||||
*) TASK_ID="$PREFIX-$(printf '%s' "$TASK_ID" | sed -E "s/^${taskPrefix}-//i")" ;;
|
||||
"$PREFIX"-*) ;;
|
||||
*) TASK_ID="$PREFIX-$(printf '%s' "$TASK_ID" | sed -E ${shellSingleQuote(`s/^${taskPrefixSedEre}-//i`)})" ;;
|
||||
esac
|
||||
|
||||
TRAILER_NAME=${JSON.stringify(trailerName)}
|
||||
TRAILER_NAME=${shellSingleQuote(trailerName)}
|
||||
|
||||
git interpret-trailers \
|
||||
--in-place \
|
||||
|
||||
Reference in New Issue
Block a user