Skip to content

fix(github): keep a webhook delivery alive when link metadata will not parse - #1526

Merged
andrejsshell merged 4 commits into
usekaneo:mainfrom
luantaraschi:fix/github-webhook-metadata-guards
Aug 15, 2026
Merged

fix(github): keep a webhook delivery alive when link metadata will not parse#1526
andrejsshell merged 4 commits into
usekaneo:mainfrom
luantaraschi:fix/github-webhook-metadata-guards

Conversation

@luantaraschi

@luantaraschi luantaraschi commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Follow-up to the two things I flagged while reviewing #1518 rather than folding them into that diff.

The metadata parse

externalLink.metadata is a JSON string in the database. A row written by an older version, or truncated, is not something the incoming delivery can do anything about, but four handlers on the GitHub side called JSON.parse on it bare:

file line
webhooks/issue-closed.ts 57
webhooks/issue-edited.ts 67
webhooks/pull-request-closed.ts 63 and 85

The throw escapes the handler, so the whole webhook delivery fails and the task is left unsynced with nothing in the log explaining why.

The Gitea handlers already do this properly. gitea/webhooks/issue-closed.ts and gitea/webhooks/issue-reopened.ts parse inside a try, warn with the external link id, and carry on with an empty object. This does the same, in one helper, since the GitHub side needs it in four places rather than one.

The early return

issue-closed returned after the first matching integration, so a repository connected to two projects moved one task to Done and left the other behind. Its Gitea twin walks all of them, and so does issue-opened on this side, so this looked like the odd one out rather than a decision.

pull-request-closed returns early on both sides, so I left that one alone. If the intent there is also to process every integration, that is a separate change and I would rather ask than assume.

What I deliberately did not touch

JSON.parse(integration.config) is still bare in issue-opened, pull-request-opened, push and pull-request-closed. The Gitea side guards those too, but config is written by the app rather than arriving from outside, so the failure story is much weaker and it did not feel like it belonged in the same diff. Happy to send it separately if you want the parity.

Tests

tests/api/plugins/github/utils/parse-link-metadata.test.ts, three cases: a well formed row, an absent value, and a truncated one where it warns and returns an empty object instead of throwing.

The webhook handlers themselves have no tests in the repo today, since they reach the database, so the coverage here is on the helper. Worth saying plainly rather than implying more than there is.

npx vitest run tests/api/plugins/github gives 7 files and 38 tests passing, and biome check --error-on-warnings is clean on the five files touched.

Summary by CodeRabbit

  • Bug Fixes

    • Improved handling of GitHub issue and pull request webhook metadata when values are missing, malformed, or invalid.
    • Webhook processing now continues across all applicable integrations instead of stopping after the first one.
    • Added contextual warning logs for invalid metadata without interrupting webhook processing.
    • Improved issue-edit synchronization when timestamp metadata is unavailable.
  • Tests

    • Added coverage for valid, missing, malformed, and non-object metadata scenarios.

Sequencing with #1518

#1518 adds webhooks/issue-reopened.ts, which does this same parse with its own inline try, written before this helper existed. The two do not touch the same files so they will not conflict, but if both land the repo ends up with five parses where four go through the helper and one does not.

Whichever merges second, I will send a one line follow-up moving issue-reopened onto the helper. Say the word if you would rather I fold it into one of them now instead.

…t 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.
Copilot AI lite review requested due to automatic review settings August 8, 2026 17:36

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@coderabbitai

coderabbitai Bot commented Aug 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 990416b0-e94e-4e66-b1a5-c6ef19b2a75e

📥 Commits

Reviewing files that changed from the base of the PR and between 32a4447 and cdf3ce2.

📒 Files selected for processing (2)
  • apps/api/src/plugins/github/utils/parse-link-metadata.ts
  • tests/api/plugins/github/utils/parse-link-metadata.test.ts

📝 Walkthrough

Walkthrough

GitHub webhook handlers now use parseLinkMetadata for external-link metadata. Invalid metadata returns an empty object and logs contextual warnings. Issue-edit synchronization uses typed metadata and epoch defaults. Issue-closed processing continues across integrations.

Changes

