Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -3449,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);
}
Expand Down Expand Up @@ -3612,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":
Expand Down Expand Up @@ -3643,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);
}
Expand Down
10 changes: 7 additions & 3 deletions src/core/backlog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand All @@ -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) {
Expand All @@ -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);
}

Expand Down
14 changes: 14 additions & 0 deletions src/file-system/operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -1367,6 +1368,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;
Expand Down Expand Up @@ -1429,6 +1439,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,
Expand Down Expand Up @@ -1458,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(", ")}]`]
Expand Down
50 changes: 50 additions & 0 deletions src/test/config-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
37 changes: 37 additions & 0 deletions src/test/terminal-status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
1 change: 1 addition & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down
34 changes: 21 additions & 13 deletions src/ui/board.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<string, Task>): Task[] {
function buildColumnTasks(
status: string,
items: Task[],
byId: Map<string, Task>,
statuses: readonly string[],
terminalStatuses?: readonly string[] | null,
): Task[] {
const topLevel: Task[] = [];
const childrenByParent = new Map<string, Task[]>();
const sorted = items.slice().sort((a, b) => {
Expand All @@ -71,7 +73,7 @@ function buildColumnTasks(status: string, items: Task[], byId: Map<string, Task>
return 1;
}

const columnIsDone = isDoneStatus(status);
const columnIsDone = isTerminalStatus(status, statuses, terminalStatuses);
if (columnIsDone) {
return compareTaskIds(b.id, a.id);
}
Expand Down Expand Up @@ -101,13 +103,17 @@ function buildColumnTasks(status: string, items: Task[], byId: Map<string, Task>
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<string, Task>(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 };
});
}
Expand Down Expand Up @@ -202,6 +208,7 @@ export async function renderBoardTui(
}) => void;
milestoneMode?: boolean;
milestoneEntities?: Milestone[];
terminalStatuses?: readonly string[] | null;
},
): Promise<void> {
if (!process.stdout.isTTY) {
Expand All @@ -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;
Expand All @@ -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";
Expand Down Expand Up @@ -574,19 +582,19 @@ 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
const tasksWithoutMoving = allTasks.filter((t) => t.id !== operation.taskId);
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);
Expand Down
6 changes: 5 additions & 1 deletion src/ui/sequences.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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();
Expand Down
19 changes: 17 additions & 2 deletions src/utils/terminal-status.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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)
Expand Down
1 change: 1 addition & 0 deletions src/web/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -497,6 +497,7 @@ function App() {
tasks={tasks}
onRefreshData={refreshData}
statuses={statuses}
terminalStatuses={config?.terminalStatuses}
milestones={milestones}
availableLabels={availableLabels}
milestoneEntities={milestoneEntities}
Expand Down
Loading