Skip to content

Commit 46e893e

Browse files
authored
Add GitHub issues integration (#1079)
* Add bootstraping code * Handle GitHub app installation flow * Handle GitHub installation deleted webhook events * Start implementing ingestion logic * Handle initial issues ingestion on integration installation * Fix comment and add concurrency for batch ingestion * Improve typing for GitHub webhook events * Fix dispatch logic installation deleted event * Fix PKCS8 error when signing token * Add handling of event when repos get added to installations * Add handling of issue closed event * Fix format * Add env var to turbo.json * Add env var to workflows * review * Add staging/prod secrets after creating GitHub apps * review: remove GITHUB_APP_NAME reference from workflow * Add changeset * Fix webhook error after further tests
1 parent f72a151 commit 46e893e

File tree

19 files changed

+1562
-13
lines changed

19 files changed

+1562
-13
lines changed

.changeset/new-facts-pay.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@gitbook/integration-github-issues': patch
3+
---
4+
5+
Add GitHub issues integration

bun.lock

Lines changed: 146 additions & 13 deletions
Large diffs are not rendered by default.
6.24 KB
Loading
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
name: github-issues
2+
title: GitHub Issues
3+
icon: ./assets/icon.png
4+
description: Automatically sync GitHub issues to docs updates in GitBook.
5+
visibility: public
6+
script: ./src/index.ts
7+
summary: |
8+
# Overview
9+
10+
Automatically get AI-suggested change requests for your docs based on feedback from your GitHub Issues.
11+
scopes:
12+
- conversations:ingest
13+
organization: gitbook
14+
configurations:
15+
account:
16+
componentId: config
17+
target: organization
18+
envs:
19+
dev-steeve:
20+
organization: idE5kUnGGjoPGcbu3FZJ
21+
secrets:
22+
GITHUB_APP_ID: ${{ "op://gitbook-integrations/githubIssuesDevSteeve/GITHUB_APP_ID" }}
23+
GITHUB_APP_NAME: ${{ "op://gitbook-integrations/githubIssuesDevSteeve/GITHUB_APP_NAME" }}
24+
GITHUB_PRIVATE_KEY: ${{ "op://gitbook-integrations/githubIssuesDevSteeve/GITHUB_PRIVATE_KEY" }}
25+
CLIENT_ID: ${{ "op://gitbook-integrations/githubIssuesDevSteeve/CLIENT_ID" }}
26+
CLIENT_SECRET: ${{ "op://gitbook-integrations/githubIssuesDevSteeve/CLIENT_SECRET" }}
27+
WEBHOOK_SECRET: ${{ "op://gitbook-integrations/githubIssuesDevSteeve/WEBHOOK_SECRET" }}
28+
test:
29+
secrets:
30+
GITHUB_APP_ID: ${{ "op://gitbook-integrations/githubIssuesStaging/GITHUB_APP_ID" }}
31+
GITHUB_APP_NAME: ${{ "op://gitbook-integrations/githubIssuesStaging/GITHUB_APP_NAME" }}
32+
GITHUB_PRIVATE_KEY: ${{ "op://gitbook-integrations/githubIssuesStaging/GITHUB_PRIVATE_KEY" }}
33+
CLIENT_ID: ${{ "op://gitbook-integrations/githubIssuesStaging/CLIENT_ID" }}
34+
CLIENT_SECRET: ${{ "op://gitbook-integrations/githubIssuesStaging/CLIENT_SECRET" }}
35+
WEBHOOK_SECRET: ${{ "op://gitbook-integrations/githubIssuesStaging/WEBHOOK_SECRET" }}
36+
staging:
37+
secrets:
38+
GITHUB_APP_ID: ${{ "op://gitbook-integrations/githubIssuesStaging/GITHUB_APP_ID" }}
39+
GITHUB_APP_NAME: ${{ "op://gitbook-integrations/githubIssuesStaging/GITHUB_APP_NAME" }}
40+
GITHUB_PRIVATE_KEY: ${{ "op://gitbook-integrations/githubIssuesStaging/GITHUB_PRIVATE_KEY" }}
41+
CLIENT_ID: ${{ "op://gitbook-integrations/githubIssuesStaging/CLIENT_ID" }}
42+
CLIENT_SECRET: ${{ "op://gitbook-integrations/githubIssuesStaging/CLIENT_SECRET" }}
43+
WEBHOOK_SECRET: ${{ "op://gitbook-integrations/githubIssuesStaging/WEBHOOK_SECRET" }}
44+
production:
45+
visibility: unlisted
46+
secrets:
47+
GITHUB_APP_ID: ${{ "op://gitbook-integrations/githubIssuesProd/GITHUB_APP_ID" }}
48+
GITHUB_APP_NAME: ${{ "op://gitbook-integrations/githubIssuesProd/GITHUB_APP_NAME" }}
49+
GITHUB_PRIVATE_KEY: ${{ "op://gitbook-integrations/githubIssuesProd/GITHUB_PRIVATE_KEY" }}
50+
CLIENT_ID: ${{ "op://gitbook-integrations/githubIssuesProd/CLIENT_ID" }}
51+
CLIENT_SECRET: ${{ "op://gitbook-integrations/githubIssuesProd/CLIENT_SECRET" }}
52+
WEBHOOK_SECRET: ${{ "op://gitbook-integrations/githubIssuesProd/WEBHOOK_SECRET" }}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
{
2+
"name": "@gitbook/integration-github-issues",
3+
"version": "0.0.1",
4+
"private": true,
5+
"dependencies": {
6+
"@gitbook/runtime": "*",
7+
"@gitbook/api": "*",
8+
"itty-router": "^2.6.1",
9+
"octokit": "^5.0.5",
10+
"p-map": "^7.0.4",
11+
"@tsndr/cloudflare-worker-jwt": "^3.2.0"
12+
},
13+
"devDependencies": {
14+
"@gitbook/cli": "workspace:*",
15+
"@gitbook/tsconfig": "workspace:*"
16+
},
17+
"scripts": {
18+
"typecheck": "tsc --noEmit",
19+
"check": "gitbook check",
20+
"publish-integrations": "gitbook publish .",
21+
"publish-integrations-staging": "gitbook publish . --env staging"
22+
}
23+
}
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
import { createComponent, InstallationConfigurationProps } from '@gitbook/runtime';
2+
import type { GitHubIssuesRuntimeContext, GitHubIssuesRuntimeEnvironment } from './types';
3+
import { createGitHubAppSetupState } from './setup';
4+
5+
/**
6+
* Configuration component for the GitHub Issues integration.
7+
*/
8+
export const configComponent = createComponent<
9+
InstallationConfigurationProps<GitHubIssuesRuntimeEnvironment>,
10+
{},
11+
undefined,
12+
GitHubIssuesRuntimeContext
13+
>({
14+
componentId: 'config',
15+
render: async (element, context) => {
16+
const { installation } = context.environment;
17+
18+
if (!installation) {
19+
return null;
20+
}
21+
22+
const config = element.props.installation.configuration;
23+
const hasInstallations = config.installation_ids && config.installation_ids.length > 0;
24+
25+
const githubAppInstallURL = new URL(
26+
`https://github.com/apps/${context.environment.secrets.GITHUB_APP_NAME}/installations/new`,
27+
);
28+
const githubAppSetupState = await createGitHubAppSetupState(context, {
29+
gitbookInstallationId: installation.id,
30+
});
31+
githubAppInstallURL.searchParams.append('state', githubAppSetupState);
32+
33+
return (
34+
<configuration>
35+
<input
36+
label="GitHub App Installation"
37+
hint="Authorize GitBook to access your GitHub issues in your repositories."
38+
element={
39+
<button
40+
style="secondary"
41+
disabled={false}
42+
label={hasInstallations ? 'Manage repositories' : 'Install GitHub App'}
43+
onPress={{
44+
action: '@ui.url.open',
45+
url: githubAppInstallURL.toString(),
46+
}}
47+
/>
48+
}
49+
/>
50+
</configuration>
51+
);
52+
},
53+
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import jwt from '@tsndr/cloudflare-worker-jwt';
2+
import { Octokit } from 'octokit';
3+
4+
import { ExposableError } from '@gitbook/runtime';
5+
import { GitHubIssuesRuntimeContext } from '../types';
6+
7+
const GITBOOK_INTEGRATION_USER_AGENT = 'GitBook-GitHub-Issues-Integration';
8+
9+
/**
10+
* Get an authenticated Octokit instance for a GitHub app installation.
11+
*/
12+
export async function getOctokitClientForInstallation(
13+
context: GitHubIssuesRuntimeContext,
14+
githubInstallationId: string,
15+
): Promise<Octokit> {
16+
const config = getGitHubAppConfig(context);
17+
if (!config.appId || !config.privateKey) {
18+
throw new ExposableError('GitHub App credentials not configured');
19+
}
20+
21+
const token = await getGitHubInstallationAccessToken({
22+
githubInstallationId,
23+
appId: config.appId,
24+
privateKey: config.privateKey,
25+
});
26+
27+
return new Octokit({
28+
auth: token,
29+
userAgent: GITBOOK_INTEGRATION_USER_AGENT,
30+
});
31+
}
32+
/**
33+
* Generate a JWT token for GitHub App authentication.
34+
*/
35+
async function generateGitHubAppJWT(appId: string, privateKey: string): Promise<string> {
36+
const now = Math.floor(Date.now() / 1000);
37+
38+
const payload = {
39+
iat: now - 60, // Issued 60 seconds ago (for clock drift)
40+
exp: now + 60 * 10,
41+
iss: appId,
42+
};
43+
44+
return await jwt.sign(payload, privateKey, { algorithm: 'RS256' });
45+
}
46+
47+
/**
48+
* Get an access token for a GitHub App installation.
49+
*/
50+
async function getGitHubInstallationAccessToken(args: {
51+
githubInstallationId: string;
52+
appId: string;
53+
privateKey: string;
54+
}): Promise<string> {
55+
const { githubInstallationId, appId, privateKey } = args;
56+
const jwtToken = await generateGitHubAppJWT(appId, privateKey);
57+
58+
const octokit = new Octokit({
59+
auth: jwtToken,
60+
userAgent: GITBOOK_INTEGRATION_USER_AGENT,
61+
});
62+
63+
try {
64+
const response = await octokit.request(
65+
'POST /app/installations/{installation_id}/access_tokens',
66+
{
67+
installation_id: parseInt(githubInstallationId),
68+
},
69+
);
70+
71+
return response.data.token;
72+
} catch (error) {
73+
const errorMessage = error instanceof Error ? error.message : String(error);
74+
throw new Error(`Failed to get installation access token: ${errorMessage}`);
75+
}
76+
}
77+
78+
/**
79+
* Get GitHub App configuration for installation-based authentication.
80+
*/
81+
export function getGitHubAppConfig(context: GitHubIssuesRuntimeContext) {
82+
// We store the private key in 1password with newlines escaped to avoid the newlines from being removed when stored as password field in the OP entry.
83+
// This means that it will also be stored with escaped newlines in the integration secret config so we need to restore the newlines
84+
// before we sign the JWT as we need the private key in a proper PKCS8 format.
85+
const privateKey = context.environment.secrets.GITHUB_PRIVATE_KEY.replace(/\\n/g, '\n');
86+
87+
return {
88+
appId: context.environment.secrets.GITHUB_APP_ID,
89+
privateKey,
90+
};
91+
}

0 commit comments

Comments
 (0)