Skip to content
Merged
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
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,10 @@ A self-hosted GitHub App version of `prnote-action`. On `pull_request.opened`, i
In GitHub, open **Settings → Developer settings → GitHub Apps → New GitHub App** and configure:

- Webhook URL: `https://your-host/api/github/webhooks`
- Marketplace webhook URL (Payload URL): `https://your-host/webhooks/marketplace`
- Webhook secret: a long random value also used as `GITHUB_WEBHOOK_SECRET`
- Repository permissions: **Contents: Read-only**, **Pull requests: Read and write**, **Issues: Read and write** only when `PRNOTE_COMMENT=true`
- Subscribe to: **Pull request**
- Subscribe to: **Pull request** on the main webhook URL, and **Marketplace purchase** on the marketplace webhook URL
- Where can this app be installed?: choose the scope you need

After creating it, note the App ID, generate a private key, and install the app on the desired repositories.
Expand Down Expand Up @@ -42,7 +43,7 @@ npm run build
npm start
```

Or use the included Dockerfile. The service exposes `GET /health` and expects GitHub webhooks at `POST /api/github/webhooks`. Run one instance for this MVP: webhook work is performed in-process after the HTTP acknowledgment, with no durable job queue or delivery deduplication. Commit a generated `package-lock.json` and switch deployment installs to `npm ci` when dependencies can be downloaded in your environment.
Or use the included Dockerfile. The service exposes `GET /health`, expects GitHub pull request webhooks at `POST /api/github/webhooks`, and GitHub Marketplace purchase events at `POST /webhooks/marketplace`. Run one instance for this MVP: webhook work is performed in-process after the HTTP acknowledgment, with no durable job queue or delivery deduplication. Commit a generated `package-lock.json` and switch deployment installs to `npm ci` when dependencies can be downloaded in your environment.

## Security notes

Expand Down
1 change: 1 addition & 0 deletions github-app-manifest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ default_permissions:
issues: write
default_events:
- pull_request
- marketplace_purchase
56 changes: 56 additions & 0 deletions src/marketplace.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type { MarketplacePurchasePayload } from "./types.js";

function describePurchase(payload: MarketplacePurchasePayload): string {
const purchase = payload.marketplace_purchase;
const account = purchase.account;
const plan = purchase.plan;
const billing = purchase.billing_cycle ?? "unknown";
const units =
purchase.unit_count === undefined ? "" : `, units=${purchase.unit_count}`;
const trial = purchase.on_free_trial ? ", free_trial=true" : "";
return `${account.type} ${account.login} on plan ${plan.name} (${plan.id}, ${billing}${units}${trial})`;
}

export async function processMarketplacePurchase(
payload: MarketplacePurchasePayload,
): Promise<void> {
const summary = describePurchase(payload);
console.info(`Marketplace ${payload.action}: ${summary}.`);

switch (payload.action) {
case "purchased":
if (payload.effective_date) {
console.info(`Marketplace purchase effective ${payload.effective_date}.`);
}
return;
case "cancelled":
if (payload.effective_date) {
console.info(`Marketplace cancellation effective ${payload.effective_date}.`);
}
return;
case "changed":
if (payload.previous_marketplace_purchase) {
const previous = payload.previous_marketplace_purchase;
console.info(
`Marketplace plan changed from ${previous.plan.name} (${previous.plan.id}) to ${payload.marketplace_purchase.plan.name} (${payload.marketplace_purchase.plan.id}).`,
);
}
if (payload.effective_date) {
console.info(`Marketplace change effective ${payload.effective_date}.`);
}
return;
case "pending_change":
if (payload.previous_marketplace_purchase) {
const previous = payload.previous_marketplace_purchase;
console.info(
`Marketplace pending change from ${previous.plan.name} (${previous.plan.id}) to ${payload.marketplace_purchase.plan.name} (${payload.marketplace_purchase.plan.id}).`,
);
}
return;
case "pending_change_cancelled":
console.info("Marketplace pending plan change cancelled.");
return;
default:
console.warn(`Unhandled marketplace action: ${payload.action}.`);
}
}
142 changes: 93 additions & 49 deletions src/server.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,17 @@
import { createServer } from "node:http";
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { loadEnvFile } from "node:process";
import { verifyWebhook } from "./auth.js";
import { readConfig } from "./config.js";
import { processMarketplacePurchase } from "./marketplace.js";
import { processPullRequest } from "./processor.js";
import type { PullRequestOpenedPayload } from "./types.js";
import type {
MarketplacePurchasePayload,
PullRequestOpenedPayload,
} from "./types.js";
import {
ackWebhook,
parseGitHubWebhook,
parseWebhookJson,
} from "./webhook.js";

try {
loadEnvFile();
Expand All @@ -13,66 +21,102 @@ try {
}

const config = readConfig();
const maxBodyBytes = 25 * 1024 * 1024;

const server = createServer(async (request, response) => {
if (request.method === "GET" && request.url === "/health") {
response
.writeHead(200, { "Content-Type": "application/json" })
.end(JSON.stringify({ ok: true }));
return;
}
if (request.method !== "POST" || request.url !== "/api/github/webhooks") {
response.writeHead(404).end("Not found");
return;
}
async function handlePullRequestWebhook(
request: IncomingMessage,
response: ServerResponse,
): Promise<void> {
const webhook = await parseGitHubWebhook(
request,
response,
config.webhookSecret,
);
if (!webhook) return;

const { body, event, delivery } = webhook;
const payload = parseWebhookJson<PullRequestOpenedPayload>(
body,
delivery,
response,
);
if (!payload) return;

const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of request) {
const buffer = Buffer.from(chunk as Uint8Array);
size += buffer.length;
if (size > maxBodyBytes) {
response.writeHead(413).end("Payload too large");
request.destroy();
return;
}
chunks.push(buffer);
console.info(
`Received ${String(event)} webhook ${delivery ?? "unknown"} (action: ${payload.action ?? "none"}).`,
);
ackWebhook(response, delivery);
if (event !== "pull_request") return;

try {
await processPullRequest(payload, config);
console.info(`Processed webhook ${delivery ?? "unknown"}.`);
} catch (error) {
console.error(`Webhook ${delivery ?? "unknown"} failed.`, error);
}
const body = Buffer.concat(chunks);
const signature = request.headers["x-hub-signature-256"] as
string | undefined;
if (!verifyWebhook(body, signature, config.webhookSecret)) {
response.writeHead(401).end("Invalid signature");
}

async function handleMarketplaceWebhook(
request: IncomingMessage,
response: ServerResponse,
): Promise<void> {
const webhook = await parseGitHubWebhook(
request,
response,
config.webhookSecret,
);
if (!webhook) return;

const { body, event, delivery } = webhook;
const payload = parseWebhookJson<MarketplacePurchasePayload>(
body,
delivery,
response,
);
if (!payload) return;

console.info(
`Received ${String(event)} webhook ${delivery ?? "unknown"} (action: ${payload.action ?? "none"}).`,
);
ackWebhook(response, delivery);
if (event !== "marketplace_purchase") {
console.warn(
`Ignoring ${String(event)} webhook ${delivery ?? "unknown"} on /webhooks/marketplace.`,
);
return;
}

const event = request.headers["x-github-event"];
const delivery = request.headers["x-github-delivery"];
let payload: PullRequestOpenedPayload | undefined;
try {
payload = JSON.parse(body.toString("utf8")) as PullRequestOpenedPayload;
await processMarketplacePurchase(payload);
console.info(`Processed marketplace webhook ${delivery ?? "unknown"}.`);
} catch (error) {
console.error(
`Webhook ${delivery ?? "unknown"} contains invalid JSON.`,
`Marketplace webhook ${delivery ?? "unknown"} failed.`,
error,
);
response.writeHead(400).end("Invalid JSON");
}
}

const server = createServer(async (request, response) => {
const path = request.url?.split("?")[0];

if (request.method === "GET" && path === "/health") {
response
.writeHead(200, { "Content-Type": "application/json" })
.end(JSON.stringify({ ok: true }));
return;
}
console.info(
`Received ${String(event)} webhook ${delivery ?? "unknown"} (action: ${payload.action ?? "none"}).`,
);
response
.writeHead(202, { "Content-Type": "application/json" })
.end(JSON.stringify({ accepted: true, delivery }));
if (event !== "pull_request") return;
try {
await processPullRequest(payload, config);
console.info(`Processed webhook ${delivery ?? "unknown"}.`);
} catch (error) {
console.error(`Webhook ${delivery ?? "unknown"} failed.`, error);

if (request.method === "POST" && path === "/api/github/webhooks") {
await handlePullRequestWebhook(request, response);
return;
}

if (request.method === "POST" && path === "/webhooks/marketplace") {
await handleMarketplaceWebhook(request, response);
return;
}

response.writeHead(404).end("Not found");
});

server.listen(config.port, () =>
Expand Down
44 changes: 44 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,47 @@ export interface PullRequestOpenedPayload {
head: { ref: string };
};
}

export type MarketplacePurchaseAction =
| "purchased"
| "cancelled"
| "changed"
| "pending_change"
| "pending_change_cancelled";

export interface MarketplaceAccount {
id: number;
type: string;
login: string;
organization_billing_email?: string;
}

export interface MarketplacePlan {
id: number;
name: string;
description?: string;
monthly_price_in_cents?: number;
yearly_price_in_cents?: number;
price_model?: string;
has_free_trial?: boolean;
unit_name?: string | null;
bullets?: string[];
}

export interface MarketplacePurchase {
account: MarketplaceAccount;
billing_cycle?: string;
unit_count?: number;
on_free_trial?: boolean;
free_trial_ends_on?: string | null;
next_billing_date?: string | null;
plan: MarketplacePlan;
}

export interface MarketplacePurchasePayload {
action: MarketplacePurchaseAction | string;
effective_date?: string;
marketplace_purchase: MarketplacePurchase;
previous_marketplace_purchase?: MarketplacePurchase;
sender?: { login: string; id: number };
}
78 changes: 78 additions & 0 deletions src/webhook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { verifyWebhook } from "./auth.js";

export const maxBodyBytes = 25 * 1024 * 1024;

export interface GitHubWebhookContext {
body: Buffer;
event: string | undefined;
delivery: string | undefined;
}

export async function readRequestBody(
request: IncomingMessage,
response: ServerResponse,
): Promise<Buffer | undefined> {
const chunks: Buffer[] = [];
let size = 0;
for await (const chunk of request) {
const buffer = Buffer.from(chunk as Uint8Array);
size += buffer.length;
if (size > maxBodyBytes) {
response.writeHead(413).end("Payload too large");
request.destroy();
return undefined;
}
chunks.push(buffer);
}
return Buffer.concat(chunks);
}

export async function parseGitHubWebhook(
request: IncomingMessage,
response: ServerResponse,
webhookSecret: string,
): Promise<GitHubWebhookContext | undefined> {
const body = await readRequestBody(request, response);
if (!body) return undefined;

const signature = request.headers["x-hub-signature-256"] as
| string
| undefined;
if (!verifyWebhook(body, signature, webhookSecret)) {
response.writeHead(401).end("Invalid signature");
return undefined;
}

return {
body,
event: request.headers["x-github-event"] as string | undefined,
delivery: request.headers["x-github-delivery"] as string | undefined,
};
}

export function ackWebhook(
response: ServerResponse,
delivery: string | undefined,
): void {
response
.writeHead(202, { "Content-Type": "application/json" })
.end(JSON.stringify({ accepted: true, delivery }));
}

export function parseWebhookJson<T>(
body: Buffer,
delivery: string | undefined,
response: ServerResponse,
): T | undefined {
try {
return JSON.parse(body.toString("utf8")) as T;
} catch (error) {
console.error(
`Webhook ${delivery ?? "unknown"} contains invalid JSON.`,
error,
);
response.writeHead(400).end("Invalid JSON");
return undefined;
}
}
Loading