Files
fusion/packages/dashboard/src/github-tracking.ts
gsxdsm 987878bd17 fix: close Fusion tracking issues created after a task is already done
Late-created GitHub tracking issues never got a task:moved close, and the
reconcile sweep only scanned the oldest 200 terminal rows.
2026-08-15 15:33:40 -07:00

643 lines
26 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
AiServiceError,
MIN_DESCRIPTION_LENGTH,
columnsWithFlag,
declaresAnyLifecycleTrait,
parseRepoSlug,
resolveTaskGithubTracking,
resolveWorkflowIrForTask,
summarizeTitle,
type GlobalSettings,
type ProjectSettings,
type Task,
type TaskStore,
} from "@fusion/core";
import type { CreatedIssue } from "./github.js";
import { GitHubClient, isGitHubIssueAlreadyImported } from "./github.js";
import { resolveGithubTrackingAuth } from "./github-auth.js";
import {
buildIssueSearchQueries,
DEDUP_MATCH_THRESHOLD,
extractFileScopePaths,
extractSymptomKeywords,
scoreCandidateIssue,
} from "./github-tracking-dedup.js";
const TRACKING_ISSUE_TITLE_LIMIT = 240;
const TRACKING_ISSUE_BODY_SUMMARY_LIMIT = 500;
function collapseWhitespace(value: string): string {
return value.replace(/\s+/g, " ").trim();
}
function truncateWithEllipsis(value: string, maxLength: number): string {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, Math.max(0, maxLength - 1)).trimEnd()}…`;
}
export function deriveTitleFromDescription(description: string | undefined, maxLength: number): string | null {
if (!description || !description.trim()) {
return null;
}
const lines = description.split(/\r?\n/);
const cleanedLines: string[] = [];
let inCodeFence = false;
for (const line of lines) {
if (/^\s*```/.test(line)) {
inCodeFence = !inCodeFence;
continue;
}
if (inCodeFence) {
continue;
}
let cleaned = line.trim();
while (cleaned) {
const next = cleaned
.replace(/^>\s*/, "")
.replace(/^#{1,6}\s+/, "")
.replace(/^(?:[-*+]\s+|\d+\.\s+)/, "");
if (next === cleaned) {
break;
}
cleaned = next.trimStart();
}
cleanedLines.push(cleaned);
}
const firstLine = cleanedLines.find((line) => line.trim().length > 0);
if (!firstLine) {
return null;
}
const terminatorMatch = /[.!?](?=\s|$)/.exec(firstLine);
const candidate = terminatorMatch
? firstLine.slice(0, terminatorMatch.index + 1)
: firstLine;
const collapsed = collapseWhitespace(candidate);
if (!collapsed) {
return null;
}
return truncateWithEllipsis(collapsed, maxLength);
}
function firstNonEmptyParagraph(value: string | undefined): string | null {
if (!value) return null;
const paragraph = value
.split(/\n\s*\n/g)
.map((part) => part.trim())
.find((part) => part.length > 0);
return paragraph && paragraph.length > 0 ? paragraph : null;
}
function sanitizeSummaryText(value: string): string {
const cleaned = value
.split(/\r?\n/)
.filter((line) => !/^```/.test(line.trim()))
.map((line) => line.replace(/^\s*#{1,6}\s+/, "").replace(/^\s*(?:[-*+]\s+|\d+\.\s+)/, ""))
.join(" ");
const withoutFusionUrls = cleaned
.replace(/https?:\/\/localhost(?::\d+)?\/[^\s)]*/gi, " ")
.replace(/https?:\/\/[^\s)]*\/tasks\/FN-\d+[^\s)]*/gi, " ");
return collapseWhitespace(withoutFusionUrls);
}
export function formatTrackingIssueTitle(task: Pick<Task, "id" | "title" | "description">): string {
const prefix = `[${task.id}] `;
const maxTitleLength = Math.max(1, TRACKING_ISSUE_TITLE_LIMIT - prefix.length);
const baseTitle = collapseWhitespace(task.title ?? "")
|| deriveTitleFromDescription(task.description, maxTitleLength)
|| "Untitled task";
return `${prefix}${truncateWithEllipsis(baseTitle, maxTitleLength)}`;
}
export function formatTrackingIssueBody(task: {
id: string;
title?: string;
description?: string;
summary?: string;
prompt?: string;
}): string {
const source = firstNonEmptyParagraph(task.description)
?? firstNonEmptyParagraph(task.prompt)
?? task.summary?.trim()
?? "No summary available.";
const sanitized = sanitizeSummaryText(source) || "No summary available.";
const summary = sanitized.length > TRACKING_ISSUE_BODY_SUMMARY_LIMIT
? `${sanitized.slice(0, TRACKING_ISSUE_BODY_SUMMARY_LIMIT - 1).trimEnd()}…`
: sanitized;
return `Fusion task: ${task.id}\n\n${summary}`;
}
export interface MaybeCreateTrackingIssueDeps {
taskStore: TaskStore;
projectSettings: ProjectSettings;
globalSettings: GlobalSettings;
rootDir: string;
logger?: Pick<Console, "warn" | "info">;
}
export async function resolveImportedIssueGithubTracking(
store: TaskStore,
projectSettings: ProjectSettings,
): Promise<{ enabled: true } | undefined> {
if (projectSettings.githubLinkImportedIssuesToTracking === true) return { enabled: true };
const globalSettings = await store.getGlobalSettingsStore().getSettings();
return resolveTaskGithubTracking({ githubTracking: undefined }, projectSettings, globalSettings).enabled ? { enabled: true } : undefined;
}
/*
FNXC:GitHubPlanningSourceIssue 2026-08-09-05:36:
Create-time serialization narrows same-process races, but shared database nodes can still race.
Source adoption rechecks after linking and deterministically suppresses the loser so one issue has one tracker.
*/
const planningSourceIssueLocks = new Map<string, Promise<unknown>>();
function sourceIssueKey(store: TaskStore, issue: { owner: string; repo: string; number: number }): string {
return `github-source-tracking:${(store as unknown as { projectId?: string }).projectId ?? "__legacy_unscoped__"}:${issue.owner.toLowerCase()}/${issue.repo.toLowerCase()}#${issue.number}`;
}
async function withSourceIssueLock<T>(store: TaskStore, issue: { owner: string; repo: string; number: number }, action: () => Promise<T>): Promise<T> {
const key = sourceIssueKey(store, issue);
const previous = planningSourceIssueLocks.get(key) ?? Promise.resolve();
let release!: () => void;
const gate = new Promise<void>((resolve) => { release = resolve; });
const queued = previous.then(() => gate);
planningSourceIssueLocks.set(key, queued);
await previous;
try {
return await action();
} finally {
release();
if (planningSourceIssueLocks.get(key) === queued) planningSourceIssueLocks.delete(key);
}
}
function liveIssueHolders(tasks: Task[], taskId: string, issue: { owner: string; repo: string; number: number; url: string }): Task[] {
return tasks.filter((candidate) => candidate.id !== taskId && isGitHubIssueAlreadyImported(candidate, { owner: issue.owner, repo: issue.repo, issueNumber: issue.number, sourceUrl: issue.url }));
}
export async function resolvePlanningGithubTrackingDecision(store: TaskStore, projectSettings: ProjectSettings, sourceIssueInput: { owner: string; repo: string; issueNumber: number; url: string }): Promise<{ githubTracking?: { enabled: true }; suppressedByTaskId?: string }> {
const issue = { owner: sourceIssueInput.owner, repo: sourceIssueInput.repo, number: sourceIssueInput.issueNumber };
return withSourceIssueLock(store, issue, async () => {
const tasks = await store.listTasks({ slim: false, includeArchived: false });
const holder = liveIssueHolders(tasks, "", { ...issue, url: sourceIssueInput.url })[0];
if (holder) return { suppressedByTaskId: holder.id };
return (await resolveImportedIssueGithubTracking(store, projectSettings)) ? { githubTracking: { enabled: true } } : {};
});
}
export async function adoptGithubSourceIssueExclusively(store: TaskStore, taskId: string, issue: { owner: string; repo: string; number: number; url: string }): Promise<{ adopted: boolean; holderTaskId?: string }> {
// Legacy unit adapters predate listTasks; retain their established single-task adoption behavior.
if (typeof store.listTasks !== "function") {
await store.linkGithubIssue(taskId, { owner: issue.owner, repo: issue.repo, number: issue.number, url: issue.url, createdAt: new Date().toISOString() });
return { adopted: true };
}
return withSourceIssueLock(store, issue, async () => {
const all = await store.listTasks({ slim: false, includeArchived: false });
const linked = all.filter((task) => task.id !== taskId && task.githubTracking?.issue
&& task.githubTracking.issue.owner.toLowerCase() === issue.owner.toLowerCase()
&& task.githubTracking.issue.repo.toLowerCase() === issue.repo.toLowerCase()
&& task.githubTracking.issue.number === issue.number);
// A pre-existing link is authoritative even when this task is older: never steal a live stream.
if (linked.length > 0) {
const holder = linked.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))[0]!;
if (all.find((task) => task.id === taskId)?.githubTracking?.issue) {
await store.unlinkGithubIssue(taskId);
}
await store.updateGithubTracking(taskId, { enabled: false });
return { adopted: false, holderTaskId: holder.id };
}
/*
FNXC:GitHubPlanningSourceIssue 2026-08-09-08:09:
Layer 1 suppresses any existing provenance holder, not only an already-enabled tracker.
Keep that rule in Layer 2 too: the post-create hook can otherwise re-enable a
Layer-1-suppressed task through project defaults before exclusive adoption runs.
*/
const candidates = liveIssueHolders(all, taskId, issue);
const winner = [all.find((task) => task.id === taskId), ...candidates]
.filter((task): task is Task => Boolean(task))
.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id))[0];
if (winner?.id !== taskId) {
if (all.find((task) => task.id === taskId)?.githubTracking?.issue) {
await store.unlinkGithubIssue(taskId);
}
await store.updateGithubTracking(taskId, { enabled: false });
return { adopted: false, holderTaskId: winner.id };
}
await store.linkGithubIssue(taskId, { owner: issue.owner, repo: issue.repo, number: issue.number, url: issue.url, createdAt: new Date().toISOString() });
const after = await store.listTasks({ slim: false, includeArchived: false });
const linkedAfter = after.filter((task) => task.githubTracking?.issue && task.githubTracking.issue.owner.toLowerCase() === issue.owner.toLowerCase() && task.githubTracking.issue.repo.toLowerCase() === issue.repo.toLowerCase() && task.githubTracking.issue.number === issue.number).sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
if (linkedAfter[0]?.id !== taskId) {
await store.unlinkGithubIssue(taskId);
await store.updateGithubTracking(taskId, { enabled: false });
return { adopted: false, holderTaskId: linkedAfter[0]?.id };
}
return { adopted: true };
});
}
export type MaybeCreateTrackingIssueReason =
| "tracking_disabled"
| "issue_already_linked"
| "no_repo_configured"
| "no_title_available"
| "existing_issue_found"
| "source_issue_linked"
| "source_issue_already_tracked_elsewhere"
| "github_error"
| "auth_token_missing"
| "auth_gh_not_installed"
| "auth_gh_not_authenticated"
| "auth_invalid_mode";
/*
FNXC:GithubTracking 2026-08-15-22:27:
A tracking issue created AFTER the Fusion task is already done/archived has no later
task:moved event, so GitHubTrackingStateService never closes it. Observed on FN-9046 /
FN-9054 / FN-9061: executionCompletedAt preceded issue.createdAt by 4–15 minutes, and
the issues stayed OPEN. After create or dedup-link, close immediately when the task is
already in a complete or archived lane. Failures are logged and never undo the link.
*/
async function closeTrackingIssueIfTaskAlreadyTerminal(
task: Task,
store: TaskStore,
client: GitHubClient,
issue: { owner: string; repo: string; number: number },
): Promise<void> {
const latest = typeof store.getTask === "function"
? ((await store.getTask(task.id).catch(() => task)) ?? task)
: task;
const ir = await resolveWorkflowIrForTask(store, latest.id).catch(() => undefined);
const traitsExpressed = ir !== undefined && declaresAnyLifecycleTrait(ir);
const completeLanes = ir === undefined || !traitsExpressed ? ["done"] : columnsWithFlag(ir, "complete");
const archivedLanes = ir === undefined || !traitsExpressed ? ["archived"] : columnsWithFlag(ir, "archived");
const isComplete = completeLanes.includes(latest.column);
const isArchived = archivedLanes.includes(latest.column);
if (!isComplete && !isArchived) {
return;
}
try {
const existing = await client.getIssue(issue.owner, issue.repo, issue.number);
if (!existing || existing.state === "closed") {
return;
}
const stateReason = isArchived && !latest.executionCompletedAt ? "not_planned" : "completed";
await client.setIssueState(issue.owner, issue.repo, issue.number, "closed", stateReason);
if (typeof store.logEntry === "function") {
await store.logEntry(latest.id, "Closed linked GitHub tracking issue", `${issue.owner}/${issue.repo}#${issue.number}`);
}
if (typeof store.recordActivity === "function") {
await store.recordActivity({
type: "task:updated",
taskId: latest.id,
taskTitle: latest.title,
details: `Closed linked GitHub tracking issue ${issue.owner}/${issue.repo}#${issue.number} because the task is already ${latest.column}`,
metadata: {
type: "github-issue-closed-already-terminal",
repo: `${issue.owner}/${issue.repo}`,
number: issue.number,
},
});
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (typeof store.logEntry === "function") {
await store.logEntry(latest.id, "Failed to close GitHub tracking issue", message);
}
}
}
function resolveTrackingTitleSummarizerModel(
projectSettings: ProjectSettings,
globalSettings: GlobalSettings,
): { provider?: string; modelId?: string } {
const candidates = [
{
provider: projectSettings.titleSummarizerProvider,
modelId: projectSettings.titleSummarizerModelId,
},
{
provider: globalSettings.titleSummarizerGlobalProvider,
modelId: globalSettings.titleSummarizerGlobalModelId,
},
{
provider: projectSettings.titleSummarizerFallbackProvider,
modelId: projectSettings.titleSummarizerFallbackModelId,
},
];
for (const candidate of candidates) {
if (candidate.provider && candidate.modelId) {
return candidate;
}
}
return {};
}
export async function maybeCreateTrackingIssue(
task: Task,
deps: MaybeCreateTrackingIssueDeps,
): Promise<{ created: false; reason: MaybeCreateTrackingIssueReason } | { created: true; issue: CreatedIssue }> {
const inlineTracking = task.githubTracking;
const resolvedTracking = resolveTaskGithubTracking(task, deps.projectSettings, deps.globalSettings);
if (!resolvedTracking.enabled) {
deps.logger?.info?.(`[github-tracking] ${task.id}: skipped (tracking_disabled)`);
return { created: false, reason: "tracking_disabled" };
}
if (inlineTracking?.issue) {
deps.logger?.info?.(`[github-tracking] ${task.id}: skipped (issue_already_linked)`);
return { created: false, reason: "issue_already_linked" };
}
let latestTask = task;
if (typeof deps.taskStore.getTask === "function") {
try {
const loadedTask = await deps.taskStore.getTask(task.id);
if (loadedTask) {
latestTask = loadedTask;
}
} catch {
// Best-effort refresh only; continue with provided task if lookup fails.
}
}
const tracking = latestTask.githubTracking ?? inlineTracking;
if (tracking?.issue) {
deps.logger?.info?.(`[github-tracking] ${task.id}: skipped (issue_already_linked)`);
return { created: false, reason: "issue_already_linked" };
}
if (tracking?.enabled !== true) {
latestTask = await deps.taskStore.updateGithubTracking(task.id, {
...(tracking ?? {}),
enabled: true,
});
}
const sourceIssue = latestTask.sourceIssue;
if (sourceIssue?.provider === "github") {
const sourceRepo = parseRepoSlug(sourceIssue.repository);
if (sourceRepo && Number.isFinite(sourceIssue.issueNumber)) {
const url = sourceIssue.url
?? `https://github.com/${sourceRepo.owner}/${sourceRepo.repo}/issues/${sourceIssue.issueNumber}`;
const adoption = await adoptGithubSourceIssueExclusively(deps.taskStore, task.id, {
owner: sourceRepo.owner, repo: sourceRepo.repo, number: sourceIssue.issueNumber, url,
});
if (!adoption.adopted) {
await deps.taskStore.logEntry(task.id, `Source issue already tracked by ${adoption.holderTaskId ?? "another task"}`);
return { created: false, reason: "source_issue_already_tracked_elsewhere" };
}
await deps.taskStore.recordActivity({
type: "task:updated",
taskId: task.id,
taskTitle: latestTask.title,
details: `Linked source issue ${sourceRepo.owner}/${sourceRepo.repo}#${sourceIssue.issueNumber}`,
metadata: {
type: "github-issue-source-linked",
repo: `${sourceRepo.owner}/${sourceRepo.repo}`,
number: sourceIssue.issueNumber,
htmlUrl: url,
},
});
return { created: false, reason: "source_issue_linked" };
}
}
const repo = resolvedTracking.repo;
if (!repo) {
deps.logger?.warn?.(`[github-tracking] No repo configured for ${task.id}`);
await deps.taskStore.recordActivity({
type: "task:updated",
taskId: task.id,
taskTitle: latestTask.title,
details: "GitHub tracking issue not created: no repository configured",
metadata: { type: "github-tracking-no-repo" },
});
return { created: false, reason: "no_repo_configured" };
}
const titleMissing = collapseWhitespace(latestTask.title ?? "").length === 0;
const resolvedSummarizer = resolveTrackingTitleSummarizerModel(deps.projectSettings, deps.globalSettings);
const canSummarizeTitle = titleMissing
&& typeof latestTask.description === "string"
&& latestTask.description.length >= MIN_DESCRIPTION_LENGTH
&& Boolean(resolvedSummarizer.provider && resolvedSummarizer.modelId);
if (canSummarizeTitle) {
try {
const generatedTitle = await summarizeTitle(
latestTask.description,
deps.rootDir,
resolvedSummarizer.provider,
resolvedSummarizer.modelId,
);
if (generatedTitle) {
const updatedTask = await deps.taskStore.updateTask(task.id, { title: generatedTitle });
task.title = updatedTask.title;
latestTask = updatedTask;
await deps.taskStore.recordActivity({
type: "task:updated",
taskId: task.id,
taskTitle: updatedTask.title,
details: "Generated task title for GitHub tracking issue",
metadata: { type: "github-tracking-title-summarized" },
});
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
const prefix = error instanceof AiServiceError
? "AI title summarizer failed"
: "Title summarizer failed";
deps.logger?.warn?.(`[github-tracking] ${task.id}: ${prefix}: ${message}`);
}
}
const effectiveTitle = collapseWhitespace(latestTask.title ?? "")
|| deriveTitleFromDescription(latestTask.description, TRACKING_ISSUE_TITLE_LIMIT - `[${task.id}] `.length);
if (!effectiveTitle) {
deps.logger?.info?.(`[github-tracking] ${task.id}: deferred — no usable title; waiting for title or summarizer`);
await deps.taskStore.recordActivity({
type: "task:updated",
taskId: task.id,
taskTitle: latestTask.title,
details: "GitHub tracking issue not created: task has no title yet",
metadata: { type: "github-tracking-no-title" },
});
return { created: false, reason: "no_title_available" };
}
const resolution = resolveGithubTrackingAuth({
projectSettings: deps.projectSettings,
globalSettings: deps.globalSettings,
});
if (!resolution.ok) {
deps.logger?.warn?.(`[github-tracking] ${task.id}: auth unavailable (${resolution.reason}): ${resolution.message}`);
await deps.taskStore.recordActivity({
type: "task:updated",
taskId: task.id,
taskTitle: latestTask.title,
details: `GitHub tracking issue not created: ${resolution.message}`,
metadata: {
type: "github-issue-skipped",
reason: resolution.reason,
message: resolution.message,
},
});
return { created: false, reason: `auth_${resolution.reason}` };
}
const githubClient = resolution.auth.mode === "token"
? new GitHubClient({ token: resolution.auth.token, forceMode: "token" })
: new GitHubClient({ forceMode: "gh-cli" });
const title = formatTrackingIssueTitle(latestTask);
const body = formatTrackingIssueBody(latestTask);
/*
FNXC:GithubTracking 2026-07-05-00:00:
Tracking-issue dedup was mis-linking new tasks to OLD/STALE issues (operator report: FN-7579 got an old issue id instead of a fresh one).
Two false-positive vectors, both fixed here:
1. Search included CLOSED issues (state: "all"), so a resolved tracking issue from an earlier, unrelated task could be reused. Dedup only exists to avoid opening a *second live* issue for the same active work — a closed/resolved issue must never be reused. We now search and accept OPEN issues only.
2. The accept filter allowed a keyword-only match (matchedKeywords >= 2 with zero file-path overlap). Symptom keywords are generic camelCase identifiers shared across many tasks (e.g. `githubTracking`, `trackingIssue`), so 2-3 shared tokens is a weak signal that routinely mis-matched. We now require at least one File-Scope path overlap before reusing an issue; keyword count only breaks ties / raises confidence.
Net effect: a task with no File-Scope paths (or no OPEN path-overlapping issue) always creates a fresh tracking issue rather than mis-linking. See docs/triage-duplicate-detection-postmortem.md.
*/
if (deps.projectSettings.githubTrackingDedupEnabled !== false) {
try {
const paths = extractFileScopePaths(latestTask as Task & { prompt?: string });
const keywords = extractSymptomKeywords(latestTask, { max: 6 });
// FNXC:GithubTracking Path overlap is now mandatory for a dedup link — without File-Scope paths there is no strong-enough signal, so skip the search entirely and create fresh.
if (paths.length > 0) {
const queries = buildIssueSearchQueries(paths, keywords);
const byNumber = new Map<number, {
number: number;
title: string;
body: string | null;
html_url: string;
state: "open" | "closed";
updatedAt?: string;
}>();
for (const query of queries) {
const candidates = await githubClient.searchIssues(repo.owner, repo.repo, query, { state: "open", limit: 10 });
for (const candidate of candidates) {
// FNXC:GithubTracking Defensive: never reuse a closed/resolved issue even if the API returns one.
if (candidate.state !== "open") continue;
if (!byNumber.has(candidate.number)) {
byNumber.set(candidate.number, candidate);
}
}
const scored = [...byNumber.values()]
.map((candidate) => ({ candidate, ...scoreCandidateIssue(candidate, paths, keywords) }))
.filter((entry) => entry.score >= DEDUP_MATCH_THRESHOLD)
.filter((entry) => entry.matchedPaths.length > 0)
.sort((a, b) => b.score - a.score);
const bestMatch = scored[0];
if (bestMatch) {
await deps.taskStore.linkGithubIssue(task.id, {
owner: repo.owner,
repo: repo.repo,
number: bestMatch.candidate.number,
url: bestMatch.candidate.html_url,
createdAt: bestMatch.candidate.updatedAt ?? new Date().toISOString(),
});
await deps.taskStore.recordActivity({
type: "task:updated",
taskId: task.id,
taskTitle: latestTask.title,
details: `Linked existing issue ${repo.owner}/${repo.repo}#${bestMatch.candidate.number} (dedup match; see docs/triage-duplicate-detection-postmortem.md)`,
metadata: {
type: "github-issue-dedup-matched",
repo: `${repo.owner}/${repo.repo}`,
number: bestMatch.candidate.number,
htmlUrl: bestMatch.candidate.html_url,
score: bestMatch.score,
matchedPaths: bestMatch.matchedPaths,
matchedKeywords: bestMatch.matchedKeywords,
state: bestMatch.candidate.state,
},
});
await closeTrackingIssueIfTaskAlreadyTerminal(latestTask, deps.taskStore, githubClient, {
owner: repo.owner,
repo: repo.repo,
number: bestMatch.candidate.number,
});
return { created: false, reason: "existing_issue_found" };
}
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.logger?.warn?.(`[github-tracking] ${task.id}: duplicate-search failed; falling back to issue creation: ${message}`);
}
}
try {
const issue = await githubClient.createIssue({ owner: repo.owner, repo: repo.repo, title, body });
await deps.taskStore.linkGithubIssue(task.id, {
owner: repo.owner,
repo: repo.repo,
number: issue.number,
url: issue.htmlUrl,
createdAt: issue.createdAt,
});
await deps.taskStore.recordActivity({
type: "task:updated",
taskId: task.id,
taskTitle: latestTask.title,
details: `Linked tracking issue ${repo.owner}/${repo.repo}#${issue.number}`,
metadata: {
type: "github-issue-created",
repo: `${repo.owner}/${repo.repo}`,
number: issue.number,
htmlUrl: issue.htmlUrl,
},
});
await closeTrackingIssueIfTaskAlreadyTerminal(latestTask, deps.taskStore, githubClient, {
owner: repo.owner,
repo: repo.repo,
number: issue.number,
});
return { created: true, issue };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
deps.logger?.warn?.(`[github-tracking] Failed to create issue for ${task.id} in ${repo.owner}/${repo.repo}: ${message}`);
await deps.taskStore.recordActivity({
type: "task:updated",
taskId: task.id,
taskTitle: latestTask.title,
details: `GitHub tracking issue not created: ${message}`,
metadata: {
type: "github-issue-failed",
reason: "github_error",
message,
},
});
return { created: false, reason: "github_error" };
}
}