GitHub metadata handling

Layer / File(s) Summary
Shared metadata parser and coverage
apps/api/src/plugins/github/utils/parse-link-metadata.ts, tests/api/plugins/github/utils/parse-link-metadata.test.ts
Adds parseLinkMetadata for valid, absent, malformed, and non-object JSON. Invalid input logs link context and returns an empty object.
Webhook handler integration
apps/api/src/plugins/github/webhooks/issue-closed.ts, apps/api/src/plugins/github/webhooks/issue-edited.ts, apps/api/src/plugins/github/webhooks/pull-request-closed.ts
Webhook handlers use contextual metadata parsing. Issue-edited synchronization uses typed metadata and epoch defaults for missing timestamps. Issue-closed processing continues across integrations.

Estimated code review effort: 3 (Moderate) | ~20 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly summarizes the main change: preventing webhook delivery failure when link metadata cannot be parsed.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

PR Summary by Qodo

Fix GitHub webhooks: guard link metadata parsing and process all integrations

🐞 Bug fix 🧪 Tests 🕐 20-40 Minutes

Grey Divider

AI Description

• Prevent GitHub webhook deliveries from failing on malformed externalLink.metadata JSON.
• Ensure issue-closed processes all repo integrations instead of returning after first.
• Add unit tests covering valid, missing, and truncated metadata parsing.
Diagram

graph TD
  A["GitHub issue-closed handler"] --> P["parseLinkMetadata"] --> D[("externalLink.metadata")]
  B["GitHub issue-edited handler"] --> P --> D
  C["GitHub pull-request-closed handler"] --> P --> D
  P --> W["console.warn (parse failure)"]
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Centralize parsing in link-manager/domain model
  • ➕ Eliminates repeated parsing concerns across all webhook handlers
  • ➕ Encapsulates legacy/invalid row handling at the data boundary
  • ➖ Broader refactor with higher regression risk
  • ➖ Harder to preserve per-caller context (source) without API changes
2. Migrate metadata to a structured JSON column with validation
  • ➕ Prevents malformed/truncated JSON from persisting long-term
  • ➕ Allows DB-level constraints and safer querying
  • ➖ Requires a migration/backfill strategy and cross-version compatibility work
  • ➖ Does not by itself fix already-corrupt rows during migration window

Recommendation: Keep the PR’s approach: a small, shared guard helper with contextual warnings is the right near-term fix for delivery resiliency and minimizes churn across handlers. Consider moving parsing/validation into the link-manager layer later if more call sites emerge or non-webhook paths also consume metadata.

Files changed (5) +82 / -12

Bug fix (4) +44 / -12
parse-link-metadata.tsAdd guarded external link metadata JSON parser with contextual warning +25/-0

Add guarded external link metadata JSON parser with contextual warning

• Introduces parseLinkMetadata(raw, context) to safely JSON.parse() externalLink.metadata. Returns {} for missing/invalid values and emits a console.warn including externalLinkId and source when parsing fails.

apps/api/src/plugins/github/utils/parse-link-metadata.ts

issue-closed.tsUse guarded metadata parsing and process all integrations on issue close +5/-5

Use guarded metadata parsing and process all integrations on issue close

• Replaces bare JSON.parse of externalLink.metadata with parseLinkMetadata to prevent webhook delivery failure on malformed rows. Removes the early return so all matching integrations for the repo are processed.

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

issue-edited.tsUse guarded metadata parsing for issue-edited webhook updates +5/-3

Use guarded metadata parsing for issue-edited webhook updates

• Replaces bare JSON.parse of externalLink.metadata with parseLinkMetadata, adding per-link context for warnings while keeping existing update behavior intact.

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

pull-request-closed.tsGuard metadata parsing for PR close handling and related-PR scans +9/-4

Guard metadata parsing for PR close handling and related-PR scans

• Uses parseLinkMetadata when reading the current external link metadata and when scanning other PR links for open/closed state, preventing delivery failure if any row contains invalid JSON.

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

Tests (1) +38 / -0
parse-link-metadata.test.tsAdd unit tests for link metadata parsing robustness +38/-0

Add unit tests for link metadata parsing robustness

