Skip to content
Open
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
32 changes: 32 additions & 0 deletions src/merged-linear-completion-reconciler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ export class MergedLinearCompletionReconciler {
return;
}

await this.commentOnCompletedIssueReopen(issue, linear);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 If Linear creates the comment but transiently rejects setIssueState, each normal retry creates another comment and claims a restoration that has not happened.

Suggested change
await this.commentOnCompletedIssueReopen(issue, linear);
const updated = await linear.setIssueState(issue.linearIssueId, targetState);
await this.commentOnCompletedIssueReopen(issue, linear);

const updated = await linear.setIssueState(issue.linearIssueId, targetState);
this.db.issueSessions.commitIssueState({
writer: WRITER,
Expand All @@ -132,6 +133,23 @@ export class MergedLinearCompletionReconciler {
});
}

private async commentOnCompletedIssueReopen(
issue: IssueRecord,
linear: NonNullable<Awaited<ReturnType<LinearClientProvider["forProject"]>>>,
): Promise<void> {
try {
await linear.upsertIssueComment({
issueId: issue.linearIssueId,
body: buildCompletedIssueReopenComment(issue),
});
} catch (error) {
this.logger.warn(
{ issueKey: issue.issueKey, error: error instanceof Error ? error.message : String(error) },
"Failed to explain why reopening completed work does not start a new run",
);
}
}

private reopenStaleLocalDoneIssue(
issue: IssueRecord,
liveIssue: Awaited<ReturnType<NonNullable<Awaited<ReturnType<LinearClientProvider["forProject"]>>>["getIssue"]>>,
Expand Down Expand Up @@ -297,3 +315,17 @@ function resolveOpenWorkflowState(

return undefined;
}

function buildCompletedIssueReopenComment(
issue: Pick<IssueRecord, "prNumber" | "prUrl">,
): string {
const completedDelivery = issue.prNumber !== undefined
? issue.prUrl
? ` Its delivery PR [#${issue.prNumber}](${issue.prUrl}) is already merged.`
: ` Its delivery PR #${issue.prNumber} is already merged.`
: " PatchRelay has already completed this issue.";

return `PatchRelay did not start new work.${completedDelivery}

Changing the status or delegating this completed issue to PatchRelay again only restores the completed state; it does not launch another run. Create a new Linear issue for the follow-up work and delegate that new issue to PatchRelay.`;
}
124 changes: 124 additions & 0 deletions test/merged-linear-completion-reconciler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,123 @@ test("reconciler keeps canceled issues buried instead of reopening stale local d
}
});

test("reconciler explains that reopening a merged issue does not start new work", async () => {
const { baseDir, db } = createDb();
try {
db.upsertIssue({
projectId: "usertold",
linearIssueId: "issue-merged-complete",
issueKey: "USE-202B",
delegatedToPatchRelay: true,
workflowOutcome: "completed",
currentLinearState: "Todo",
currentLinearStateType: "unstarted",
prNumber: 202,
prUrl: "https://github.com/test/repo/pull/202",
prState: "merged",
});

let requestedState: string | undefined;
let commentBody: string | undefined;
const reconciler = new MergedLinearCompletionReconciler(
db,
{
forProject: async () => ({
getIssue: async () => buildLiveIssue({
id: "issue-merged-complete",
identifier: "USE-202B",
title: "Merged issue",
stateName: "Todo",
stateType: "unstarted",
}),
upsertIssueComment: async (params) => {
commentBody = params.body;
return { id: "comment-completed-reopen", body: params.body };
},
setIssueState: async (_issueId, state) => {
requestedState = state;
return buildLiveIssue({
id: "issue-merged-complete",
identifier: "USE-202B",
title: "Merged issue",
stateName: state,
stateType: "completed",
});
},
}) as LinearClient,
},
pino({ enabled: false }),
);

await reconciler.reconcile();

assert.equal(requestedState, "Done");
assert.match(commentBody ?? "", /did not start new work/);
assert.match(commentBody ?? "", /\[#202\]\(https:\/\/github\.com\/test\/repo\/pull\/202\) is already merged/);
assert.match(commentBody ?? "", /does not launch another run/);
assert.match(commentBody ?? "", /Create a new Linear issue/);
} finally {
rmSync(baseDir, { recursive: true, force: true });
}
});

test("reconciler still restores Done when the completed-issue explanation fails", async () => {
const { baseDir, db } = createDb();
try {
db.upsertIssue({
projectId: "usertold",
linearIssueId: "issue-merged-comment-failure",
issueKey: "USE-202C",
delegatedToPatchRelay: true,
workflowOutcome: "completed",
currentLinearState: "Todo",
currentLinearStateType: "unstarted",
prNumber: 203,
prState: "merged",
});

let requestedState: string | undefined;
const reconciler = new MergedLinearCompletionReconciler(
db,
{
forProject: async () => ({
getIssue: async () => buildLiveIssue({
id: "issue-merged-comment-failure",
identifier: "USE-202C",
title: "Merged issue with comment failure",
stateName: "Todo",
stateType: "unstarted",
}),
upsertIssueComment: async () => {
throw new Error("Linear comment API unavailable");
},
setIssueState: async (_issueId, state) => {
requestedState = state;
return buildLiveIssue({
id: "issue-merged-comment-failure",
identifier: "USE-202C",
title: "Merged issue with comment failure",
stateName: state,
stateType: "completed",
});
},
}) as LinearClient,
},
pino({ enabled: false }),
);

await reconciler.reconcile();

assert.equal(requestedState, "Done");
assert.equal(
db.getIssue("usertold", "issue-merged-comment-failure")?.currentLinearState,
"Done",
);
} finally {
rmSync(baseDir, { recursive: true, force: true });
}
});

test("reconciler completes trusted no-PR done issues in Linear instead of reopening them", async () => {
const { baseDir, db } = createDb();
try {
Expand All @@ -221,6 +338,7 @@ test("reconciler completes trusted no-PR done issues in Linear instead of reopen
});

let requestedState: string | undefined;
let commentBody: string | undefined;
const reconciler = new MergedLinearCompletionReconciler(
db,
{
Expand All @@ -232,6 +350,10 @@ test("reconciler completes trusted no-PR done issues in Linear instead of reopen
stateName: "In Progress",
stateType: "started",
}),
upsertIssueComment: async (params) => {
commentBody = params.body;
return { id: "comment-no-pr-completed-reopen", body: params.body };
},
setIssueState: async (_issueId, state) => {
requestedState = state;
return { stateName: state, stateType: "completed" };
Expand All @@ -245,6 +367,8 @@ test("reconciler completes trusted no-PR done issues in Linear instead of reopen

const refreshed = db.getIssue("usertold", "issue-no-pr-complete");
assert.equal(requestedState, "Done");
assert.match(commentBody ?? "", /PatchRelay has already completed this issue/);
assert.match(commentBody ?? "", /Create a new Linear issue/);
assertIssuePhase(refreshed, "done");
assert.equal(refreshed?.currentLinearState, "Done");
assert.equal(refreshed?.currentLinearStateType, "completed");
Expand Down
Loading