Skip to content
Open
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
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |

Expand Down
6 changes: 6 additions & 0 deletions examples/developer-tools/github-app/.env.example
Original file line number Diff line number Diff line change
@@ -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
108 changes: 108 additions & 0 deletions examples/developer-tools/github-app/README.md
Original file line number Diff line number Diff line change
@@ -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
```
9 changes: 9 additions & 0 deletions examples/developer-tools/github-app/examples/aport.yml
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions examples/developer-tools/github-app/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
114 changes: 114 additions & 0 deletions examples/developer-tools/github-app/src/aport.js
Original file line number Diff line number Diff line change
@@ -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,
};
45 changes: 45 additions & 0 deletions examples/developer-tools/github-app/src/checks.js
Original file line number Diff line number Diff line change
@@ -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,
};
Loading