Skip to content

Commit b95c0fd

Browse files
fix(github): keep a webhook delivery alive when link metadata will not parse (#1526)
* fix(github): keep a webhook delivery alive when link metadata will not parse An external link's metadata is a JSON string in the database, so a row written by an older version, or a truncated one, is not something the delivery can do anything about. On the GitHub side four handlers called `JSON.parse` on it bare, so the throw escaped the handler and the whole delivery failed, leaving the task unsynced with no record of why. The Gitea handlers already warn and carry on with an empty object. This is the same behaviour, in one helper, since the GitHub side needs it in four places. `issue-closed` also stopped at the first matching integration, so a repository connected to two projects only moved one task. Its Gitea twin and `issue-opened` on this side both walk all of them. `pull-request-closed` returns early on both sides, so that one is left as it is. * fix(github): type the metadata the edit handler reads back `JSON.parse` returned `any`, so reading `metadata.lastSync.title` off it went unchecked. The helper hands back a typed value, which made `tsc` point at four reads that were never verified. `parseLinkMetadata` takes the shape from the caller now, defaulting to the untyped record for the handlers that only spread it forward, and the edit handler declares what it looks for. Every field is optional, since a row may predate any of them. A stamp with no timestamp used to become an Invalid Date, whose NaN failed the recency comparison; the epoch fails it the same way, so the branch taken does not change. * fix(github): ignore link metadata that parses to something other than an object `JSON.parse` answers `null` for the row `null`, and a string for `"kaneo"`. The helper handed either one back as metadata, so a caller reading `metadata.createdFrom` off it threw inside the webhook, which is the crash this helper exists to prevent. A parsed value that is not a plain object now takes the same exit as a row that will not parse. Arrays go with them: nothing here writes one, and spreading it forward would turn its indices into keys. The warning no longer carries the row itself. A row can hold a task description synced from Kaneo, and the link id is enough to find it by. --------- Co-authored-by: (justin)randoneering <justin@randoneering.tech>
1 parent 37b47f8 commit b95c0fd

5 files changed

Lines changed: 155 additions & 15 deletions

File tree

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
// An external link's metadata is a JSON string in the database, so a row
2+
// written by an older version, or truncated, is not the caller's fault and
3+
// should not take the webhook delivery down with it. The Gitea handlers already
4+
// warn and carry on with an empty object; this is the same behaviour in one
5+
// place, since the GitHub side needs it in four.
6+
// The shape is whatever an earlier write left behind, so callers that read
7+
// named fields say what they expect. The default keeps the untyped reading for
8+
// the handlers that only spread the value forward.
9+
export function parseLinkMetadata<T extends object = Record<string, unknown>>(
10+
raw: string | null | undefined,
11+
context: { externalLinkId: string; source: string },
12+
): Partial<T> {
13+
if (!raw) {
14+
return {};
15+
}
16+
17+
let parsed: unknown;
18+
19+
try {
20+
parsed = JSON.parse(raw);
21+
} catch (error) {
22+
console.warn("Failed to parse GitHub external link metadata", {
23+
...context,
24+
error,
25+
});
26+
27+
return {};
28+
}
29+
30+
// Parsing succeeding is not the same as the row holding metadata. The
31+
// literal `null` parses to `null`, `"kaneo"` to a string, and either would
32+
// pass the return type on to a caller that reads a named field off it, which
33+
// is the crash this helper exists to prevent. An array is not what any writer
34+
// here produces either, and spreading one forward would turn its indices into
35+
// keys. Both take the same exit as a row that will not parse.
36+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
37+
console.warn(
38+
"Ignoring GitHub external link metadata that is not an object",
39+
{
40+
...context,
41+
metadataType: parsed === null ? "null" : typeof parsed,
42+
},
43+
);
44+
45+
return {};
46+
}
47+
48+
return parsed as Partial<T>;
49+
}

