From 965012c66a870efd26f4c6da9404bf205ef22874 Mon Sep 17 00:00:00 2001 From: jamilahmadzai Date: Tue, 12 May 2026 10:55:04 +0200 Subject: [PATCH] feat: add APort GitHub PR verification app --- README.md | 1 + .../developer-tools/github-app/.env.example | 6 + examples/developer-tools/github-app/README.md | 108 +++++++++++++++++ .../github-app/examples/aport.yml | 9 ++ .../developer-tools/github-app/package.json | 28 +++++ .../developer-tools/github-app/src/aport.js | 114 ++++++++++++++++++ .../developer-tools/github-app/src/checks.js | 45 +++++++ .../developer-tools/github-app/src/config.js | 100 +++++++++++++++ .../developer-tools/github-app/src/index.js | 65 ++++++++++ .../github-app/tests/aport.test.js | 79 ++++++++++++ .../github-app/tests/config.test.js | 110 +++++++++++++++++ 11 files changed, 665 insertions(+) create mode 100644 examples/developer-tools/github-app/.env.example create mode 100644 examples/developer-tools/github-app/README.md create mode 100644 examples/developer-tools/github-app/examples/aport.yml create mode 100644 examples/developer-tools/github-app/package.json create mode 100644 examples/developer-tools/github-app/src/aport.js create mode 100644 examples/developer-tools/github-app/src/checks.js create mode 100644 examples/developer-tools/github-app/src/config.js create mode 100644 examples/developer-tools/github-app/src/index.js create mode 100644 examples/developer-tools/github-app/tests/aport.test.js create mode 100644 examples/developer-tools/github-app/tests/config.test.js diff --git a/README.md b/README.md index 77cbea9..8a58ba8 100644 --- a/README.md +++ b/README.md @@ -226,6 +226,7 @@ Reduce APort integration time from hours to minutes. | Tool | Description | Status | Maintainer | |------|-------------|--------|------------| | [APort CLI](tools/cli/) | `npx create-aport-integration` scaffolding tool | ✅ Active | Community | +| [GitHub PR Verification App](examples/developer-tools/github-app/) | Enforce APort policy checks on pull requests | ✅ Active | Community | | [VS Code Extension](tools/vscode-extension/) | Policy development with IntelliSense | 🚧 In Progress | Community | | [Postman Collection](tools/postman-collection/) | Complete API testing collection | ✅ Active | Community | diff --git a/examples/developer-tools/github-app/.env.example b/examples/developer-tools/github-app/.env.example new file mode 100644 index 0000000..74652d3 --- /dev/null +++ b/examples/developer-tools/github-app/.env.example @@ -0,0 +1,6 @@ +APP_ID=123456 +PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" +WEBHOOK_SECRET=replace_with_your_webhook_secret +APORT_API_KEY=your_aport_api_key +APORT_BASE_URL=https://aport.io +PORT=3000 diff --git a/examples/developer-tools/github-app/README.md b/examples/developer-tools/github-app/README.md new file mode 100644 index 0000000..6e613d3 --- /dev/null +++ b/examples/developer-tools/github-app/README.md @@ -0,0 +1,108 @@ +# APort GitHub PR Verifier + +GitHub App example that verifies pull requests with APort before merge. + +The app listens for pull request events, resolves an APort agent id, sends the pull request context to APort, and writes a GitHub check run back to the PR. + +## Features + +- Probot-based GitHub App entrypoint. +- Pull request checks for opened, reopened, ready-for-review, and synchronized PRs. +- Configurable APort policy pack. +- Configurable agent id source: PR author login, static id, author map, or branch name. +- Optional changed-file and label context. +- Fail-closed or neutral-on-deny mode. +- Unit tests for policy normalization, config handling, check output, and APort API calls. + +## Setup + +```bash +npm install +cp .env.example .env +npm start +``` + +Create a GitHub App and configure: + +| Setting | Value | +| --- | --- | +| Webhook URL | Your deployed app URL | +| Webhook secret | Same value as `WEBHOOK_SECRET` | +| Repository permissions | Checks: read/write, Contents: read, Pull requests: read, Metadata: read | +| Subscribe to events | Pull request | + +Install the GitHub App on the repositories where PR checks should run. + +## Repository Configuration + +Add `.github/aport.yml` to a protected repository: + +```yaml +enabled: true +checkName: APort policy check +policy: code.repository.merge.v1 +failOnError: true +agent: + source: author-login +context: + includeFiles: true + includeLabels: true +``` + +Supported `agent.source` values: + +| Source | Behavior | +| --- | --- | +| `author-login` | Uses the GitHub PR author login as the agent id. | +| `static` | Uses `agent.staticAgentId`. | +| `author-map` | Looks up `agent.authorMap[githubLogin]`. | +| `branch` | Uses the PR head branch name. | + +Example author map: + +```yaml +agent: + source: author-map + authorMap: + octocat: ap_a2d10232c6534523812423eec8a1425c +``` + +## Environment Variables + +```bash +APP_ID=123456 +PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\n...\n-----END RSA PRIVATE KEY-----" +WEBHOOK_SECRET=replace_with_your_webhook_secret +APORT_API_KEY=your_aport_api_key +APORT_BASE_URL=https://aport.io +PORT=3000 +``` + +`APORT_API_KEY` is optional for APort endpoints that support public verification, but production deployments should set it. + +## Local Development + +Use a webhook tunnel such as `smee.io`, `ngrok`, or a hosted deployment URL: + +```bash +npm start +``` + +Then open or synchronize a pull request in an installed repository. The app creates a check run named `APort policy check`. + +## Testing + +```bash +npm test +npm run check +``` + +## Files + +```text +src/index.js Probot webhook registration and PR verification flow +src/aport.js APort API client and decision normalization +src/config.js .github/aport.yml defaults, agent resolution, PR context +src/checks.js GitHub check-run conclusion and output helpers +examples/aport.yml Example repository configuration +``` diff --git a/examples/developer-tools/github-app/examples/aport.yml b/examples/developer-tools/github-app/examples/aport.yml new file mode 100644 index 0000000..6d7e765 --- /dev/null +++ b/examples/developer-tools/github-app/examples/aport.yml @@ -0,0 +1,9 @@ +enabled: true +checkName: APort policy check +policy: code.repository.merge.v1 +failOnError: true +agent: + source: author-login +context: + includeFiles: true + includeLabels: true diff --git a/examples/developer-tools/github-app/package.json b/examples/developer-tools/github-app/package.json new file mode 100644 index 0000000..899788b --- /dev/null +++ b/examples/developer-tools/github-app/package.json @@ -0,0 +1,28 @@ +{ + "name": "aport-github-pr-verifier", + "version": "0.1.0", + "private": true, + "description": "GitHub App example that verifies pull requests with APort policy checks", + "main": "src/index.js", + "scripts": { + "start": "probot run ./src/index.js", + "test": "node --test", + "check": "node --check src/index.js && node --check src/aport.js && node --check src/checks.js && node --check src/config.js" + }, + "keywords": [ + "aport", + "github-app", + "probot", + "pull-request", + "policy-verification" + ], + "author": "APort Community", + "license": "MIT", + "dependencies": { + "dotenv": "^16.4.5", + "probot": "^13.4.0" + }, + "engines": { + "node": ">=18.0.0" + } +} diff --git a/examples/developer-tools/github-app/src/aport.js b/examples/developer-tools/github-app/src/aport.js new file mode 100644 index 0000000..9bdd7a4 --- /dev/null +++ b/examples/developer-tools/github-app/src/aport.js @@ -0,0 +1,114 @@ +const DEFAULT_BASE_URL = "https://aport.io"; + +function normalizeDecision(result) { + const decision = result && (result.decision || (result.data && result.data.decision)); + + if (decision && Object.prototype.hasOwnProperty.call(decision, "allow")) { + return { + allowed: Boolean(decision.allow), + reason: formatReasons(decision.reasons) || decision.reason, + raw: result, + }; + } + + return { + allowed: Boolean(result && (result.verified || result.allowed || result.allow)), + reason: result && (result.reason || result.message), + raw: result, + }; +} + +function formatReasons(reasons) { + if (!Array.isArray(reasons)) { + return undefined; + } + + return reasons + .map((reason) => { + if (!reason) { + return undefined; + } + + if (typeof reason === "string") { + return reason; + } + + return reason.message || reason.code; + }) + .filter(Boolean) + .join(", "); +} + +async function parseResponse(response) { + const text = await response.text(); + + if (!text) { + return {}; + } + + try { + return JSON.parse(text); + } catch (error) { + return { message: text }; + } +} + +async function verifyAportPolicy({ + agentId, + policy, + context, + apiKey = process.env.APORT_API_KEY, + baseUrl = process.env.APORT_BASE_URL || DEFAULT_BASE_URL, + fetchImpl = globalThis.fetch, +}) { + if (!agentId) { + return { + allowed: false, + reason: "Missing APort agent id", + raw: null, + }; + } + + if (!fetchImpl) { + throw new Error("A fetch implementation is required. Use Node.js 18 or newer."); + } + + const headers = { "Content-Type": "application/json" }; + + if (apiKey) { + headers.Authorization = "Bearer " + apiKey; + } + + const response = await fetchImpl( + baseUrl.replace(/\/$/, "") + "/api/verify/policy/" + encodeURIComponent(policy), + { + method: "POST", + headers, + body: JSON.stringify({ + context: { + agent_id: agentId, + policy_id: policy, + context, + }, + }), + } + ); + + const body = await parseResponse(response); + + if (!response.ok) { + return { + allowed: false, + reason: body.message || "APort request failed with status " + response.status, + raw: body, + }; + } + + return normalizeDecision(body); +} + +module.exports = { + formatReasons, + normalizeDecision, + verifyAportPolicy, +}; diff --git a/examples/developer-tools/github-app/src/checks.js b/examples/developer-tools/github-app/src/checks.js new file mode 100644 index 0000000..25921ed --- /dev/null +++ b/examples/developer-tools/github-app/src/checks.js @@ -0,0 +1,45 @@ +function conclusionForVerification(verification, failOnError) { + if (verification.allowed) { + return "success"; + } + + return failOnError ? "failure" : "neutral"; +} + +function outputForVerification({ verification, agentId, policy }) { + if (verification.allowed) { + return { + title: "APort policy approved", + summary: "Agent `" + agentId + "` is allowed by policy `" + policy + "`.", + }; + } + + return { + title: "APort policy denied", + summary: + "Agent `" + + (agentId || "unknown") + + "` was not allowed by policy `" + + policy + + "`.\n\nReason: " + + (verification.reason || "No reason returned by APort."), + }; +} + +async function createCompletedCheckRun({ context, pullRequest, checkName, conclusion, output }) { + return context.octokit.checks.create( + context.repo({ + name: checkName, + head_sha: pullRequest.head.sha, + status: "completed", + conclusion, + output, + }) + ); +} + +module.exports = { + conclusionForVerification, + createCompletedCheckRun, + outputForVerification, +}; diff --git a/examples/developer-tools/github-app/src/config.js b/examples/developer-tools/github-app/src/config.js new file mode 100644 index 0000000..f765096 --- /dev/null +++ b/examples/developer-tools/github-app/src/config.js @@ -0,0 +1,100 @@ +const DEFAULT_CONFIG = { + enabled: true, + checkName: "APort policy check", + policy: "code.repository.merge.v1", + failOnError: true, + agent: { + source: "author-login", + staticAgentId: "", + authorMap: {}, + }, + context: { + includeFiles: true, + includeLabels: true, + }, +}; + +function mergeConfig(config = {}) { + return { + ...DEFAULT_CONFIG, + ...config, + agent: { + ...DEFAULT_CONFIG.agent, + ...(config.agent || {}), + }, + context: { + ...DEFAULT_CONFIG.context, + ...(config.context || {}), + }, + }; +} + +function resolveAgentId(config, pullRequest) { + const source = config.agent.source; + + if (source === "static") { + return config.agent.staticAgentId || undefined; + } + + if (source === "author-map") { + return config.agent.authorMap[pullRequest.user.login]; + } + + if (source === "branch") { + return pullRequest.head.ref; + } + + return pullRequest.user.login; +} + +function labelsForPullRequest(pullRequest) { + return (pullRequest.labels || []).map((label) => label.name); +} + +async function filesForPullRequest(context, pullRequest, includeFiles) { + if (!includeFiles) { + return []; + } + + const response = await context.octokit.pulls.listFiles( + context.repo({ + pull_number: pullRequest.number, + per_page: 100, + }) + ); + + return response.data.map((file) => ({ + filename: file.filename, + additions: file.additions, + deletions: file.deletions, + changes: file.changes, + status: file.status, + })); +} + +async function buildVerificationContext(context, config, pullRequest) { + const labels = config.context.includeLabels ? labelsForPullRequest(pullRequest) : []; + const files = await filesForPullRequest(context, pullRequest, config.context.includeFiles); + + return { + repository: context.payload.repository.full_name, + pullRequest: { + number: pullRequest.number, + title: pullRequest.title, + author: pullRequest.user.login, + base: pullRequest.base.ref, + head: pullRequest.head.ref, + draft: pullRequest.draft, + labels, + files, + }, + }; +} + +module.exports = { + DEFAULT_CONFIG, + buildVerificationContext, + labelsForPullRequest, + mergeConfig, + resolveAgentId, +}; diff --git a/examples/developer-tools/github-app/src/index.js b/examples/developer-tools/github-app/src/index.js new file mode 100644 index 0000000..c9ea408 --- /dev/null +++ b/examples/developer-tools/github-app/src/index.js @@ -0,0 +1,65 @@ +require("dotenv").config(); + +const { verifyAportPolicy } = require("./aport"); +const { + buildVerificationContext, + mergeConfig, + resolveAgentId, +} = require("./config"); +const { + conclusionForVerification, + createCompletedCheckRun, + outputForVerification, +} = require("./checks"); + +async function loadConfig(context) { + const repoConfig = await context.config("aport.yml", {}); + return mergeConfig(repoConfig); +} + +async function verifyPullRequest(context) { + const pullRequest = context.payload.pull_request; + const config = await loadConfig(context); + + if (!config.enabled) { + context.log.info("APort GitHub App disabled by .github/aport.yml"); + return; + } + + const agentId = resolveAgentId(config, pullRequest); + const verificationContext = await buildVerificationContext(context, config, pullRequest); + const verification = await verifyAportPolicy({ + agentId, + policy: config.policy, + context: verificationContext, + }); + const conclusion = conclusionForVerification(verification, config.failOnError); + const output = outputForVerification({ + verification, + agentId, + policy: config.policy, + }); + + await createCompletedCheckRun({ + context, + pullRequest, + checkName: config.checkName, + conclusion, + output, + }); +} + +module.exports = (app) => { + app.on( + [ + "pull_request.opened", + "pull_request.reopened", + "pull_request.ready_for_review", + "pull_request.synchronize", + ], + verifyPullRequest + ); +}; + +module.exports.loadConfig = loadConfig; +module.exports.verifyPullRequest = verifyPullRequest; diff --git a/examples/developer-tools/github-app/tests/aport.test.js b/examples/developer-tools/github-app/tests/aport.test.js new file mode 100644 index 0000000..09d81f0 --- /dev/null +++ b/examples/developer-tools/github-app/tests/aport.test.js @@ -0,0 +1,79 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { + formatReasons, + normalizeDecision, + verifyAportPolicy, +} = require("../src/aport"); + +test("formats decision reasons", () => { + assert.equal( + formatReasons([{ message: "Missing assurance" }, { code: "limit_exceeded" }]), + "Missing assurance, limit_exceeded" + ); +}); + +test("normalizes allow decisions", () => { + assert.deepEqual(normalizeDecision({ decision: { allow: true } }).allowed, true); + assert.deepEqual(normalizeDecision({ verified: true }).allowed, true); +}); + +test("normalizes deny decisions with reason text", () => { + const result = normalizeDecision({ + data: { + decision: { + allow: false, + reasons: [{ message: "Policy denied" }], + }, + }, + }); + + assert.equal(result.allowed, false); + assert.equal(result.reason, "Policy denied"); +}); + +test("verifyAportPolicy fails when agent id is missing", async () => { + const result = await verifyAportPolicy({ + agentId: "", + policy: "code.repository.merge.v1", + context: {}, + }); + + assert.equal(result.allowed, false); + assert.equal(result.reason, "Missing APort agent id"); +}); + +test("verifyAportPolicy posts expected payload", async () => { + const calls = []; + const fetchImpl = async (url, options) => { + calls.push({ url, options }); + + return { + ok: true, + async text() { + return JSON.stringify({ decision: { allow: true } }); + }, + }; + }; + + const result = await verifyAportPolicy({ + agentId: "octocat", + policy: "code.repository.merge.v1", + context: { repository: "owner/repo" }, + apiKey: "test-key", + baseUrl: "https://aport.example", + fetchImpl, + }); + + assert.equal(result.allowed, true); + assert.equal(calls[0].url, "https://aport.example/api/verify/policy/code.repository.merge.v1"); + assert.equal(calls[0].options.headers.Authorization, "Bearer test-key"); + assert.deepEqual(JSON.parse(calls[0].options.body), { + context: { + agent_id: "octocat", + policy_id: "code.repository.merge.v1", + context: { repository: "owner/repo" }, + }, + }); +}); diff --git a/examples/developer-tools/github-app/tests/config.test.js b/examples/developer-tools/github-app/tests/config.test.js new file mode 100644 index 0000000..c24e66a --- /dev/null +++ b/examples/developer-tools/github-app/tests/config.test.js @@ -0,0 +1,110 @@ +const assert = require("node:assert/strict"); +const test = require("node:test"); + +const { + buildVerificationContext, + labelsForPullRequest, + mergeConfig, + resolveAgentId, +} = require("../src/config"); +const { + conclusionForVerification, + outputForVerification, +} = require("../src/checks"); + +const pullRequest = { + number: 42, + title: "Add feature", + draft: false, + user: { login: "octocat" }, + base: { ref: "main" }, + head: { ref: "feature/aport", sha: "abc123" }, + labels: [{ name: "security" }, { name: "needs-review" }], +}; + +test("mergeConfig keeps defaults and overrides nested values", () => { + const config = mergeConfig({ + policy: "custom.policy.v1", + agent: { + source: "static", + staticAgentId: "ap_static", + }, + }); + + assert.equal(config.policy, "custom.policy.v1"); + assert.equal(config.agent.source, "static"); + assert.equal(config.agent.staticAgentId, "ap_static"); + assert.equal(config.context.includeFiles, true); +}); + +test("resolveAgentId supports author, static, map, and branch sources", () => { + assert.equal(resolveAgentId(mergeConfig(), pullRequest), "octocat"); + assert.equal(resolveAgentId(mergeConfig({ agent: { source: "static", staticAgentId: "ap_123" } }), pullRequest), "ap_123"); + assert.equal( + resolveAgentId(mergeConfig({ agent: { source: "author-map", authorMap: { octocat: "ap_mapped" } } }), pullRequest), + "ap_mapped" + ); + assert.equal(resolveAgentId(mergeConfig({ agent: { source: "branch" } }), pullRequest), "feature/aport"); +}); + +test("labelsForPullRequest returns label names", () => { + assert.deepEqual(labelsForPullRequest(pullRequest), ["security", "needs-review"]); +}); + +test("buildVerificationContext includes PR metadata and changed files", async () => { + const context = { + payload: { + repository: { full_name: "owner/repo" }, + }, + repo(params) { + return { owner: "owner", repo: "repo", ...params }; + }, + octokit: { + pulls: { + async listFiles(params) { + assert.equal(params.pull_number, 42); + return { + data: [ + { + filename: "src/index.js", + additions: 10, + deletions: 2, + changes: 12, + status: "modified", + }, + ], + }; + }, + }, + }, + }; + + const verificationContext = await buildVerificationContext(context, mergeConfig(), pullRequest); + + assert.equal(verificationContext.repository, "owner/repo"); + assert.deepEqual(verificationContext.pullRequest.labels, ["security", "needs-review"]); + assert.deepEqual(verificationContext.pullRequest.files, [ + { + filename: "src/index.js", + additions: 10, + deletions: 2, + changes: 12, + status: "modified", + }, + ]); +}); + +test("check helpers produce expected conclusions and output", () => { + assert.equal(conclusionForVerification({ allowed: true }, true), "success"); + assert.equal(conclusionForVerification({ allowed: false }, true), "failure"); + assert.equal(conclusionForVerification({ allowed: false }, false), "neutral"); + + assert.match( + outputForVerification({ + verification: { allowed: false, reason: "Denied" }, + agentId: "octocat", + policy: "code.repository.merge.v1", + }).summary, + /Denied/ + ); +});