← All posts
March 30, 20266 min readFeedbackIQ team

Closing the loop: merged PRs → changelog → upvoter emails → RSS

GitHub webhook verification, LLM-written changelog drafts a human reviews, Resend for fan-out, and a first-class Atom feed because RSS is deeply uncool and still works.

Closing the loop: merged PRs → changelog → upvoter emails → RSS

A pull request merging is the most satisfying moment in the whole pipeline, and for months we were wasting it. The PR would land, the feedback row would get marked shipped, and that was it. The user who filed the original bug never knew. The upvoters who voted for the feature never knew. The rest of the world never saw a changelog.

Closing that loop — merged PR → changelog entry → upvoter emails → RSS feed — is this post. It’s most of what turns FeedbackIQ from a ticket generator into a product-marketing surface.

The GitHub webhook

Every repo the customer connects gets a GitHub App webhook listening for pull_request.closed events. When the event arrives with merged: true, we look up the feedback row by the stored githubPrNumber and transition it to shipped.

export async function POST(req: Request) {
  const event = req.headers.get("x-github-event");
  const body = await req.json();

  if (event === "pull_request" && body.action === "closed" && body.pull_request.merged) {
    const pr = body.pull_request;
    await handleMerged(pr.number, pr.base.repo.full_name, pr.merge_commit_sha);
  }
  return new Response("ok");
}

Signature verification is non-negotiable. The webhook secret is stored per installation, and we createHmac('sha256') the raw body and compare against the x-hub-signature-256header in constant time before we touch anything.

Changelog entry from the PR diff

A merged PR gives us a title, a body, and a diff. That’s already more than most changelogs have. We run the PR through a small LLM pass that converts the technical PR description into user-facing copy: a one-line headline, a short paragraph of what changed and why, and a category (feature/fix/improvement).

The model never sees the diff — only the PR title, body, and the linked feedback’s original text. That’s enough context; sending the diff adds tokens without improving output.

The generated entry lands in the dashboard as a draft. The user reviews it, tweaks if needed, and hits Publish. Nothing ships user-facing until a human confirms — we never want a customer’s public changelog to read like a commit log from Mars.

Resend for the emails

When the changelog entry is published, every upvoter of the underlying feedback (and its confirmed duplicates — yes, the dedupe layer matters here too) gets an email saying “the thing you voted for just shipped.” Simple template, one CTA button linking to the changelog page.

Resend is the least-interesting integration on paper and the one that made this feature cheap to ship. resend.emails.send returns a promise; we fan out with Promise.allSettled and let the failures (bounces, unsubscribes) settle into a webhook that flips the voter’s canEmail flag.

const results = await Promise.allSettled(
  upvoters.map((u) =>
    resend.emails.send({
      from: "FeedbackIQ <changelog@feedbackiq.app>",
      to: u.email,
      subject: `Shipped: ${entry.title}`,
      react: <ChangelogEmail entry={entry} unsubscribeUrl={u.unsubUrl} />,
    })
  )
);

RSS feed as a first-class surface

Every project gets a public changelog at /changelog/[slug] and a matching Atom feed at /changelog/[slug]/feed.xml. RSS is deeply uncool and also the single most-reliable way to let power users keep up with product changes without joining another email list. Generating it in Next.js is 20 lines:

export const dynamic = "force-dynamic";

export async function GET(_: Request, { params }: Props) {
  const entries = await prisma.changelogEntry.findMany({
    where: { project: { slug: params.slug }, publishedAt: { not: null } },
    orderBy: { publishedAt: "desc" },
    take: 50,
  });
  const xml = buildAtomXml(entries);
  return new Response(xml, {
    headers: { "Content-Type": "application/atom+xml" },
  });
}

The psychological win

The whole loop — from widget submission to merged PR to “your vote shipped” email — compresses a cycle that used to take weeks into one that can run in a day. That compression is the actual product. The AI is incidental; the loop is the thing.

Next up: the liquid-glass UI pass. Snake borders, backdrop-filter, conic gradients, and a surprisingly-deep rabbit hole of CSS that made the dashboard feel like 2026 instead of 2018.

Try FeedbackIQ

Drop a widget on your site, ship PRs from feedback

Claude reads the report, writes the fix, opens the PR on your repo. Dedupe with pgvector so the backlog doesn’t drown in duplicates.

Start for free