apps/api/src/plugins/github/webhooks/issue-closed.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
findAllIntegrationsByRepo,
88
updateTaskStatus,
99
} from "../services/task-service";
10+
import { parseLinkMetadata } from "../utils/parse-link-metadata";
1011
import { resolveTargetStatus } from "../utils/resolve-column";
1112

1213
type IssueClosedPayload = {
@@ -53,9 +54,10 @@ export async function handleIssueClosed(payload: IssueClosedPayload) {
5354
continue;
5455
}
5556

56-
const existingMetadata = externalLink.metadata
57-
? JSON.parse(externalLink.metadata)
58-
: {};
57+
const existingMetadata = parseLinkMetadata(externalLink.metadata, {
58+
externalLinkId: externalLink.id,
59+
source: "issue_closed",
60+
});
5961

6062
if (existingMetadata.createdFrom === "kaneo") {
6163
continue;
@@ -90,7 +92,5 @@ export async function handleIssueClosed(payload: IssueClosedPayload) {
9092
state: "closed",
9193
},
9294
});
93-
94-
return;
9595
}
9696
}

apps/api/src/plugins/github/webhooks/issue-edited.ts

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,22 @@ import { taskTable } from "../../../database/schema";
44
import { findExternalLink, updateExternalLink } from "../services/link-manager";
55
import { findAllIntegrationsByRepo } from "../services/task-service";
66
import { formatTaskDescriptionFromIssue } from "../utils/format";
7+
import { parseLinkMetadata } from "../utils/parse-link-metadata";
8+
9+
// What this handler reads back out of the row. Every field is optional,
10+
// because the row may predate any of them.
11+
type SyncStamp = {
12+
timestamp?: string;
13+
source?: string;
14+
value?: string;
15+
};
16+
17+
type IssueEditedMetadata = {
18+
lastSync?: {
19+
title?: SyncStamp;
20+
description?: SyncStamp;
21+
};
22+
};
723

