FN-8227: link closed tracked issues to landing commits

Closed GitHub tracked issues now reliably include their landing commit link.

- Re-read the authoritative task row before posting a Done tracking comment.
- Preserve event-snapshot commit links when the task lookup fails or is absent.
- Cover stale events, unavailable rows, no-op landings, and non-Done transitions.

Files changed:
 .changeset/fn-8227-tracked-issue-commit-link.md    |  7 ++
 .../src/__tests__/github-tracking-comments.test.ts | 81 ++++++++++++++++++++++
 packages/dashboard/src/github-tracking-comments.ts | 14 +++-
 3 files changed, 100 insertions(+), 2 deletions(-)

Fusion-Task-Id: FN-8227

Fusion-Task-Lineage: f497dbb8-da5c-4409-a8fd-836d6df573da

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-07-17 13:04:45 -07:00
parent 9b9d6a2e72
commit f3ef60b80a
3 changed files with 100 additions and 2 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Closed GitHub tracked issues now reliably link the landing commit.
category: fix
dev: GitHubTrackingCommentService re-reads the authoritative task via store.getTask before building the Done comment, so mergeDetails.commitSha present at closure time is linked even when the task:moved snapshot omitted it (autoMerge:false PR merges, no-op landings, recovery finalization). Falls back to the event snapshot on refetch failure.

View File

@@ -41,12 +41,15 @@ vi.mock("../cli-package-version.js", async (importOriginal) => ({
class MockStore extends EventEmitter { class MockStore extends EventEmitter {
logEntry: Mock; logEntry: Mock;
getTask: Mock;
getSettings: Mock; getSettings: Mock;
getGlobalSettingsStore: Mock; getGlobalSettingsStore: Mock;
constructor() { constructor() {
super(); super();
this.logEntry = vi.fn().mockResolvedValue(undefined); this.logEntry = vi.fn().mockResolvedValue(undefined);
// Null preserves the event snapshot unless a test supplies a newer authoritative row.
this.getTask = vi.fn().mockResolvedValue(null);
this.getSettings = vi.fn().mockResolvedValue({ githubAuthMode: "token", githubAuthToken: "ghp_test" }); this.getSettings = vi.fn().mockResolvedValue({ githubAuthMode: "token", githubAuthToken: "ghp_test" });
this.getGlobalSettingsStore = vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) })); this.getGlobalSettingsStore = vi.fn(() => ({ getSettings: vi.fn().mockResolvedValue({}) }));
} }
@@ -503,6 +506,84 @@ describe("GitHubTrackingCommentService", () => {
expect(body).not.toContain("Current version"); expect(body).not.toContain("Current version");
}); });
it("recovers a landing commit from the authoritative row when the done event is stale", async () => {
service.start();
const snapshot = createTask({ mergeDetails: { prNumber: 7 } });
store.getTask.mockResolvedValueOnce(createTask({
mergeDetails: { commitSha: "abcdef1234567890", prNumber: 7 },
}));
store.emit("task:moved", { task: snapshot, from: "in-progress", to: "done" });
await flushAsync();
expect(store.getTask).toHaveBeenCalledWith("FN-1");
expect(mockCommentOnIssue).toHaveBeenCalledTimes(1);
expect(mockCommentOnIssue.mock.calls[0]?.[3]).toContain(
"Commit: [abcdef1](https://github.com/owner/repo/commit/abcdef1234567890)",
);
});
it("keeps an event-snapshot commit link when the authoritative row is unavailable", async () => {
service.start();
store.getTask.mockRejectedValueOnce(new Error("store unavailable"));
store.emit("task:moved", {
task: createTask({ mergeDetails: { commitSha: "abcdef1234567890" } }),
from: "in-progress",
to: "done",
});
await flushAsync();
expect(mockCommentOnIssue).toHaveBeenCalledTimes(1);
expect(mockCommentOnIssue.mock.calls[0]?.[3]).toContain(
"Commit: [abcdef1](https://github.com/owner/repo/commit/abcdef1234567890)",
);
});
it("keeps an event-snapshot commit link when the authoritative row is missing", async () => {
service.start();
store.getTask.mockResolvedValueOnce(null);
store.emit("task:moved", {
task: createTask({ mergeDetails: { commitSha: "abcdef1234567890" } }),
from: "in-progress",
to: "done",
});
await flushAsync();
expect(mockCommentOnIssue).toHaveBeenCalledTimes(1);
expect(mockCommentOnIssue.mock.calls[0]?.[3]).toContain(
"Commit: [abcdef1](https://github.com/owner/repo/commit/abcdef1234567890)",
);
});
it("posts a no-op landing without a Commit line", async () => {
service.start();
store.getTask.mockResolvedValueOnce(createTask({ mergeDetails: { noOpMerge: true } }));
store.emit("task:moved", {
task: createTask({ mergeDetails: { noOpMerge: true } }),
from: "in-progress",
to: "done",
});
await flushAsync();
expect(mockCommentOnIssue).toHaveBeenCalledTimes(1);
expect(mockCommentOnIssue.mock.calls[0]?.[3]).not.toContain("Commit:");
});
it("does not refetch or duplicate a comment for in-progress and same-column transitions", async () => {
service.start();
store.emit("task:moved", { task: createTask(), from: "todo", to: "in-progress" });
store.emit("task:moved", { task: createTask(), from: "done", to: "done" });
await flushAsync();
expect(store.getTask).not.toHaveBeenCalled();
expect(mockCommentOnIssue).toHaveBeenCalledTimes(1);
expect(mockCommentOnIssue.mock.calls[0]?.[3]).toContain("🚧 In progress");
});
it("writes success logs", async () => { it("writes success logs", async () => {
service.start(); service.start();

View File

@@ -248,9 +248,19 @@ export class GitHubTrackingCommentService {
return; return;
} }
/*
* FNXC:GitHubTrackingComments 2026-07-16-12:40:
* A closed tracked issue must link its landing commit when one exists. The task:moved snapshot
* can predate mergeDetails persistence on human PR, no-op, and recovery done paths, so re-read
* the authoritative row before building the Done comment. Fall back to the snapshot when the
* read fails so the comment is never dropped.
*/
const taskForComment = event.to === "done"
? await this.store.getTask(event.task.id).catch(() => null) ?? event.task
: event.task;
const body = event.to === "done" const body = event.to === "done"
? formatTrackingComment(event.task, event.to, { owner, repo }) ? formatTrackingComment(taskForComment, event.to, { owner, repo })
: formatTrackingComment(event.task, event.to); : formatTrackingComment(taskForComment, event.to);
try { try {
const projectSettings = await this.store.getSettings() as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">; const projectSettings = await this.store.getSettings() as Pick<ProjectSettings, "githubAuthMode" | "githubAuthToken">;