feat(FN-3869): add github tracking fields and resolver to tasks
Adds GitHub tracking fields to the task model with schema migration, store accessors, and type definitions in `@fusion/core`. New `github-tracking.ts` module exposes a resolver for fetching GitHub issue/PR metadata, integrated into the task store. Test coverage spans the new module, store integratio Fusion-Task-Id: FN-3869
This commit is contained in:
@@ -646,3 +646,46 @@ describe("migrateFromLegacy", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("schema migration", () => {
|
||||
let tmpDir: string;
|
||||
let fusionDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
tmpDir = makeTmpDir();
|
||||
fusionDir = join(tmpDir, ".fusion");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("adds tasks.githubTracking when migrating from schema version 70", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.exec("CREATE TABLE IF NOT EXISTS __meta (key TEXT PRIMARY KEY, value TEXT)");
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS tasks (
|
||||
id TEXT PRIMARY KEY,
|
||||
description TEXT NOT NULL,
|
||||
"column" TEXT NOT NULL,
|
||||
createdAt TEXT NOT NULL,
|
||||
updatedAt TEXT NOT NULL,
|
||||
issueInfo TEXT
|
||||
)
|
||||
`);
|
||||
db.exec("INSERT INTO __meta (key, value) VALUES ('schemaVersion', '70')");
|
||||
db.exec("INSERT INTO __meta (key, value) VALUES ('lastModified', '1000')");
|
||||
db.exec(`INSERT INTO tasks (id, description, "column", createdAt, updatedAt, issueInfo) VALUES ('FN-legacy', 'legacy', 'todo', '2025-01-01T00:00:00.000Z', '2025-01-01T00:00:00.000Z', '{\"number\":1}')`);
|
||||
|
||||
db.init();
|
||||
|
||||
const columns = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(columns.map((column) => column.name)).toContain("githubTracking");
|
||||
|
||||
const row = db.prepare("SELECT id, issueInfo FROM tasks WHERE id = 'FN-legacy'").get() as { id: string; issueInfo: string };
|
||||
expect(row.id).toBe("FN-legacy");
|
||||
expect(JSON.parse(row.issueInfo).number).toBe(1);
|
||||
|
||||
db.close();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -175,7 +175,7 @@ describe("Database", () => {
|
||||
});
|
||||
|
||||
it("seeds schema version", () => {
|
||||
expect(db.getSchemaVersion()).toBe(70);
|
||||
expect(db.getSchemaVersion()).toBe(71);
|
||||
});
|
||||
it("seeds lastModified", () => {
|
||||
const ts = db.getLastModified();
|
||||
@@ -197,7 +197,7 @@ describe("Database", () => {
|
||||
|
||||
it("is idempotent - calling init() twice does not fail", () => {
|
||||
expect(() => db.init()).not.toThrow();
|
||||
expect(db.getSchemaVersion()).toBe(70);
|
||||
expect(db.getSchemaVersion()).toBe(71);
|
||||
});
|
||||
it("does not overwrite existing config on re-init", () => {
|
||||
// Update the config
|
||||
@@ -996,7 +996,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
||||
expect(db.getSchemaVersion()).toBe(70);
|
||||
expect(db.getSchemaVersion()).toBe(71);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -1021,11 +1021,11 @@ describe("schema migrations", () => {
|
||||
const db = new Database(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(70);
|
||||
expect(db.getSchemaVersion()).toBe(71);
|
||||
|
||||
// Re-init should not fail
|
||||
db.init();
|
||||
expect(db.getSchemaVersion()).toBe(70);
|
||||
expect(db.getSchemaVersion()).toBe(71);
|
||||
|
||||
db.close();
|
||||
});
|
||||
@@ -1060,7 +1060,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(70);
|
||||
expect(db.getSchemaVersion()).toBe(71);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("priority");
|
||||
@@ -1101,7 +1101,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(70);
|
||||
expect(db.getSchemaVersion()).toBe(71);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1170,7 +1170,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(70);
|
||||
expect(db.getSchemaVersion()).toBe(71);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
const colNames = cols.map((col) => col.name);
|
||||
@@ -1410,7 +1410,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(70);
|
||||
expect(db.getSchemaVersion()).toBe(71);
|
||||
|
||||
const cols = db.prepare("PRAGMA table_info(chat_messages)").all() as Array<{ name: string }>;
|
||||
expect(cols.map((col) => col.name)).toContain("attachments");
|
||||
@@ -1484,7 +1484,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(70);
|
||||
expect(db.getSchemaVersion()).toBe(71);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||
@@ -1508,7 +1508,7 @@ describe("schema migrations", () => {
|
||||
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(70);
|
||||
expect(db.getSchemaVersion()).toBe(71);
|
||||
|
||||
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
|
||||
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||
@@ -1612,7 +1612,7 @@ describe("schema migrations", () => {
|
||||
db.init();
|
||||
|
||||
// Verify version bumped to 29
|
||||
expect(db.getSchemaVersion()).toBe(70);
|
||||
expect(db.getSchemaVersion()).toBe(71);
|
||||
|
||||
// Verify new columns exist and existing data is intact
|
||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||
@@ -2081,7 +2081,7 @@ describe("createDatabase factory", () => {
|
||||
const db = createDatabase(fusionDir);
|
||||
db.init();
|
||||
|
||||
expect(db.getSchemaVersion()).toBe(70);
|
||||
expect(db.getSchemaVersion()).toBe(71);
|
||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||
|
||||
db.close();
|
||||
@@ -2214,7 +2214,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
||||
|
||||
const migrated = new Database(fusion);
|
||||
migrated.init();
|
||||
expect(migrated.getSchemaVersion()).toBe(70);
|
||||
expect(migrated.getSchemaVersion()).toBe(71);
|
||||
const tables = migrated
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
@@ -2228,7 +2228,7 @@ describe("migration v67 drops orphan project auth tables", () => {
|
||||
const fusion = join(temp, ".fusion");
|
||||
const fresh = new Database(fusion);
|
||||
fresh.init();
|
||||
expect(fresh.getSchemaVersion()).toBe(70);
|
||||
expect(fresh.getSchemaVersion()).toBe(71);
|
||||
const tables = fresh
|
||||
.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'project_auth_%'")
|
||||
.all() as Array<{ name: string }>;
|
||||
|
||||
94
packages/core/src/__tests__/github-tracking.test.ts
Normal file
94
packages/core/src/__tests__/github-tracking.test.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { isValidRepoSlug, parseRepoSlug, resolveTaskGithubTracking } from "../github-tracking.js";
|
||||
|
||||
describe("parseRepoSlug", () => {
|
||||
it("parses valid owner/repo slugs", () => {
|
||||
expect(parseRepoSlug("octocat/hello-world")).toEqual({ owner: "octocat", repo: "hello-world" });
|
||||
});
|
||||
|
||||
it("accepts leading and trailing whitespace", () => {
|
||||
expect(parseRepoSlug(" octocat/hello-world ")).toEqual({ owner: "octocat", repo: "hello-world" });
|
||||
});
|
||||
|
||||
it("rejects malformed values", () => {
|
||||
expect(parseRepoSlug("octocat")).toBeNull();
|
||||
expect(parseRepoSlug("octocat//hello-world")).toBeNull();
|
||||
expect(parseRepoSlug("/hello-world")).toBeNull();
|
||||
expect(parseRepoSlug("octocat/")).toBeNull();
|
||||
expect(parseRepoSlug(null)).toBeNull();
|
||||
expect(parseRepoSlug(undefined)).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects values with spaces in segments", () => {
|
||||
expect(parseRepoSlug("octo cat/hello-world")).toBeNull();
|
||||
expect(parseRepoSlug("octocat/hello world")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("isValidRepoSlug", () => {
|
||||
it("returns true only for valid repo slugs", () => {
|
||||
expect(isValidRepoSlug("octocat/hello-world")).toBe(true);
|
||||
expect(isValidRepoSlug("octocat")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveTaskGithubTracking", () => {
|
||||
it.each([
|
||||
[{ enabled: true }, { githubTrackingEnabledByDefault: false }, true, "task"],
|
||||
[{ enabled: false }, { githubTrackingEnabledByDefault: true }, false, "task"],
|
||||
[{}, { githubTrackingEnabledByDefault: true }, true, "project"],
|
||||
[{}, {}, false, "default"],
|
||||
] as const)(
|
||||
"resolves enabled precedence",
|
||||
(taskTracking, projectSettings, expectedEnabled, expectedSource) => {
|
||||
const resolved = resolveTaskGithubTracking(
|
||||
{ githubTracking: taskTracking },
|
||||
projectSettings,
|
||||
undefined,
|
||||
);
|
||||
expect(resolved.enabled).toBe(expectedEnabled);
|
||||
expect(resolved.source.enabled).toBe(expectedSource);
|
||||
},
|
||||
);
|
||||
|
||||
it("falls back to global enabled when present", () => {
|
||||
const resolved = resolveTaskGithubTracking(
|
||||
{ githubTracking: {} },
|
||||
{},
|
||||
{ githubTrackingDefaultEnabledForNewTasks: true },
|
||||
);
|
||||
expect(resolved.enabled).toBe(true);
|
||||
expect(resolved.source.enabled).toBe("global");
|
||||
});
|
||||
|
||||
it("resolves repo from task override first", () => {
|
||||
const resolved = resolveTaskGithubTracking(
|
||||
{ githubTracking: { repoOverride: "task/override" } },
|
||||
{ githubTrackingDefaultRepo: "project/default" },
|
||||
{ githubTrackingDefaultRepo: "global/default" },
|
||||
);
|
||||
expect(resolved.repo).toEqual({ owner: "task", repo: "override" });
|
||||
expect(resolved.source.repo).toBe("task");
|
||||
});
|
||||
|
||||
it("falls through invalid repo tiers", () => {
|
||||
const resolved = resolveTaskGithubTracking(
|
||||
{ githubTracking: { repoOverride: "invalid" } },
|
||||
{ githubTrackingDefaultRepo: "still invalid" },
|
||||
{ githubDefaultRepo: "global/default" },
|
||||
);
|
||||
expect(resolved.repo).toEqual({ owner: "global", repo: "default" });
|
||||
expect(resolved.source.repo).toBe("global");
|
||||
});
|
||||
|
||||
it("returns none source when no valid repo exists", () => {
|
||||
const resolved = resolveTaskGithubTracking(
|
||||
{ githubTracking: { repoOverride: "invalid" } },
|
||||
{ githubTrackingDefaultRepo: "also invalid" },
|
||||
{ githubTrackingDefaultRepo: "" },
|
||||
);
|
||||
expect(resolved.repo).toBeNull();
|
||||
expect(resolved.source.repo).toBe("none");
|
||||
});
|
||||
});
|
||||
@@ -886,7 +886,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
|
||||
const db1 = createDatabase(legacyDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(70);
|
||||
expect(db1.getSchemaVersion()).toBe(71);
|
||||
db1.close();
|
||||
|
||||
// Step 2: Manually downgrade to version 32 and drop insight tables
|
||||
@@ -921,7 +921,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
expect(tableNamesBefore).not.toContain("project_insight_runs");
|
||||
// Now run init — this triggers the v32→v33 migration
|
||||
db3.init();
|
||||
expect(db3.getSchemaVersion()).toBe(70);
|
||||
expect(db3.getSchemaVersion()).toBe(71);
|
||||
|
||||
// Step 4: Verify insight tables exist after migration
|
||||
const tablesAfter = db3.prepare(
|
||||
@@ -952,12 +952,12 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
try {
|
||||
const db1 = createDatabase(testDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(70);
|
||||
expect(db1.getSchemaVersion()).toBe(71);
|
||||
db1.close();
|
||||
|
||||
const db2 = createDatabase(testDir);
|
||||
expect(() => db2.init()).not.toThrow();
|
||||
expect(db2.getSchemaVersion()).toBe(70);
|
||||
expect(db2.getSchemaVersion()).toBe(71);
|
||||
db2.close();
|
||||
} finally {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
@@ -971,7 +971,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
||||
// Step 1: Create a fresh DB and run migrations
|
||||
const db1 = createDatabase(compatDir);
|
||||
db1.init();
|
||||
expect(db1.getSchemaVersion()).toBe(70);
|
||||
expect(db1.getSchemaVersion()).toBe(71);
|
||||
|
||||
// Step 2: Strip lifecycle and cancelledAt columns by recreating the
|
||||
// table without them. This simulates a DB that was created before the
|
||||
|
||||
@@ -2629,7 +2629,7 @@ describe("MissionStore", () => {
|
||||
|
||||
describe("Loop State & Validator Run Schema (v31)", () => {
|
||||
it("schema version is 40 after migration", () => {
|
||||
expect(db.getSchemaVersion()).toBe(70);
|
||||
expect(db.getSchemaVersion()).toBe(71);
|
||||
});
|
||||
|
||||
it("mission_features table has loop state columns", () => {
|
||||
|
||||
@@ -465,7 +465,7 @@ describe("Run Audit", () => {
|
||||
});
|
||||
|
||||
it("schema version is bumped to 40", () => {
|
||||
expect(db.getSchemaVersion()).toBe(70);
|
||||
expect(db.getSchemaVersion()).toBe(71);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
124
packages/core/src/__tests__/store-github-tracking.test.ts
Normal file
124
packages/core/src/__tests__/store-github-tracking.test.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { mkdtempSync } from "node:fs";
|
||||
import { rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
|
||||
import type { TaskGithubTrackedIssue } from "../types.js";
|
||||
import { TaskStore } from "../store.js";
|
||||
|
||||
function makeTmpDir(): string {
|
||||
return mkdtempSync(join(tmpdir(), "kb-store-github-tracking-test-"));
|
||||
}
|
||||
|
||||
describe("TaskStore github tracking", () => {
|
||||
let rootDir: string;
|
||||
let globalDir: string;
|
||||
let store: TaskStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
rootDir = makeTmpDir();
|
||||
globalDir = makeTmpDir();
|
||||
store = new TaskStore(rootDir, globalDir, { inMemoryDb: true });
|
||||
await store.init();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
store.close();
|
||||
await rm(rootDir, { recursive: true, force: true });
|
||||
await rm(globalDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
const issue: TaskGithubTrackedIssue = {
|
||||
owner: "octocat",
|
||||
repo: "hello-world",
|
||||
number: 42,
|
||||
url: "https://github.com/octocat/hello-world/issues/42",
|
||||
createdAt: "2026-05-09T00:00:00.000Z",
|
||||
};
|
||||
|
||||
it("round-trips githubTracking through updateGithubTracking", async () => {
|
||||
const task = await store.createTask({ description: "Track issue" });
|
||||
|
||||
await store.updateGithubTracking(task.id, {
|
||||
enabled: true,
|
||||
repoOverride: "octocat/hello-world",
|
||||
});
|
||||
|
||||
const updated = await store.getTask(task.id);
|
||||
expect(updated?.githubTracking).toEqual({
|
||||
enabled: true,
|
||||
repoOverride: "octocat/hello-world",
|
||||
});
|
||||
});
|
||||
|
||||
it("links and unlinks tracked issue while preserving other tracking fields", async () => {
|
||||
const task = await store.createTask({ description: "Link issue" });
|
||||
|
||||
await store.linkGithubIssue(task.id, issue);
|
||||
let updated = await store.getTask(task.id);
|
||||
expect(updated?.githubTracking?.enabled).toBe(true);
|
||||
expect(updated?.githubTracking?.issue).toEqual(issue);
|
||||
|
||||
await store.updateGithubTracking(task.id, {
|
||||
enabled: false,
|
||||
repoOverride: "octocat/hello-world",
|
||||
issue,
|
||||
});
|
||||
await store.linkGithubIssue(task.id, issue);
|
||||
|
||||
updated = await store.getTask(task.id);
|
||||
expect(updated?.githubTracking?.enabled).toBe(false);
|
||||
|
||||
await store.unlinkGithubIssue(task.id);
|
||||
updated = await store.getTask(task.id);
|
||||
|
||||
expect(updated?.githubTracking?.issue).toBeUndefined();
|
||||
expect(updated?.githubTracking?.unlinkedAt).toBeTruthy();
|
||||
expect(updated?.githubTracking?.enabled).toBe(false);
|
||||
expect(updated?.githubTracking?.repoOverride).toBe("octocat/hello-world");
|
||||
});
|
||||
|
||||
it("does not emit task:updated for idempotent updateGithubTracking writes", async () => {
|
||||
const task = await store.createTask({ description: "No-op" });
|
||||
const updatedEvents: string[] = [];
|
||||
store.on("task:updated", (t) => updatedEvents.push(t.id));
|
||||
|
||||
const tracking = { enabled: true, repoOverride: "octocat/hello-world" };
|
||||
await store.updateGithubTracking(task.id, tracking);
|
||||
await store.updateGithubTracking(task.id, tracking);
|
||||
|
||||
expect(updatedEvents).toEqual([task.id]);
|
||||
});
|
||||
|
||||
it("omits githubTracking in slim list paths", async () => {
|
||||
const task = await store.createTask({ description: "Slim list" });
|
||||
await store.updateGithubTracking(task.id, { enabled: true, repoOverride: "octocat/hello-world" });
|
||||
|
||||
const tasks = await store.listTasks({ slim: true });
|
||||
const listed = tasks.find((entry) => entry.id === task.id);
|
||||
|
||||
expect(listed?.githubTracking).toBeUndefined();
|
||||
});
|
||||
|
||||
it("preserves githubTracking through archive and restore", async () => {
|
||||
const task = await store.createTask({ description: "Archive tracking" });
|
||||
await store.updateGithubTracking(task.id, {
|
||||
enabled: true,
|
||||
repoOverride: "octocat/hello-world",
|
||||
issue,
|
||||
});
|
||||
|
||||
await store.moveTask(task.id, "todo");
|
||||
await store.moveTask(task.id, "in-progress");
|
||||
await store.moveTask(task.id, "done");
|
||||
await store.archiveTask(task.id, false);
|
||||
const restored = await store.unarchiveTask(task.id);
|
||||
|
||||
expect(restored.githubTracking).toEqual({
|
||||
enabled: true,
|
||||
repoOverride: "octocat/hello-world",
|
||||
issue,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
|
||||
|
||||
expect(tableNames.has("task_documents")).toBe(true);
|
||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||
expect(db.getSchemaVersion()).toBe(70);
|
||||
expect(db.getSchemaVersion()).toBe(71);
|
||||
|
||||
const index = db
|
||||
.prepare(
|
||||
|
||||
@@ -88,7 +88,7 @@ export function probeFts5(db: DatabaseSync): boolean {
|
||||
|
||||
// ── Schema Definition ────────────────────────────────────────────────
|
||||
|
||||
const SCHEMA_VERSION = 70;
|
||||
const SCHEMA_VERSION = 71;
|
||||
|
||||
function normalizeTaskComments(
|
||||
steeringComments: SteeringComment[] | undefined,
|
||||
@@ -206,6 +206,7 @@ CREATE TABLE IF NOT EXISTS tasks (
|
||||
workflowStepResults TEXT DEFAULT '[]',
|
||||
prInfo TEXT,
|
||||
issueInfo TEXT,
|
||||
githubTracking TEXT,
|
||||
sourceIssueProvider TEXT,
|
||||
sourceIssueRepository TEXT,
|
||||
sourceIssueExternalIssueId TEXT,
|
||||
@@ -2960,6 +2961,12 @@ export class Database {
|
||||
});
|
||||
}
|
||||
|
||||
if (version < 71) {
|
||||
this.applyMigration(71, () => {
|
||||
this.addColumnIfMissing("tasks", "githubTracking", "TEXT");
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
99
packages/core/src/github-tracking.ts
Normal file
99
packages/core/src/github-tracking.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { GlobalSettings, ProjectSettings, Task } from "./types.js";
|
||||
|
||||
export interface RepoSlug {
|
||||
owner: string;
|
||||
repo: string;
|
||||
}
|
||||
|
||||
export interface ResolvedTaskGithubTracking {
|
||||
enabled: boolean;
|
||||
repo: RepoSlug | null;
|
||||
source: {
|
||||
enabled: "task" | "project" | "global" | "default";
|
||||
repo: "task" | "project" | "global" | "none";
|
||||
};
|
||||
}
|
||||
|
||||
function parseRepoSlugCandidate(input: unknown): RepoSlug | null {
|
||||
if (typeof input !== "string") return null;
|
||||
const trimmed = input.trim();
|
||||
if (!trimmed) return null;
|
||||
|
||||
const parts = trimmed.split("/");
|
||||
if (parts.length !== 2) return null;
|
||||
const [owner, repo] = parts;
|
||||
if (!owner || !repo) return null;
|
||||
if (/\s/.test(owner) || /\s/.test(repo)) return null;
|
||||
|
||||
return { owner, repo };
|
||||
}
|
||||
|
||||
export function parseRepoSlug(input: string | undefined | null): RepoSlug | null {
|
||||
return parseRepoSlugCandidate(input);
|
||||
}
|
||||
|
||||
export function isValidRepoSlug(input: string): boolean {
|
||||
return parseRepoSlug(input) !== null;
|
||||
}
|
||||
|
||||
export function resolveTaskGithubTracking(
|
||||
task: Pick<Task, "githubTracking">,
|
||||
projectSettings?: Pick<ProjectSettings, "githubTrackingEnabledByDefault" | "githubTrackingDefaultRepo"> & {
|
||||
githubTrackingDefaultEnabledForNewTasks?: boolean;
|
||||
githubDefaultRepo?: string;
|
||||
},
|
||||
globalSettings?: Pick<GlobalSettings, "githubTrackingDefaultRepo"> & {
|
||||
githubTrackingDefaultEnabledForNewTasks?: boolean;
|
||||
githubDefaultRepo?: string;
|
||||
},
|
||||
): ResolvedTaskGithubTracking {
|
||||
const taskEnabled = task.githubTracking?.enabled;
|
||||
const projectEnabled = projectSettings?.githubTrackingEnabledByDefault
|
||||
?? projectSettings?.githubTrackingDefaultEnabledForNewTasks;
|
||||
// TODO(FN-3868): remove legacy fallback keys once all callers are migrated.
|
||||
const globalEnabled = globalSettings?.githubTrackingDefaultEnabledForNewTasks;
|
||||
|
||||
let enabled = false;
|
||||
let enabledSource: ResolvedTaskGithubTracking["source"]["enabled"] = "default";
|
||||
|
||||
if (typeof taskEnabled === "boolean") {
|
||||
enabled = taskEnabled;
|
||||
enabledSource = "task";
|
||||
} else if (typeof projectEnabled === "boolean") {
|
||||
enabled = projectEnabled;
|
||||
enabledSource = "project";
|
||||
} else if (typeof globalEnabled === "boolean") {
|
||||
enabled = globalEnabled;
|
||||
enabledSource = "global";
|
||||
} else {
|
||||
enabled = false;
|
||||
enabledSource = "default";
|
||||
}
|
||||
|
||||
const taskRepo = parseRepoSlugCandidate(task.githubTracking?.repoOverride);
|
||||
const projectRepo = parseRepoSlugCandidate(projectSettings?.githubTrackingDefaultRepo ?? projectSettings?.githubDefaultRepo);
|
||||
const globalRepo = parseRepoSlugCandidate(globalSettings?.githubTrackingDefaultRepo ?? globalSettings?.githubDefaultRepo);
|
||||
|
||||
let repo: RepoSlug | null = null;
|
||||
let repoSource: ResolvedTaskGithubTracking["source"]["repo"] = "none";
|
||||
|
||||
if (taskRepo) {
|
||||
repo = taskRepo;
|
||||
repoSource = "task";
|
||||
} else if (projectRepo) {
|
||||
repo = projectRepo;
|
||||
repoSource = "project";
|
||||
} else if (globalRepo) {
|
||||
repo = globalRepo;
|
||||
repoSource = "global";
|
||||
}
|
||||
|
||||
return {
|
||||
enabled,
|
||||
repo,
|
||||
source: {
|
||||
enabled: enabledSource,
|
||||
repo: repoSource,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export { discoverPiExtensions, formatPiExtensionSource, getEnabledPiExtensionPat
|
||||
export type { PiExtensionEntry, PiExtensionSettings, PiExtensionSource } from "./pi-extensions.js";
|
||||
export { canTransition, getValidTransitions, resolveDependencyOrder } from "./board.js";
|
||||
export { getTaskMergeBlocker, getTaskCompletionBlocker, isTaskReadyForMerge } from "./task-merge.js";
|
||||
export {
|
||||
export {
|
||||
isGhAvailable,
|
||||
isGhAuthenticated,
|
||||
runGh,
|
||||
@@ -101,6 +101,12 @@ export {
|
||||
getCurrentRepo,
|
||||
type GhError,
|
||||
} from "./gh-cli.js";
|
||||
export {
|
||||
parseRepoSlug,
|
||||
isValidRepoSlug,
|
||||
resolveTaskGithubTracking,
|
||||
} from "./github-tracking.js";
|
||||
export type { RepoSlug, ResolvedTaskGithubTracking } from "./github-tracking.js";
|
||||
export { AUTOMATION_PRESETS, MAX_RUN_HISTORY } from "./automation.js";
|
||||
export type { ScheduleType, ScheduledTask, ScheduledTaskCreateInput, ScheduledTaskUpdateInput, AutomationRunResult, AutomationStepType, AutomationStep, AutomationStepResult } from "./automation.js";
|
||||
export { AutomationStore } from "./automation-store.js";
|
||||
|
||||
@@ -95,6 +95,7 @@ interface TaskRow {
|
||||
workflowStepResults: string | null;
|
||||
prInfo: string | null;
|
||||
issueInfo: string | null;
|
||||
githubTracking: string | null;
|
||||
sourceIssueProvider: string | null;
|
||||
sourceIssueRepository: string | null;
|
||||
sourceIssueExternalIssueId: string | null;
|
||||
@@ -808,6 +809,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
workflowStepResults: (() => { const w = fromJson<import("./types.js").WorkflowStepResult[]>(row.workflowStepResults); return w && w.length > 0 ? w : undefined; })(),
|
||||
prInfo: fromJson<import("./types.js").PrInfo>(row.prInfo),
|
||||
issueInfo: fromJson<import("./types.js").IssueInfo>(row.issueInfo),
|
||||
githubTracking: fromJson<import("./types.js").TaskGithubTracking>(row.githubTracking) ?? undefined,
|
||||
sourceIssue: (() => {
|
||||
if (
|
||||
row.sourceIssueProvider === null
|
||||
@@ -868,6 +870,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
reviewLevel: entry.reviewLevel,
|
||||
prInfo: slim ? undefined : entry.prInfo,
|
||||
issueInfo: slim ? undefined : entry.issueInfo,
|
||||
githubTracking: slim ? undefined : entry.githubTracking,
|
||||
sourceIssue: slim ? undefined : entry.sourceIssue,
|
||||
attachments: slim ? undefined : entry.attachments,
|
||||
comments: entry.comments,
|
||||
@@ -992,6 +995,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
reviewLevel: task.reviewLevel,
|
||||
prInfo: task.prInfo,
|
||||
issueInfo: task.issueInfo,
|
||||
githubTracking: task.githubTracking,
|
||||
sourceIssue: task.sourceIssue,
|
||||
attachments: task.attachments,
|
||||
comments: task.comments,
|
||||
@@ -1076,7 +1080,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt",
|
||||
"createdAt", "updatedAt", "columnMovedAt", "executionStartedAt", "executionCompletedAt",
|
||||
"dependencies", "steps", "comments", "review", "reviewState", "workflowStepResults", "steeringComments",
|
||||
"attachments", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
|
||||
"attachments", "prInfo", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
|
||||
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
|
||||
"missionId", "sliceId", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
||||
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
|
||||
@@ -1125,7 +1129,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
"tokenUsageInputTokens", "tokenUsageOutputTokens", "tokenUsageCachedTokens", "tokenUsageTotalTokens", "tokenUsageFirstUsedAt", "tokenUsageLastUsedAt",
|
||||
"createdAt", "updatedAt", "columnMovedAt", "executionStartedAt", "executionCompletedAt",
|
||||
"dependencies", "steps", "attachments", "steeringComments",
|
||||
"comments", "review", "reviewState", "workflowStepResults", "prInfo", "issueInfo", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
|
||||
"comments", "review", "reviewState", "workflowStepResults", "prInfo", "issueInfo", "githubTracking", "sourceIssueProvider", "sourceIssueRepository", "sourceIssueExternalIssueId", "sourceIssueNumber", "sourceIssueUrl", "mergeDetails",
|
||||
"breakIntoSubtasks", "enabledWorkflowSteps", "modifiedFiles",
|
||||
"missionId", "sliceId", "assignedAgentId", "pausedByAgentId", "assigneeUserId", "nodeId", "effectiveNodeId", "effectiveNodeSource",
|
||||
"sourceType", "sourceAgentId", "sourceRunId", "sourceSessionId", "sourceMessageId", "sourceParentTaskId", "sourceMetadata",
|
||||
@@ -1168,11 +1172,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
tokenUsageTotalTokens, tokenUsageFirstUsedAt, tokenUsageLastUsedAt, createdAt, updatedAt, columnMovedAt,
|
||||
executionStartedAt, executionCompletedAt,
|
||||
dependencies, steps, log, attachments, steeringComments,
|
||||
comments, review, reviewState, workflowStepResults, prInfo, issueInfo,
|
||||
comments, review, reviewState, workflowStepResults, prInfo, issueInfo, githubTracking,
|
||||
sourceIssueProvider, sourceIssueRepository, sourceIssueExternalIssueId, sourceIssueNumber, sourceIssueUrl,
|
||||
mergeDetails, breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles, missionId, sliceId, assignedAgentId, pausedByAgentId, assigneeUserId, nodeId, effectiveNodeId, effectiveNodeSource, sourceType, sourceAgentId, sourceRunId, sourceSessionId, sourceMessageId, sourceParentTaskId, sourceMetadata, checkedOutBy, checkedOutAt, checkoutNodeId, checkoutRunId, checkoutLeaseRenewedAt, checkoutLeaseEpoch
|
||||
) VALUES (
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?
|
||||
)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
@@ -1232,6 +1236,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
workflowStepResults = excluded.workflowStepResults,
|
||||
prInfo = excluded.prInfo,
|
||||
issueInfo = excluded.issueInfo,
|
||||
githubTracking = excluded.githubTracking,
|
||||
sourceIssueProvider = excluded.sourceIssueProvider,
|
||||
sourceIssueRepository = excluded.sourceIssueRepository,
|
||||
sourceIssueExternalIssueId = excluded.sourceIssueExternalIssueId,
|
||||
@@ -1321,6 +1326,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
toJson(task.workflowStepResults || []),
|
||||
toJsonNullable(task.prInfo),
|
||||
toJsonNullable(task.issueInfo),
|
||||
toJsonNullable(task.githubTracking),
|
||||
task.sourceIssue?.provider ?? null,
|
||||
task.sourceIssue?.repository ?? null,
|
||||
task.sourceIssue?.externalIssueId ?? null,
|
||||
@@ -2754,6 +2760,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
if (slim) {
|
||||
task.timedExecutionMs = this.computeTimedExecutionMs(task.log);
|
||||
task.log = [];
|
||||
task.githubTracking = undefined;
|
||||
}
|
||||
|
||||
if (!slim || task.steps.length > 0) {
|
||||
@@ -2826,6 +2833,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
const task = this.rowToTask(row);
|
||||
task.timedExecutionMs = this.computeTimedExecutionMs(task.log);
|
||||
task.log = [];
|
||||
task.githubTracking = undefined;
|
||||
return task;
|
||||
});
|
||||
|
||||
@@ -2937,6 +2945,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
if (slim) {
|
||||
task.timedExecutionMs = this.computeTimedExecutionMs(task.log);
|
||||
task.log = [];
|
||||
task.githubTracking = undefined;
|
||||
}
|
||||
|
||||
if (task.steps.length > 0) {
|
||||
@@ -5970,6 +5979,98 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
});
|
||||
}
|
||||
|
||||
async updateGithubTracking(
|
||||
id: string,
|
||||
tracking: import("./types.js").TaskGithubTracking | null,
|
||||
): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
const task = await this.readTaskJson(dir);
|
||||
const nextTracking = tracking ?? undefined;
|
||||
const previousTracking = task.githubTracking;
|
||||
|
||||
if (JSON.stringify(previousTracking ?? null) === JSON.stringify(nextTracking ?? null)) {
|
||||
return task;
|
||||
}
|
||||
|
||||
task.githubTracking = nextTracking;
|
||||
task.log.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
action: tracking?.enabled === false ? "GitHub tracking disabled" : "GitHub tracking enabled",
|
||||
});
|
||||
task.updatedAt = new Date().toISOString();
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||
this.emit("task:updated", task);
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
async linkGithubIssue(
|
||||
id: string,
|
||||
issue: import("./types.js").TaskGithubTrackedIssue,
|
||||
): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
const task = await this.readTaskJson(dir);
|
||||
const previous = task.githubTracking ?? {};
|
||||
|
||||
const nextTracking: import("./types.js").TaskGithubTracking = {
|
||||
...previous,
|
||||
issue,
|
||||
enabled: previous.enabled ?? true,
|
||||
};
|
||||
|
||||
if (JSON.stringify(previous) === JSON.stringify(nextTracking)) {
|
||||
return task;
|
||||
}
|
||||
|
||||
task.githubTracking = nextTracking;
|
||||
task.log.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
action: "GitHub issue linked",
|
||||
outcome: `${issue.owner}/${issue.repo}#${issue.number}`,
|
||||
});
|
||||
task.updatedAt = new Date().toISOString();
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||
this.emit("task:updated", task);
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
async unlinkGithubIssue(id: string): Promise<Task> {
|
||||
return this.withTaskLock(id, async () => {
|
||||
const dir = this.taskDir(id);
|
||||
const task = await this.readTaskJson(dir);
|
||||
const previous = task.githubTracking;
|
||||
const previousIssue = previous?.issue;
|
||||
|
||||
if (!previousIssue || !previous) {
|
||||
return task;
|
||||
}
|
||||
|
||||
task.githubTracking = {
|
||||
...previous,
|
||||
issue: undefined,
|
||||
unlinkedAt: new Date().toISOString(),
|
||||
};
|
||||
task.log.push({
|
||||
timestamp: new Date().toISOString(),
|
||||
action: "GitHub issue unlinked",
|
||||
outcome: `${previousIssue.owner}/${previousIssue.repo}#${previousIssue.number}`,
|
||||
});
|
||||
task.updatedAt = new Date().toISOString();
|
||||
|
||||
await this.atomicWriteTaskJson(dir, task);
|
||||
if (this.isWatching) this.taskCache.set(id, { ...task });
|
||||
this.emit("task:updated", task);
|
||||
return task;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Read historical agent log entries for a task from SQLite.
|
||||
* Returns entries in chronological order (oldest first).
|
||||
@@ -6270,6 +6371,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
prInfo: entry.prInfo,
|
||||
review: entry.review,
|
||||
issueInfo: entry.issueInfo,
|
||||
githubTracking: entry.githubTracking,
|
||||
sourceIssue: entry.sourceIssue,
|
||||
attachments: entry.attachments,
|
||||
log: [...entry.log, { timestamp: new Date().toISOString(), action: "Task restored from archive" }],
|
||||
|
||||
@@ -576,6 +576,27 @@ export interface IssueInfo {
|
||||
lastCheckedAt?: string;
|
||||
}
|
||||
|
||||
export interface TaskGithubTrackedIssue {
|
||||
owner: string;
|
||||
repo: string;
|
||||
number: number;
|
||||
url: string;
|
||||
nodeId?: string;
|
||||
createdAt: string;
|
||||
lastSyncedAt?: string;
|
||||
}
|
||||
|
||||
export interface TaskGithubTracking {
|
||||
/** Per-task enabled override. When undefined, project/global default applies. */
|
||||
enabled?: boolean;
|
||||
/** "owner/repo" override; when undefined, project/global default repo applies. */
|
||||
repoOverride?: string;
|
||||
/** Linked GitHub issue. Set after issue creation succeeds. Cleared via unlinkGithubIssue(). */
|
||||
issue?: TaskGithubTrackedIssue;
|
||||
/** ISO-8601 of the most recent manual unlink, retained for audit. */
|
||||
unlinkedAt?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Durable provenance metadata for tasks imported from external issue trackers.
|
||||
*
|
||||
@@ -1103,6 +1124,11 @@ export interface Task {
|
||||
mergeDetails?: MergeDetails;
|
||||
/** Issue information for tasks imported from GitHub issues */
|
||||
issueInfo?: IssueInfo;
|
||||
/**
|
||||
* Per-task tracking metadata for Fusion-emitted GitHub issues.
|
||||
* Distinct from issueInfo/sourceIssue, which describe imported source issues.
|
||||
*/
|
||||
githubTracking?: TaskGithubTracking;
|
||||
/** Durable source provenance for the originating external issue. */
|
||||
sourceIssue?: TaskSourceIssue;
|
||||
log: TaskLogEntry[];
|
||||
@@ -2556,6 +2582,7 @@ export interface ArchivedTaskEntry {
|
||||
executionMode?: ExecutionMode;
|
||||
prInfo?: PrInfo;
|
||||
issueInfo?: IssueInfo;
|
||||
githubTracking?: TaskGithubTracking;
|
||||
/** Durable source provenance for the originating external issue. */
|
||||
sourceIssue?: TaskSourceIssue;
|
||||
/** Attachment metadata (filenames, mime types, etc.) without file content */
|
||||
|
||||
Reference in New Issue
Block a user