824
type IssueEditedPayload = {
925
action: string;
@@ -63,12 +79,16 @@ export async function handleIssueEdited(payload: IssueEditedPayload) {
6379
continue;
6480
}
6581

66-
const metadata = externalLink.metadata
67-
? JSON.parse(externalLink.metadata)
68-
: {};
82+
const metadata = parseLinkMetadata<IssueEditedMetadata>(
83+
externalLink.metadata,
84+
{
85+
externalLinkId: externalLink.id,
86+
source: "issue_edited",
87+
},
88+
);
6989

7090
const updateData: Record<string, unknown> = {};
71-
const updatedMetadata = { ...metadata };
91+
const updatedMetadata: IssueEditedMetadata = { ...metadata };
7292

7393
if (!updatedMetadata.lastSync) {
7494
updatedMetadata.lastSync = {};
@@ -89,7 +109,7 @@ export async function handleIssueEdited(payload: IssueEditedPayload) {
89109
}
90110

91111
const timeSinceLastSync =
92-
Date.now() - new Date(lastTitleSync.timestamp).getTime();
112+
Date.now() - new Date(lastTitleSync.timestamp ?? 0).getTime();
93113
if (timeSinceLastSync < 2000 && shouldUpdateTitle) {
94114
console.log(
95115
`Skipping title update - recent sync detected (${timeSinceLastSync}ms ago)`,
@@ -129,7 +149,7 @@ export async function handleIssueEdited(payload: IssueEditedPayload) {
129149
}
130150

131151
const timeSinceLastSync =
132-
Date.now() - new Date(lastDescSync.timestamp).getTime();
152+
Date.now() - new Date(lastDescSync.timestamp ?? 0).getTime();
133153
if (timeSinceLastSync < 2000 && shouldUpdateDescription) {
134154
console.log(
135155
`Skipping description update - recent sync detected (${timeSinceLastSync}ms ago)`,

apps/api/src/plugins/github/webhooks/pull-request-closed.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
findTaskById,
1010
updateTaskStatus,
1111
} from "../services/task-service";
12+
import { parseLinkMetadata } from "../utils/parse-link-metadata";
1213
import { resolveTargetStatus } from "../utils/resolve-column";
1314

1415
type PRClosedPayload = {
@@ -59,9 +60,10 @@ export async function handlePullRequestClosed(payload: PRClosedPayload) {
5960
continue;
6061
}
6162

62-
const existingMetadata = externalLink.metadata
63-
? JSON.parse(externalLink.metadata)
64-
: {};
63+
const existingMetadata = parseLinkMetadata(externalLink.metadata, {
64+
externalLinkId: externalLink.id,
65+
source: "pull_request_closed",
66+
});
6567

6668
await updateExternalLink(externalLink.id, {
6769
metadata: {
@@ -82,7 +84,10 @@ export async function handlePullRequestClosed(payload: PRClosedPayload) {
8284

8385
const hasOpenPRs = allTaskPRs.some((pr) => {
8486
if (pr.id === externalLink.id) return false;
85-
const metadata = pr.metadata ? JSON.parse(pr.metadata) : {};
87+
const metadata = parseLinkMetadata(pr.metadata, {
88+
externalLinkId: pr.id,
89+
source: "pull_request_closed",
90+
});
8691
return metadata.state === "open";
8792
});
8893

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import { afterEach, describe, expect, it, vi } from "vitest";
2+
import { parseLinkMetadata } from "../../../../../apps/api/src/plugins/github/utils/parse-link-metadata";
3+
4+
const context = { externalLinkId: "link-1", source: "issue_closed" };
5+
6+
afterEach(() => {
7+
vi.restoreAllMocks();
8+
});
9+
10+
describe("parseLinkMetadata", () => {
11+
it("reads a well formed object", () => {
12+
expect(
13+
parseLinkMetadata('{"state":"open","createdFrom":"kaneo"}', context),
14+
).toEqual({
15+
state: "open",
16+
createdFrom: "kaneo",
17+
});
18+
});
19+
20+
it("treats an absent value as no metadata", () => {
21+
expect(parseLinkMetadata(null, context)).toEqual({});
22+
expect(parseLinkMetadata(undefined, context)).toEqual({});
23+
expect(parseLinkMetadata("", context)).toEqual({});
24+
});
25+
26+
it("warns and carries on when the row cannot be parsed", () => {
27+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
28+
29+
// A row truncated by an older write, which used to throw out of the handler
30+
// and fail the whole webhook delivery.
31+
expect(parseLinkMetadata('{"state":"op', context)).toEqual({});
32+
expect(warn).toHaveBeenCalledOnce();
33+
expect(warn.mock.calls[0][1]).toMatchObject({
34+
externalLinkId: "link-1",
35+
source: "issue_closed",
36+
});
37+
});
38+
39+
it("keeps the row out of the warning", () => {
40+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
41+
42+
// The row can hold a task description synced from Kaneo, so the log gets
43+
// the link id to find it by and not the content itself.
44+
parseLinkMetadata(
45+
'{"lastSync":{"description":"segredo do cliente"',
46+
context,
47+
);
48+
expect(JSON.stringify(warn.mock.calls[0])).not.toContain(
49+
"segredo do cliente",
50+
);
51+
});
52+
53+
it("ignores a row that parses to something other than an object", () => {
54+
const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined);
55+
56+
// `JSON.parse` answers these without throwing, so they used to reach the
57+
// handlers typed as metadata. `null` is the one that bites: reading
58+
// `metadata.createdFrom` off it throws inside the webhook.
59+
expect(parseLinkMetadata("null", context)).toEqual({});
60+
expect(parseLinkMetadata('"kaneo"', context)).toEqual({});
61+
expect(parseLinkMetadata("42", context)).toEqual({});
62+
expect(parseLinkMetadata("true", context)).toEqual({});
63+
expect(parseLinkMetadata('["kaneo"]', context)).toEqual({});
64+
expect(warn).toHaveBeenCalledTimes(5);
65+
});
66+
});

0 commit comments

Comments
 (0)