feat: add tags workflow

This commit is contained in:
2026-07-16 03:34:23 +05:00
parent 7c0584707a
commit 6eedfb2bc5
29 changed files with 1153 additions and 22 deletions
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { AppErrorVariant } from "@/error";
import { parseProjectConfig } from "./parseProjectConfig";
describe("parseProjectConfig", () => {
it("parses tags list", () => {
const result = parseProjectConfig("tags:\n - bug\n - frontend\n");
expect(result.isOk()).toBe(true);
if (result.isErr()) return;
expect(result.value.tags).toEqual(["bug", "frontend"]);
});
it("accepts empty / missing tags", () => {
expect(parseProjectConfig("")._unsafeUnwrap().tags).toEqual([]);
expect(parseProjectConfig("tags: []\n")._unsafeUnwrap().tags).toEqual(
[],
);
});
it("dedupes and trims tags", () => {
const result = parseProjectConfig(
"tags:\n - bug\n - bug \n - frontend\n",
);
expect(result._unsafeUnwrap().tags).toEqual(["bug", "frontend"]);
});
it("rejects invalid YAML", () => {
const result = parseProjectConfig("tags: [\n");
expect(result.isErr()).toBe(true);
if (result.isOk()) return;
expect(result.error.variant).toBe(AppErrorVariant.INVALID_PROJECT_CONFIG);
});
it("rejects non-string tags", () => {
const result = parseProjectConfig("tags:\n - 1\n");
expect(result.isErr()).toBe(true);
if (result.isOk()) return;
expect(result.error.variant).toBe(AppErrorVariant.INVALID_PROJECT_CONFIG);
});
});
+73
View File
@@ -0,0 +1,73 @@
import * as yaml from "js-yaml";
import { err, ok, Result } from "neverthrow";
import { appError, AppError, AppErrorVariant } from "@/error";
import {
EMPTY_PROJECT_CONFIG,
normalizeTag,
type ProjectConfig,
} from "@/model/projectConfig";
/**
* Parse task-folder config.yml content.
* Accepts missing/empty tags → [].
*/
export function parseProjectConfig(
content: string,
): Result<ProjectConfig, AppError> {
if (content.trim() === "") {
return ok({ ...EMPTY_PROJECT_CONFIG });
}
let data: unknown;
try {
data = yaml.load(content);
} catch (error) {
const detail = error instanceof Error ? error.message : "invalid YAML";
return err(
appError(AppErrorVariant.INVALID_PROJECT_CONFIG, { detail }),
);
}
if (data === null || data === undefined) {
return ok({ ...EMPTY_PROJECT_CONFIG });
}
if (typeof data !== "object" || Array.isArray(data)) {
return err(
appError(AppErrorVariant.INVALID_PROJECT_CONFIG, {
detail: "root must be a mapping",
}),
);
}
const record = data as Record<string, unknown>;
if (record.tags === undefined) {
return ok({ tags: [] });
}
if (!Array.isArray(record.tags)) {
return err(
appError(AppErrorVariant.INVALID_PROJECT_CONFIG, {
detail: "tags must be an array of strings",
}),
);
}
const tags: string[] = [];
const seen = new Set<string>();
for (const item of record.tags) {
if (typeof item !== "string") {
return err(
appError(AppErrorVariant.INVALID_PROJECT_CONFIG, {
detail: "tags must be an array of strings",
}),
);
}
const tag = normalizeTag(item);
if (!tag || seen.has(tag)) continue;
seen.add(tag);
tags.push(tag);
}
return ok({ tags });
}
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { parseProjectConfig } from "./parseProjectConfig";
import { serializeProjectConfig } from "./serializeProjectConfig";
describe("serializeProjectConfig", () => {
it("round-trips tags", () => {
const yaml = serializeProjectConfig({ tags: ["bug", "docs"] });
expect(yaml).toContain("bug");
expect(yaml).toContain("docs");
const parsed = parseProjectConfig(yaml);
expect(parsed._unsafeUnwrap().tags).toEqual(["bug", "docs"]);
});
it("serializes empty tags", () => {
const yaml = serializeProjectConfig({ tags: [] });
const parsed = parseProjectConfig(yaml);
expect(parsed._unsafeUnwrap().tags).toEqual([]);
});
});
@@ -0,0 +1,15 @@
import * as yaml from "js-yaml";
import type { ProjectConfig } from "@/model/projectConfig";
export function serializeProjectConfig(config: ProjectConfig): string {
return yaml
.dump(
{ tags: config.tags },
{
lineWidth: -1,
forceQuotes: false,
},
)
.trimEnd()
.concat("\n");
}