• Adds vitest coverage for parseLinkMetadata across valid JSON, absent values, and truncated JSON. Verifies a warning is emitted and the function returns {} rather than throwing.

tests/api/plugins/github/utils/parse-link-metadata.test.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (1)
tests/api/plugins/github/utils/parse-link-metadata.test.ts (1)

11-36: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use Arrange-Act-Assert in each test.

Separate setup, the parseLinkMetadata call, and assertions. This makes each tested behavior explicit.

As per coding guidelines, **/*.test.{ts,tsx,js} requires Arrange-Act-Assert test structure.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/api/plugins/github/utils/parse-link-metadata.test.ts` around lines 11 -
36, The tests in parse-link-metadata.test.ts currently combine setup,
parseLinkMetadata invocation, and assertions. Restructure each test into
explicit Arrange, Act, and Assert phases: prepare inputs and spies first, call
parseLinkMetadata once in the Act phase, then perform all expectations on the
captured result and warning calls.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/plugins/github/utils/parse-link-metadata.ts`:
- Around line 17-21: Update the warning in the GitHub link metadata parsing flow
to remove the raw metadata payload from the log. In the relevant parse-link
metadata function, log only externalLinkId, source, and a classified error
value, preserving the existing failure warning without exposing user-provided
content.
- Line 9: Update parseLinkMetadata() to return a typed metadata shape that
includes lastSync.title and lastSync.description, using a shared schema or a
generic type. Update each caller to provide or narrow that type before assigning
these fields, while preserving the parser’s existing behavior.
- Line 15: Update the JSON parsing logic in the metadata parser to return an
empty object unless the parsed value is a non-null, non-array object; preserve
valid object metadata unchanged. Add coverage for null, arrays, and primitive
JSON values.

---

Nitpick comments:
In `@tests/api/plugins/github/utils/parse-link-metadata.test.ts`:
- Around line 11-36: The tests in parse-link-metadata.test.ts currently combine
setup, parseLinkMetadata invocation, and assertions. Restructure each test into
explicit Arrange, Act, and Assert phases: prepare inputs and spies first, call
parseLinkMetadata once in the Act phase, then perform all expectations on the
captured result and warning calls.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 9a649ef0-84c1-4387-82b9-f3ca47285d55

📥 Commits

Reviewing files that changed from the base of the PR and between fa2198e and 8f96782.

📒 Files selected for processing (5)
  • apps/api/src/plugins/github/utils/parse-link-metadata.ts
  • apps/api/src/plugins/github/webhooks/issue-closed.ts
  • apps/api/src/plugins/github/webhooks/issue-edited.ts
  • apps/api/src/plugins/github/webhooks/pull-request-closed.ts
  • tests/api/plugins/github/utils/parse-link-metadata.test.ts

Comment thread apps/api/src/plugins/github/utils/parse-link-metadata.ts Outdated
Comment thread apps/api/src/plugins/github/utils/parse-link-metadata.ts Outdated
Comment thread apps/api/src/plugins/github/utils/parse-link-metadata.ts
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Aug 8, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (0) 📎 Requirement gaps (0) 🎨 UX issues (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)

Grey Divider


Action required

1. Null metadata still crashes ✓ Resolved 🐞 Bug ☼ Reliability
Description
parseLinkMetadata returns the raw JSON.parse value without verifying it is a non-null object, so
valid JSON like "null" results in a null return. GitHub webhook handlers then access properties on
the result (e.g., createdFrom/lastSync/state), which will throw and can still fail the webhook
delivery.
Code

apps/api/src/plugins/github/utils/parse-link-metadata.ts[R14-16]

+  try {
+    return JSON.parse(raw) as Record<string, unknown>;
+  } catch (error) {
Evidence
The helper currently returns JSON.parse(raw) directly; if raw is the valid JSON string "null",
the helper returns runtime null. Downstream handlers access properties on the returned value,
which throws when it is null, so webhook deliveries can still fail despite the new guard.

apps/api/src/plugins/github/utils/parse-link-metadata.ts[6-24]
apps/api/src/plugins/github/webhooks/issue-closed.ts[57-64]
apps/api/src/plugins/github/webhooks/issue-edited.ts[67-82]
apps/api/src/plugins/github/webhooks/pull-request-closed.ts[63-92]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`parseLinkMetadata()` can return non-object JSON values (e.g. `null`) because it returns `JSON.parse(raw)` directly. Several GitHub webhook handlers dereference properties on the returned value (e.g. `existingMetadata.createdFrom`, `metadata.lastSync?.title`, `metadata.state`), which will throw if the helper returns `null`, so deliveries can still fail.
### Issue Context
The helper’s intended contract is `Record<string, unknown>` but `JSON.parse()` returns `any` at runtime and may legitimately return `null` for the JSON string `"null"`.
### Fix Focus Areas
- apps/api/src/plugins/github/utils/parse-link-metadata.ts[14-23]
- tests/api/plugins/github/utils/parse-link-metadata.test.ts[10-37]
### Suggested fix
- Parse into `unknown`.
- If the parsed value is not a non-null plain object (at least `typeof parsed === "object" && parsed !== null` and ideally `!Array.isArray(parsed)`), log a warning and return `{}`.
- Add regression tests for inputs like `"null"` (and optionally `"[]"`) to ensure the helper returns `{}` and does not allow downstream handler crashes.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Tip of the day
💡 Did you know, you can turn on the rule miner and Qodo learns your standards from review history

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

Grey Divider

Qodo Logo

Comment thread apps/api/src/plugins/github/utils/parse-link-metadata.ts
`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.
@luantaraschi

Copy link
Copy Markdown
Contributor Author

The typecheck was right to fail and I had not run it before opening this, only vitest and biome. Fixed in 32a4447.

JSON.parse was returning any, so metadata.lastSync.title in issue-edited was never checked by anything. The helper hands back a typed value, which is what made tsc speak up. It takes the shape from the caller now, defaulting to the untyped record for the handlers that only spread the value forward, and the edit handler declares what it reads.

One behavioural note, since it is the kind of thing worth stating rather than hiding in a diff: a stamp with no timestamp used to reach new Date(undefined), an Invalid Date whose NaN failed the < 2000 recency comparison. It now reaches new Date(0), and a value that large fails the same comparison, so the branch taken is unchanged either way.

tsc --noEmit reports nothing in the files this PR touches. The @kaneo/email and @kaneo/permissions errors in that output are workspace packages that are not built in my checkout and are present on main too.

… 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.
@luantaraschi

Copy link
Copy Markdown
Contributor Author

Both bots found the same real gap, and it was in the part of this PR that was supposed to close it. Fixed in cdf3ce2a.

JSON.parse does not have to answer an object. The row null parses to null and "kaneo" parses to a string, and the helper handed either one back typed as Partial<T>. handleIssueClosed then reads existingMetadata.createdFrom off it and throws inside the webhook, which is the crash this PR exists to remove. The try/catch never sees it, because nothing threw during parsing. Arrays go out with them: nothing here writes one, and a handler that only spreads the value forward would turn its indices into keys.

The premise of the helper is a row written by an older version or truncated, so a row holding null belongs in the same bucket as a row that will not parse. It takes the same exit and logs.

I also took the point about the log. The warning no longer carries the row: issue-edited.ts stores formattedDescription in that metadata, so a truncated row can hold a task description synced from Kaneo. The externalLinkId is already there and is enough to find the row by, and there is a test that the description does not reach the log.

Checked the tests catch it rather than just pass. Reverting the guard alone leaves the three tests that were already here green and fails both new ones:

 ❯ tests/api/plugins/github/utils/parse-link-metadata.test.ts (5 tests | 2 failed)
     × keeps the row out of the warning
     × ignores a row that parses to something other than an object
AssertionError: expected null to deeply equal {}

npx vitest run tests/api/plugins/github is 40 passing across 7 files, and biome check is clean on both files. tsc --noEmit reports nothing in what this PR touches; the @kaneo/email and @kaneo/permissions errors are workspace packages that are not built in my checkout and are on main too.

@andrejsshell
andrejsshell merged commit b95c0fd into usekaneo:main Aug 15, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants