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: 5 additions & 0 deletions .changeset/new-facts-pay.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gitbook/integration-github-issues': patch
---

Add GitHub issues integration
159 changes: 146 additions & 13 deletions bun.lock

Large diffs are not rendered by default.

Binary file added integrations/github-issues/assets/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
52 changes: 52 additions & 0 deletions integrations/github-issues/gitbook-manifest.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
name: github-issues
title: GitHub Issues
icon: ./assets/icon.png
description: Automatically sync GitHub issues to docs updates in GitBook.
visibility: public
script: ./src/index.ts
summary: |
# Overview

Automatically get AI-suggested change requests for your docs based on feedback from your GitHub Issues.
scopes:
- conversations:ingest
organization: gitbook
configurations:
account:
componentId: config
target: organization
envs:
dev-steeve:
organization: idE5kUnGGjoPGcbu3FZJ
secrets:
GITHUB_APP_ID: ${{ "op://gitbook-integrations/githubIssuesDevSteeve/GITHUB_APP_ID" }}
GITHUB_APP_NAME: ${{ "op://gitbook-integrations/githubIssuesDevSteeve/GITHUB_APP_NAME" }}
GITHUB_PRIVATE_KEY: ${{ "op://gitbook-integrations/githubIssuesDevSteeve/GITHUB_PRIVATE_KEY" }}
CLIENT_ID: ${{ "op://gitbook-integrations/githubIssuesDevSteeve/CLIENT_ID" }}
CLIENT_SECRET: ${{ "op://gitbook-integrations/githubIssuesDevSteeve/CLIENT_SECRET" }}
WEBHOOK_SECRET: ${{ "op://gitbook-integrations/githubIssuesDevSteeve/WEBHOOK_SECRET" }}
test:
secrets:
GITHUB_APP_ID: ${{ "op://gitbook-integrations/githubIssuesStaging/GITHUB_APP_ID" }}
GITHUB_APP_NAME: ${{ "op://gitbook-integrations/githubIssuesStaging/GITHUB_APP_NAME" }}
GITHUB_PRIVATE_KEY: ${{ "op://gitbook-integrations/githubIssuesStaging/GITHUB_PRIVATE_KEY" }}
CLIENT_ID: ${{ "op://gitbook-integrations/githubIssuesStaging/CLIENT_ID" }}
CLIENT_SECRET: ${{ "op://gitbook-integrations/githubIssuesStaging/CLIENT_SECRET" }}
WEBHOOK_SECRET: ${{ "op://gitbook-integrations/githubIssuesStaging/WEBHOOK_SECRET" }}
staging:
secrets:
GITHUB_APP_ID: ${{ "op://gitbook-integrations/githubIssuesStaging/GITHUB_APP_ID" }}
GITHUB_APP_NAME: ${{ "op://gitbook-integrations/githubIssuesStaging/GITHUB_APP_NAME" }}
GITHUB_PRIVATE_KEY: ${{ "op://gitbook-integrations/githubIssuesStaging/GITHUB_PRIVATE_KEY" }}
CLIENT_ID: ${{ "op://gitbook-integrations/githubIssuesStaging/CLIENT_ID" }}
CLIENT_SECRET: ${{ "op://gitbook-integrations/githubIssuesStaging/CLIENT_SECRET" }}
WEBHOOK_SECRET: ${{ "op://gitbook-integrations/githubIssuesStaging/WEBHOOK_SECRET" }}
production:
visibility: unlisted
secrets:
GITHUB_APP_ID: ${{ "op://gitbook-integrations/githubIssuesProd/GITHUB_APP_ID" }}
GITHUB_APP_NAME: ${{ "op://gitbook-integrations/githubIssuesProd/GITHUB_APP_NAME" }}
GITHUB_PRIVATE_KEY: ${{ "op://gitbook-integrations/githubIssuesProd/GITHUB_PRIVATE_KEY" }}
CLIENT_ID: ${{ "op://gitbook-integrations/githubIssuesProd/CLIENT_ID" }}
CLIENT_SECRET: ${{ "op://gitbook-integrations/githubIssuesProd/CLIENT_SECRET" }}
WEBHOOK_SECRET: ${{ "op://gitbook-integrations/githubIssuesProd/WEBHOOK_SECRET" }}
23 changes: 23 additions & 0 deletions integrations/github-issues/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"name": "@gitbook/integration-github-issues",
"version": "0.0.1",
"private": true,
"dependencies": {
"@gitbook/runtime": "*",
"@gitbook/api": "*",
"itty-router": "^2.6.1",
"octokit": "^5.0.5",
"p-map": "^7.0.4",
"@tsndr/cloudflare-worker-jwt": "^3.2.0"
},
"devDependencies": {
"@gitbook/cli": "workspace:*",
"@gitbook/tsconfig": "workspace:*"
},
"scripts": {
"typecheck": "tsc --noEmit",
"check": "gitbook check",
"publish-integrations": "gitbook publish .",
"publish-integrations-staging": "gitbook publish . --env staging"
}
}
53 changes: 53 additions & 0 deletions integrations/github-issues/src/components.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { createComponent, InstallationConfigurationProps } from '@gitbook/runtime';
import type { GitHubIssuesRuntimeContext, GitHubIssuesRuntimeEnvironment } from './types';
import { createGitHubAppSetupState } from './setup';

/**
* Configuration component for the GitHub Issues integration.
*/
export const configComponent = createComponent<
InstallationConfigurationProps<GitHubIssuesRuntimeEnvironment>,
{},
undefined,
GitHubIssuesRuntimeContext
>({
componentId: 'config',
render: async (element, context) => {
const { installation } = context.environment;

if (!installation) {
return null;
}

const config = element.props.installation.configuration;
const hasInstallations = config.installation_ids && config.installation_ids.length > 0;

const githubAppInstallURL = new URL(
`https://github.com/apps/${context.environment.secrets.GITHUB_APP_NAME}/installations/new`,
);
const githubAppSetupState = await createGitHubAppSetupState(context, {
gitbookInstallationId: installation.id,
});
githubAppInstallURL.searchParams.append('state', githubAppSetupState);

return (
<configuration>
<input
label="GitHub App Installation"
hint="Authorize GitBook to access your GitHub issues in your repositories."
element={
<button
style="secondary"
disabled={false}
label={hasInstallations ? 'Manage repositories' : 'Install GitHub App'}
onPress={{
action: '@ui.url.open',
url: githubAppInstallURL.toString(),
}}
/>
}
/>
</configuration>
);
},
});
91 changes: 91 additions & 0 deletions integrations/github-issues/src/github-api/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import jwt from '@tsndr/cloudflare-worker-jwt';
import { Octokit } from 'octokit';

import { ExposableError } from '@gitbook/runtime';
import { GitHubIssuesRuntimeContext } from '../types';

const GITBOOK_INTEGRATION_USER_AGENT = 'GitBook-GitHub-Issues-Integration';

/**
* Get an authenticated Octokit instance for a GitHub app installation.
*/
export async function getOctokitClientForInstallation(
context: GitHubIssuesRuntimeContext,
githubInstallationId: string,
): Promise<Octokit> {
const config = getGitHubAppConfig(context);
if (!config.appId || !config.privateKey) {
throw new ExposableError('GitHub App credentials not configured');
}

const token = await getGitHubInstallationAccessToken({
githubInstallationId,
appId: config.appId,
privateKey: config.privateKey,
});

return new Octokit({
auth: token,
userAgent: GITBOOK_INTEGRATION_USER_AGENT,
});
}
/**
* Generate a JWT token for GitHub App authentication.
*/
async function generateGitHubAppJWT(appId: string, privateKey: string): Promise<string> {
const now = Math.floor(Date.now() / 1000);

const payload = {
iat: now - 60, // Issued 60 seconds ago (for clock drift)
exp: now + 60 * 10,
iss: appId,
};

return await jwt.sign(payload, privateKey, { algorithm: 'RS256' });
}

/**
* Get an access token for a GitHub App installation.
*/
async function getGitHubInstallationAccessToken(args: {
githubInstallationId: string;
appId: string;
privateKey: string;
}): Promise<string> {
const { githubInstallationId, appId, privateKey } = args;
const jwtToken = await generateGitHubAppJWT(appId, privateKey);

const octokit = new Octokit({
auth: jwtToken,
userAgent: GITBOOK_INTEGRATION_USER_AGENT,
});

try {
const response = await octokit.request(
'POST /app/installations/{installation_id}/access_tokens',
{
installation_id: parseInt(githubInstallationId),
},
);

return response.data.token;
} catch (error) {
const errorMessage = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to get installation access token: ${errorMessage}`);
}
}

/**
* Get GitHub App configuration for installation-based authentication.
*/
export function getGitHubAppConfig(context: GitHubIssuesRuntimeContext) {
// 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.
// This means that it will also be stored with escaped newlines in the integration secret config so we need to restore the newlines
// before we sign the JWT as we need the private key in a proper PKCS8 format.
const privateKey = context.environment.secrets.GITHUB_PRIVATE_KEY.replace(/\\n/g, '\n');

return {
appId: context.environment.secrets.GITHUB_APP_ID,
privateKey,
};
}
Loading