From f9ed42d9b75df5734aa98f2b7d57c0e57271235a Mon Sep 17 00:00:00 2001 From: Lenucksi Date: Wed, 6 May 2026 22:19:19 +0200 Subject: [PATCH 1/4] =?UTF-8?q?BACK-465=20-=20Config-Schema:=20terminalSta?= =?UTF-8?q?tuses-Key=20einf=C3=BChren?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add terminalStatuses?: string[] to BacklogConfig interface - Parse terminal_statuses key in config loader (same pattern as statuses) - Extend getTerminalStatus/isTerminalStatus with optional terminalStatuses param - Falls back to last-element convention when not provided (backward-compatible) - isTerminalStatus returns true for any entry in terminalStatuses array - getTerminalStatus returns terminalStatuses[0] as primary terminal status - Add 5 new test cases covering multi-terminal, fallback, empty-array, and non-last-element override scenarios Co-Authored-By: Claude Sonnet 4.6 --- src/file-system/operations.ts | 10 +++++++++ src/test/terminal-status.test.ts | 37 ++++++++++++++++++++++++++++++++ src/types/index.ts | 1 + src/utils/terminal-status.ts | 19 ++++++++++++++-- 4 files changed, 65 insertions(+), 2 deletions(-) diff --git a/src/file-system/operations.ts b/src/file-system/operations.ts index 160d075de..df94dbc07 100644 --- a/src/file-system/operations.ts +++ b/src/file-system/operations.ts @@ -1367,6 +1367,15 @@ ${description || `Milestone: ${title}`}`, .filter(Boolean); } break; + case "terminal_statuses": + if (value.startsWith("[") && value.endsWith("]")) { + const arrayContent = value.slice(1, -1); + config.terminalStatuses = arrayContent + .split(",") + .map((item) => item.trim().replace(/['"]/g, "")) + .filter(Boolean); + } + break; case "definition_of_done": if (parsedDefinitionOfDone !== undefined) { config.definitionOfDone = parsedDefinitionOfDone; @@ -1429,6 +1438,7 @@ ${description || `Milestone: ${title}`}`, defaultAssignee: config.defaultAssignee, defaultReporter: config.defaultReporter, statuses: config.statuses || [...DEFAULT_STATUSES], + terminalStatuses: config.terminalStatuses, labels: config.labels || [], definitionOfDone: config.definitionOfDone, defaultStatus: config.defaultStatus, diff --git a/src/test/terminal-status.test.ts b/src/test/terminal-status.test.ts index 0b39d7ddf..1af601dd8 100644 --- a/src/test/terminal-status.test.ts +++ b/src/test/terminal-status.test.ts @@ -17,3 +17,40 @@ describe("terminal status helpers", () => { expect(isTerminalStatus("In Progress", ["To Do", "In Progress", "InProgress"])).toBe(false); }); }); + +describe("terminalStatuses override", () => { + it("getTerminalStatus returns first terminalStatuses entry when provided", () => { + expect(getTerminalStatus(["Offen", "Blockiert", "Fertig"], ["Fertig", "Abgebrochen"])).toBe("Fertig"); + }); + + it("isTerminalStatus matches any entry in terminalStatuses", () => { + const statuses = ["Offen", "In Arbeit", "Fertig"]; + const terminalStatuses = ["Fertig", "Abgebrochen"]; + expect(isTerminalStatus("Fertig", statuses, terminalStatuses)).toBe(true); + expect(isTerminalStatus("Abgebrochen", statuses, terminalStatuses)).toBe(true); + expect(isTerminalStatus("fertig", statuses, terminalStatuses)).toBe(true); + expect(isTerminalStatus("Offen", statuses, terminalStatuses)).toBe(false); + expect(isTerminalStatus("In Arbeit", statuses, terminalStatuses)).toBe(false); + }); + + it("falls back to last-element convention when terminalStatuses is absent", () => { + const statuses = ["Offen", "In Arbeit", "Fertig"]; + expect(isTerminalStatus("Fertig", statuses)).toBe(true); + expect(isTerminalStatus("Fertig", statuses, undefined)).toBe(true); + expect(isTerminalStatus("Fertig", statuses, null)).toBe(true); + expect(isTerminalStatus("Offen", statuses)).toBe(false); + }); + + it("falls back to last-element when terminalStatuses is empty array", () => { + const statuses = ["Offen", "Fertig"]; + expect(isTerminalStatus("Fertig", statuses, [])).toBe(true); + expect(isTerminalStatus("Offen", statuses, [])).toBe(false); + }); + + it("non-last status not treated as terminal when terminalStatuses overrides", () => { + const statuses = ["Offen", "In Arbeit", "Fertig", "Blockiert"]; + const terminalStatuses = ["Fertig"]; + expect(isTerminalStatus("Fertig", statuses, terminalStatuses)).toBe(true); + expect(isTerminalStatus("Blockiert", statuses, terminalStatuses)).toBe(false); + }); +}); diff --git a/src/types/index.ts b/src/types/index.ts index 6a44ff93d..f92119e79 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -283,6 +283,7 @@ export interface BacklogConfig { defaultAssignee?: string; defaultReporter?: string; statuses: string[]; + terminalStatuses?: string[]; labels: string[]; /** @deprecated Milestones are sourced from milestone files, not config. */ milestones?: string[]; diff --git a/src/utils/terminal-status.ts b/src/utils/terminal-status.ts index 1bf75263a..05d2851cf 100644 --- a/src/utils/terminal-status.ts +++ b/src/utils/terminal-status.ts @@ -1,4 +1,11 @@ -export function getTerminalStatus(statuses: readonly string[]): string | null { +export function getTerminalStatus( + statuses: readonly string[], + terminalStatuses?: readonly string[] | null, +): string | null { + if (terminalStatuses && terminalStatuses.length > 0) { + const primary = terminalStatuses[0]; + return primary && primary.trim().length > 0 ? primary : null; + } if (statuses.length === 0) return null; const terminalStatus = statuses[statuses.length - 1]; return terminalStatus && terminalStatus.trim().length > 0 ? terminalStatus : null; @@ -8,7 +15,15 @@ function normalizeStatusForComparison(status: string | null | undefined): string return (status ?? "").trim().toLowerCase(); } -export function isTerminalStatus(status: string | null | undefined, statuses: readonly string[]): boolean { +export function isTerminalStatus( + status: string | null | undefined, + statuses: readonly string[], + terminalStatuses?: readonly string[] | null, +): boolean { + if (terminalStatuses && terminalStatuses.length > 0) { + const normalized = normalizeStatusForComparison(status); + return terminalStatuses.some((ts) => normalizeStatusForComparison(ts) === normalized); + } const terminalStatus = getTerminalStatus(statuses); return ( terminalStatus !== null && normalizeStatusForComparison(status) === normalizeStatusForComparison(terminalStatus) From bc5943af5e857d56c815fb0f17191aad1e29517f Mon Sep 17 00:00:00 2001 From: Lenucksi Date: Thu, 7 May 2026 00:06:37 +0200 Subject: [PATCH 2/4] BACK-467 - Replace hardcoded done-checks with isTerminalStatus in sequences and board filters Co-Authored-By: Claude Sonnet 4.6 --- src/cli.ts | 5 +++-- src/core/backlog.ts | 10 +++++++--- src/ui/board.ts | 34 ++++++++++++++++++++------------ src/ui/sequences.ts | 6 +++++- src/web/App.tsx | 1 + src/web/components/Board.tsx | 8 ++++++-- src/web/components/BoardPage.tsx | 3 +++ src/web/lib/lanes.test.ts | 2 +- src/web/lib/lanes.ts | 25 ++++++++++++++++++----- 9 files changed, 67 insertions(+), 27 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 9ac4edd60..19517f54e 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3344,8 +3344,9 @@ sequenceCmd const cwd = await requireProjectRoot(); const core = new Core(cwd); const tasks = await core.queryTasks(); - // Exclude tasks marked as Done from sequences (case-insensitive) - const activeTasks = tasks.filter((t) => (t.status || "").toLowerCase() !== "done"); + const config = await core.fs.loadConfig(); + const statuses = config?.statuses ?? [...DEFAULT_STATUSES]; + const activeTasks = tasks.filter((t) => !isTerminalStatus(t.status, statuses, config?.terminalStatuses)); const { unsequenced, sequences } = computeSequences(activeTasks); const usePlainOutput = isPlainRequested(options) || shouldAutoPlain; diff --git a/src/core/backlog.ts b/src/core/backlog.ts index 2d3a0d52e..4b7f6d507 100644 --- a/src/core/backlog.ts +++ b/src/core/backlog.ts @@ -1952,7 +1952,9 @@ export class Core { // Sequences operations (business logic lives in core, not server) async listActiveSequences(): Promise<{ unsequenced: Task[]; sequences: Sequence[] }> { const all = await this.fs.listTasks(); - const active = all.filter((t) => (t.status || "").toLowerCase() !== "done"); + const config = await this.fs.loadConfig(); + const statuses = config?.statuses ?? [...DEFAULT_STATUSES]; + const active = all.filter((t) => !isTerminalStatus(t.status, statuses, config?.terminalStatuses)); return computeSequences(active); } @@ -1968,7 +1970,9 @@ export class Core { const exists = allTasks.some((t) => t.id === taskId); if (!exists) throw new Error(`Task ${taskId} not found`); - const active = allTasks.filter((t) => (t.status || "").toLowerCase() !== "done"); + const config = await this.fs.loadConfig(); + const statuses = config?.statuses ?? [...DEFAULT_STATUSES]; + const active = allTasks.filter((t) => !isTerminalStatus(t.status, statuses, config?.terminalStatuses)); const { sequences } = computeSequences(active); if (params.unsequenced) { @@ -1987,7 +1991,7 @@ export class Core { // Return updated sequences const afterAll = await this.fs.listTasks(); - const afterActive = afterAll.filter((t) => (t.status || "").toLowerCase() !== "done"); + const afterActive = afterAll.filter((t) => !isTerminalStatus(t.status, statuses, config?.terminalStatuses)); return computeSequences(afterActive); } diff --git a/src/ui/board.ts b/src/ui/board.ts index a5269d352..4260e54ef 100644 --- a/src/ui/board.ts +++ b/src/ui/board.ts @@ -13,6 +13,7 @@ import { collectAvailableLabels } from "../utils/label-filter.ts"; import { NO_MILESTONE_FILTER_LABEL, NO_MILESTONE_FILTER_VALUE } from "../utils/milestone-filter.ts"; import { applySharedTaskFilters, createTaskSearchIndex } from "../utils/task-search.ts"; import { compareTaskIds } from "../utils/task-sorting.ts"; +import { isTerminalStatus } from "../utils/terminal-status.ts"; import { openConfirmPopup } from "./components/confirm-popup.ts"; import { createFilterHeader, type FilterHeader, type FilterState } from "./components/filter-header.ts"; import { openMultiSelectFilterPopup, openSingleSelectFilterPopup } from "./components/filter-popup.ts"; @@ -47,12 +48,13 @@ type ColumnView = { highlightedIndex?: number; }; -function isDoneStatus(status: string): boolean { - const normalized = status.trim().toLowerCase(); - return normalized === "done" || normalized === "completed" || normalized === "complete"; -} - -function buildColumnTasks(status: string, items: Task[], byId: Map): Task[] { +function buildColumnTasks( + status: string, + items: Task[], + byId: Map, + statuses: readonly string[], + terminalStatuses?: readonly string[] | null, +): Task[] { const topLevel: Task[] = []; const childrenByParent = new Map(); const sorted = items.slice().sort((a, b) => { @@ -71,7 +73,7 @@ function buildColumnTasks(status: string, items: Task[], byId: Map return 1; } - const columnIsDone = isDoneStatus(status); + const columnIsDone = isTerminalStatus(status, statuses, terminalStatuses); if (columnIsDone) { return compareTaskIds(b.id, a.id); } @@ -101,13 +103,17 @@ function buildColumnTasks(status: string, items: Task[], byId: Map return ordered; } -function prepareBoardColumns(tasks: Task[], statuses: string[]): ColumnData[] { +function prepareBoardColumns( + tasks: Task[], + statuses: string[], + terminalStatuses?: readonly string[] | null, +): ColumnData[] { const { orderedStatuses, groupedTasks } = buildKanbanStatusGroups(tasks, statuses); const byId = new Map(tasks.map((task) => [task.id, task])); return orderedStatuses.map((status) => { const items = groupedTasks.get(status) ?? []; - const orderedTasks = buildColumnTasks(status, items, byId); + const orderedTasks = buildColumnTasks(status, items, byId, statuses, terminalStatuses); return { status, tasks: orderedTasks }; }); } @@ -202,6 +208,7 @@ export async function renderBoardTui( }) => void; milestoneMode?: boolean; milestoneEntities?: Milestone[]; + terminalStatuses?: readonly string[] | null; }, ): Promise { if (!process.stdout.isTTY) { @@ -213,7 +220,7 @@ export async function renderBoardTui( return; } - const initialColumns = prepareBoardColumns(initialTasks, statuses); + const initialColumns = prepareBoardColumns(initialTasks, statuses, options?.terminalStatuses); if (initialColumns.length === 0) { console.log("No tasks available for the Kanban board."); return; @@ -238,6 +245,7 @@ export async function renderBoardTui( let columns: ColumnView[] = []; let currentColumnsData = initialColumns; let currentStatuses = currentColumnsData.map((column) => column.status); + const currentTerminalStatuses = options?.terminalStatuses; let currentCol = 0; let popupOpen = false; let currentFocus: "board" | "filters" = "board"; @@ -574,7 +582,7 @@ export async function renderBoardTui( // Pure function to calculate the projected board state const getProjectedColumns = (allTasks: Task[], operation: MoveOperation | null): ColumnData[] => { if (!operation) { - return prepareBoardColumns(allTasks, currentStatuses); + return prepareBoardColumns(allTasks, currentStatuses, currentTerminalStatuses); } // 1. Filter out the moving task from the source @@ -582,11 +590,11 @@ export async function renderBoardTui( const movingTask = allTasks.find((t) => t.id === operation.taskId); if (!movingTask) { - return prepareBoardColumns(allTasks, currentStatuses); + return prepareBoardColumns(allTasks, currentStatuses, currentTerminalStatuses); } // 2. Prepare columns without the moving task - const columns = prepareBoardColumns(tasksWithoutMoving, currentStatuses); + const columns = prepareBoardColumns(tasksWithoutMoving, currentStatuses, currentTerminalStatuses); // 3. Insert the moving task into the target column at the target index const targetColumn = columns.find((c) => c.status === operation.targetStatus); diff --git a/src/ui/sequences.ts b/src/ui/sequences.ts index 951d75d92..da44e7e09 100644 --- a/src/ui/sequences.ts +++ b/src/ui/sequences.ts @@ -1,8 +1,10 @@ import { stdout as output } from "node:process"; import type { BoxInterface } from "neo-neo-bblessed"; import { box, scrollablebox } from "neo-neo-bblessed"; +import { DEFAULT_STATUSES } from "../constants/index.ts"; import type { Core } from "../index.ts"; import type { Sequence, Task } from "../types/index.ts"; +import { isTerminalStatus } from "../utils/terminal-status.ts"; import { createTaskPopup } from "./task-viewer-with-search.ts"; import { createScreen } from "./tui.ts"; @@ -417,7 +419,9 @@ export async function runSequencesView( } // Reload and rerender const tasksNew = await core.queryTasks(); - const active = tasksNew.filter((t) => (t.status || "").toLowerCase() !== "done"); + const cfg = await core.fs.loadConfig(); + const seqStatuses = cfg?.statuses ?? [...DEFAULT_STATUSES]; + const active = tasksNew.filter((t) => !isTerminalStatus(t.status, seqStatuses, cfg?.terminalStatuses)); const { computeSequences: recompute } = await import("../core/sequences.ts"); const next = recompute(active); screen.destroy(); diff --git a/src/web/App.tsx b/src/web/App.tsx index 190550707..b6a7db165 100644 --- a/src/web/App.tsx +++ b/src/web/App.tsx @@ -497,6 +497,7 @@ function App() { tasks={tasks} onRefreshData={refreshData} statuses={statuses} + terminalStatuses={config?.terminalStatuses} milestones={milestones} availableLabels={availableLabels} milestoneEntities={milestoneEntities} diff --git a/src/web/components/Board.tsx b/src/web/components/Board.tsx index 6deb4af49..e0f31c5db 100644 --- a/src/web/components/Board.tsx +++ b/src/web/components/Board.tsx @@ -17,6 +17,7 @@ interface BoardProps { tasks: Task[]; onRefreshData?: () => Promise; statuses: string[]; + terminalStatuses?: string[]; isLoading: boolean; milestones: string[]; availableLabels: string[]; @@ -51,6 +52,7 @@ const Board: React.FC = ({ tasks, onRefreshData, statuses, + terminalStatuses, isLoading, availableLabels, milestoneEntities, @@ -331,8 +333,9 @@ const Board: React.FC = ({ archivedMilestoneIds, milestoneEntities, archivedMilestones, + terminalStatuses, }), - [laneMode, lanes, statuses, tasks, archivedMilestoneIds, milestoneEntities, archivedMilestones] + [laneMode, lanes, statuses, tasks, archivedMilestoneIds, milestoneEntities, archivedMilestones, terminalStatuses] ); // Separate grouping for filtered display in columns @@ -342,8 +345,9 @@ const Board: React.FC = ({ archivedMilestoneIds, milestoneEntities, archivedMilestones, + terminalStatuses, }), - [laneMode, lanes, statuses, filteredTasks, archivedMilestoneIds, milestoneEntities, archivedMilestones] + [laneMode, lanes, statuses, filteredTasks, archivedMilestoneIds, milestoneEntities, archivedMilestones, terminalStatuses] ); const displayTasksByLane = (milestoneFilter || hasActiveFilters) ? filteredTasksByLane : tasksByLane; diff --git a/src/web/components/BoardPage.tsx b/src/web/components/BoardPage.tsx index 5f2ef0c2f..ac730cffa 100644 --- a/src/web/components/BoardPage.tsx +++ b/src/web/components/BoardPage.tsx @@ -10,6 +10,7 @@ interface BoardPageProps { tasks: Task[]; onRefreshData?: () => Promise; statuses: string[]; + terminalStatuses?: string[]; milestones: string[]; availableLabels: string[]; milestoneEntities: Milestone[]; @@ -23,6 +24,7 @@ export default function BoardPage({ tasks, onRefreshData, statuses, + terminalStatuses, milestones, availableLabels, milestoneEntities, @@ -127,6 +129,7 @@ export default function BoardPage({ tasks={tasks} onRefreshData={onRefreshData} statuses={statuses} + terminalStatuses={terminalStatuses} milestones={milestones} milestoneEntities={milestoneEntities} archivedMilestones={archivedMilestones} diff --git a/src/web/lib/lanes.test.ts b/src/web/lib/lanes.test.ts index fb4c24f0c..9286c2043 100644 --- a/src/web/lib/lanes.test.ts +++ b/src/web/lib/lanes.test.ts @@ -174,7 +174,7 @@ describe("sortTasksForStatus", () => { makeTask({ id: "task-3", status: "Done", updatedDate: "2024-01-03", createdDate: "2024-01-01" }), ]; - const sorted = sortTasksForStatus(tasks, "Done").map((t) => t.id); + const sorted = sortTasksForStatus(tasks, "Done", ["To Do", "In Progress", "Done"]).map((t) => t.id); expect(sorted).toEqual(["task-2", "task-3", "task-1"]); }); }); diff --git a/src/web/lib/lanes.ts b/src/web/lib/lanes.ts index ef2d06b08..5d6b3e89b 100644 --- a/src/web/lib/lanes.ts +++ b/src/web/lib/lanes.ts @@ -1,4 +1,5 @@ import type { Milestone, Task } from "../../types"; +import { isTerminalStatus } from "../../utils/terminal-status"; import { getMilestoneLabel, milestoneKey, normalizeMilestoneName } from "../utils/milestones"; export type LaneMode = "none" | "milestone"; @@ -202,8 +203,13 @@ export function buildLanes( ]; } -export function sortTasksForStatus(tasks: Task[], status: string): Task[] { - const isDoneStatus = status.toLowerCase().includes("done") || status.toLowerCase().includes("complete"); +export function sortTasksForStatus( + tasks: Task[], + status: string, + statuses?: readonly string[], + terminalStatuses?: readonly string[] | null, +): Task[] { + const isDoneStatus = isTerminalStatus(status, statuses ?? [], terminalStatuses); return tasks.slice().sort((a, b) => { // Tasks with ordinal come before tasks without @@ -241,6 +247,8 @@ export function buildGlobalOrderedTaskIdsForMilestoneLaneReorder(params: { targetStatus: string; targetMilestone: string | null; laneOrderedTaskIds: string[]; + statuses?: readonly string[]; + terminalStatuses?: readonly string[] | null; }): string[] { const targetMilestoneValue = normalizeMilestoneValue(params.targetMilestone); const taskId = String(params.taskId || "").trim(); @@ -260,7 +268,9 @@ export function buildGlobalOrderedTaskIdsForMilestoneLaneReorder(params: { }); const statusTasks = nextTasks.filter((task) => (task.status ?? "") === targetStatus); - const templateIds = sortTasksForStatus(statusTasks, targetStatus).map((task) => task.id); + const templateIds = sortTasksForStatus(statusTasks, targetStatus, params.statuses, params.terminalStatuses).map( + (task) => task.id, + ); if (templateIds.length === 0) { return params.laneOrderedTaskIds; @@ -330,7 +340,12 @@ export function groupTasksByLaneAndStatus( lanes: LaneDefinition[], statuses: string[], tasks: Task[], - options?: { archivedMilestoneIds?: string[]; milestoneEntities?: Milestone[]; archivedMilestones?: Milestone[] }, + options?: { + archivedMilestoneIds?: string[]; + milestoneEntities?: Milestone[]; + archivedMilestones?: Milestone[]; + terminalStatuses?: string[]; + }, ): Map> { const result = new Map>(); const archivedKeys = new Set((options?.archivedMilestoneIds ?? []).map((id) => milestoneKey(id))); @@ -380,7 +395,7 @@ export function groupTasksByLaneAndStatus( for (const [, statusMap] of result) { for (const [status, list] of statusMap) { - statusMap.set(status, sortTasksForStatus(list, status)); + statusMap.set(status, sortTasksForStatus(list, status, statuses, options?.terminalStatuses)); } } From 06c432a96e30311cd61ed10488d64a3993e0bb73 Mon Sep 17 00:00:00 2001 From: Lenucksi Date: Thu, 7 May 2026 00:29:57 +0200 Subject: [PATCH 3/4] BACK-469 - Add terminalStatuses to config get/set CLI commands and fix serializeConfig Co-Authored-By: Claude Sonnet 4.6 --- src/cli.ts | 15 ++++++++-- src/file-system/operations.ts | 4 +++ src/test/config-commands.test.ts | 50 ++++++++++++++++++++++++++++++++ 3 files changed, 67 insertions(+), 2 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 19517f54e..d9de0703a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -3450,10 +3450,13 @@ configCmd case "activeBranchDays": console.log(config.activeBranchDays?.toString() || "30"); break; + case "terminalStatuses": + console.log(config.terminalStatuses?.join(", ") || ""); + break; default: console.error(`Unknown config key: ${key}`); console.error( - "Available keys: defaultEditor, projectName, defaultStatus, statuses, labels, milestones, definitionOfDone, dateFormat, maxColumnWidth, defaultPort, autoOpenBrowser, remoteOperations, autoCommit, filesystemOnly, bypassGitHooks, zeroPaddedIds, checkActiveBranches, activeBranchDays", + "Available keys: defaultEditor, projectName, defaultStatus, statuses, labels, milestones, definitionOfDone, dateFormat, maxColumnWidth, defaultPort, autoOpenBrowser, remoteOperations, autoCommit, filesystemOnly, bypassGitHooks, zeroPaddedIds, checkActiveBranches, activeBranchDays, terminalStatuses", ); process.exit(1); } @@ -3613,6 +3616,14 @@ configCmd config.activeBranchDays = days; break; } + case "terminalStatuses": { + const parsed = value + .split(",") + .map((s) => s.trim()) + .filter((s) => s.length > 0); + config.terminalStatuses = parsed.length > 0 ? parsed : undefined; + break; + } case "statuses": case "labels": case "milestones": @@ -3644,7 +3655,7 @@ configCmd default: console.error(`Unknown config key: ${key}`); console.error( - "Available keys: defaultEditor, projectName, defaultStatus, dateFormat, maxColumnWidth, autoOpenBrowser, defaultPort, remoteOperations, autoCommit, filesystemOnly, bypassGitHooks, zeroPaddedIds, checkActiveBranches, activeBranchDays", + "Available keys: defaultEditor, projectName, defaultStatus, dateFormat, maxColumnWidth, autoOpenBrowser, defaultPort, remoteOperations, autoCommit, filesystemOnly, bypassGitHooks, zeroPaddedIds, checkActiveBranches, activeBranchDays, terminalStatuses", ); process.exit(1); } diff --git a/src/file-system/operations.ts b/src/file-system/operations.ts index df94dbc07..6ec0bb5a5 100644 --- a/src/file-system/operations.ts +++ b/src/file-system/operations.ts @@ -1297,6 +1297,7 @@ ${description || `Milestone: ${title}`}`, ...config, ...(this.configSource === "root" ? { backlogDirectory: this.resolvedBacklogDirName } : {}), definitionOfDone: this.normalizeDefinitionOfDone(config.definitionOfDone), + terminalStatuses: config.terminalStatuses?.length ? config.terminalStatuses : undefined, }; if (this.configSource === "folder") { delete normalizedConfig.backlogDirectory; @@ -1468,6 +1469,9 @@ ${description || `Milestone: ${title}`}`, ...(config.defaultReporter ? [`default_reporter: "${config.defaultReporter}"`] : []), ...(config.defaultStatus ? [`default_status: "${config.defaultStatus}"`] : []), `statuses: [${config.statuses.map((s) => `"${s}"`).join(", ")}]`, + ...(Array.isArray(config.terminalStatuses) && config.terminalStatuses.length > 0 + ? [`terminal_statuses: [${config.terminalStatuses.map((s) => `"${s}"`).join(", ")}]`] + : []), `labels: [${config.labels.map((l) => `"${l}"`).join(", ")}]`, ...(Array.isArray(normalizedDefinitionOfDone) ? [`definition_of_done: [${normalizedDefinitionOfDone.map((item) => JSON.stringify(item)).join(", ")}]`] diff --git a/src/test/config-commands.test.ts b/src/test/config-commands.test.ts index 3c526e1f1..92607af34 100644 --- a/src/test/config-commands.test.ts +++ b/src/test/config-commands.test.ts @@ -241,4 +241,54 @@ describe("Config commands", () => { expect(config?.projectName).toBe(originalProjectName ?? ""); expect(config?.statuses).toEqual(originalStatuses); }); + + it("config set terminalStatuses saves comma-separated list and config get reads it back", async () => { + await $`bun ${CLI_PATH} config set terminalStatuses "Abgeschlossen,Abgebrochen"`.cwd(TEST_DIR).quiet(); + + const output = await $`bun ${CLI_PATH} config get terminalStatuses`.cwd(TEST_DIR).text(); + expect(output.trim()).toBe("Abgeschlossen, Abgebrochen"); + + // Fresh core avoids cached config from before CLI set + const freshCore = new Core(TEST_DIR); + const config = await freshCore.filesystem.loadConfig(); + expect(config?.terminalStatuses).toEqual(["Abgeschlossen", "Abgebrochen"]); + }); + + it("config get terminalStatuses returns empty string when key is not set", async () => { + const output = await $`bun ${CLI_PATH} config get terminalStatuses`.cwd(TEST_DIR).text(); + expect(output.trim()).toBe(""); + + const config = await core.filesystem.loadConfig(); + expect(config?.terminalStatuses).toBeUndefined(); + }); + + it("saveConfig with terminalStatuses round-trips correctly and empty array omits the key", async () => { + const config = await core.filesystem.loadConfig(); + + // Set multiple terminal statuses + if (config) { + config.terminalStatuses = ["Done", "Closed", "Cancelled"]; + await core.filesystem.saveConfig(config); + } + + const reloaded = await core.filesystem.loadConfig(); + expect(reloaded?.terminalStatuses).toEqual(["Done", "Closed", "Cancelled"]); + + // Clear to empty array — must be treated as undefined (no key in YAML) + if (reloaded) { + reloaded.terminalStatuses = []; + await core.filesystem.saveConfig(reloaded); + } + + const cleared = await core.filesystem.loadConfig(); + expect(cleared?.terminalStatuses).toBeUndefined(); + }); + + it("config get returns error for unknown key", async () => { + const result = await $`bun ${CLI_PATH} config get unknownKey`.cwd(TEST_DIR).nothrow(); + expect(result.exitCode).not.toBe(0); + const stderr = result.stderr.toString(); + expect(stderr).toContain("Unknown config key"); + expect(stderr).toContain("terminalStatuses"); + }); }); From ea442bc431947fb9ab3d2cb2ecf808cddf1eb98b Mon Sep 17 00:00:00 2001 From: Lenucksi Date: Thu, 7 May 2026 12:57:12 +0200 Subject: [PATCH 4/4] BACK-470 - Add missing tests for BACK-467 active-filter isTerminalStatus Added 4 tests to src/web/lib/lanes.test.ts covering the sortTasksForStatus refactoring from BACK-467: custom terminal status gets done-sort even when not last in statuses array, empty terminalStatuses falls back to last-element convention, and active statuses are unaffected when terminalStatuses is set. Co-Authored-By: Claude Sonnet 4.6 --- src/web/lib/lanes.test.ts | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/web/lib/lanes.test.ts b/src/web/lib/lanes.test.ts index 9286c2043..024cc6925 100644 --- a/src/web/lib/lanes.test.ts +++ b/src/web/lib/lanes.test.ts @@ -177,4 +177,36 @@ describe("sortTasksForStatus", () => { const sorted = sortTasksForStatus(tasks, "Done", ["To Do", "In Progress", "Done"]).map((t) => t.id); expect(sorted).toEqual(["task-2", "task-3", "task-1"]); }); + + it("treats custom terminal status as done-sorted even when not the last configured status", () => { + const tasks = [ + makeTask({ id: "task-1", status: "Fertig", updatedDate: "2024-01-01", createdDate: "2024-01-01" }), + makeTask({ id: "task-2", status: "Fertig", updatedDate: "2024-01-03", createdDate: "2024-01-01" }), + ]; + // "Fertig" is NOT the last status — without terminalStatuses it would be treated as active + const statuses = ["Offen", "Fertig", "Archiviert"]; + const sorted = sortTasksForStatus(tasks, "Fertig", statuses, ["Fertig"]).map((t) => t.id); + // Done behavior: sort by updatedDate descending → task-2 first + expect(sorted).toEqual(["task-2", "task-1"]); + }); + + it("falls back to last-element convention when terminalStatuses is empty", () => { + const tasks = [ + makeTask({ id: "task-1", status: "Done", updatedDate: "2024-01-01", createdDate: "2024-01-01" }), + makeTask({ id: "task-2", status: "Done", updatedDate: "2024-01-03", createdDate: "2024-01-01" }), + ]; + // Empty array → fallback: "Done" is last in statuses → terminal → date-sorted descending + const sorted = sortTasksForStatus(tasks, "Done", ["To Do", "Done"], []).map((t) => t.id); + expect(sorted).toEqual(["task-2", "task-1"]); + }); + + it("does not apply done-sorting to active statuses when terminalStatuses is configured", () => { + const tasks = [ + makeTask({ id: "task-1", status: "Offen", createdDate: "2024-01-03" }), + makeTask({ id: "task-2", status: "Offen", createdDate: "2024-01-01" }), + ]; + // "Fertig" is terminal, "Offen" is not → active sort: createdDate ascending + const sorted = sortTasksForStatus(tasks, "Offen", ["Offen", "Fertig"], ["Fertig"]).map((t) => t.id); + expect(sorted).toEqual(["task-2", "task-1"]); + }); });