Skip to content
Draft
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
9 changes: 9 additions & 0 deletions integrations/github-conversations/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# GitHub Conversations Integration

This integration ingests resolved or closed GitHub Discussions into GitBook.

## Setup Instructions for development

1. Create a GitHub OAuth application.
2. Set `https://<integration-domain>/v1/integrations/github-conversations/integration/oauth` as the redirect URL.
3. Configure a webhook on your repository for the `discussion` event with `https://<integration-domain>/v1/integrations/github-conversations/integration/webhook` as the URL.
24 changes: 24 additions & 0 deletions integrations/github-conversations/gitbook-manifest.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
name: github-conversations
title: GitHub Discussions
description: Ingest resolved or closed GitHub Discussions into GitBook for auto-improvements.
visibility: public
script: ./src/index.ts
summary: |
# Overview

Automatically get AI-suggested change requests for your docs based on GitHub community discussions.
scopes:
- conversations:ingest
organization: gitbook
configurations:
account:
componentId: config
secrets:
CLIENT_ID: ${{ env.GITHUB_CLIENT_ID }}
CLIENT_SECRET: ${{ env.GITHUB_CLIENT_SECRET }}
target: organization
envs:
staging:
secrets:
CLIENT_ID: ${{ "op://gitbook-integrations/GithubConversationsStaging/CLIENT_ID" }}
CLIENT_SECRET: ${{ "op://gitbook-integrations/GithubConversationsStaging/CLIENT_SECRET" }}
20 changes: 20 additions & 0 deletions integrations/github-conversations/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "@gitbook/integration-github-conversations",
"version": "0.0.1",
"private": true,
"dependencies": {
"@gitbook/runtime": "*",
"@gitbook/api": "*",
"octokit": "^4.0.2",
"p-map": "^7.0.3"
},
"devDependencies": {
"@gitbook/cli": "workspace:*",
"@gitbook/tsconfig": "workspace:*"
},
"scripts": {
"typecheck": "tsc --noEmit",
"check": "gitbook check",
"publish-integrations-staging": "gitbook publish . --env staging"
}
}
32 changes: 32 additions & 0 deletions integrations/github-conversations/src/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { Octokit } from 'octokit';
import { GitHubRuntimeContext } from './types';
import { OAuthConfig, getOAuthToken } from '@gitbook/runtime';

/** Get OAuth configuration for GitHub */
export function getGitHubOAuthConfig(context: GitHubRuntimeContext): OAuthConfig {
return {
redirectURL: `${context.environment.integration.urls.publicEndpoint}/oauth`,
clientId: context.environment.secrets.CLIENT_ID,
clientSecret: context.environment.secrets.CLIENT_SECRET,
authorizeURL: 'https://github.com/login/oauth/authorize',
accessTokenURL: 'https://github.com/login/oauth/access_token',
scopes: ['read:discussion'],
prompt: 'consent',
};
}

/** Initialize a GitHub API client */
export async function getGitHubClient(context: GitHubRuntimeContext) {
const { installation } = context.environment;
if (!installation) {
throw new Error('Installation not found');
}
const { oauth_credentials } = installation.configuration;
if (!oauth_credentials) {
throw new Error('GitHub OAuth credentials not found');
}

const token = await getOAuthToken(oauth_credentials, getGitHubOAuthConfig(context), context);

return new Octokit({ auth: token });
}
100 changes: 100 additions & 0 deletions integrations/github-conversations/src/config.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import { createComponent, InstallationConfigurationProps } from '@gitbook/runtime';
import { GitHubRuntimeContext, GitHubRuntimeEnvironment } from './types';

export const configComponent = createComponent<
InstallationConfigurationProps<GitHubRuntimeEnvironment>,
{ step: 'edit.repo' | 'authenticate' | 'initial'; repo?: string },
{ action: 'save.repo' | 'edit.repo' },
GitHubRuntimeContext
>({
componentId: 'config',
initialState: (props) => {
const { installation } = props;
if (installation.configuration?.repository && installation.configuration?.oauth_credentials) {
return { step: 'initial' as const };
}
if (installation.configuration?.repository) {
return { step: 'authenticate' as const };
}
return { step: 'edit.repo' as const, repo: '' };
},
action: async (element, action, context) => {
switch (action.action) {
case 'edit.repo':
return {
state: {
step: 'edit.repo',
repo: context.environment.installation?.configuration?.repository ?? '',
},
};
case 'save.repo':
await context.api.integrations.updateIntegrationInstallation(
context.environment.integration.name,
context.environment.installation!.id,
{
configuration: {
repository: action.repo,
},
},
);
return { state: { step: 'authenticate' } };
}
},
render: async (element, context) => {
const { installation } = context.environment;
if (!installation) {
return null;
}
switch (element.state.step) {
case 'initial':
return (
<configuration>
<input
label="Repository"
hint={<text>The integration is configured with the following repository:</text>}
element={<textinput state="repo" initialValue={installation.configuration!.repository!} disabled={true} />}
/>
<box>
<button style="secondary" label="Edit configuration" onPress={{ action: 'edit.repo' }} />
</box>
<divider />
<input
label="Authenticate"
hint="Authorize GitBook to access your GitHub discussions."
element={<button style="secondary" label="Authorize" onPress={{ action: '@ui.url.open', url: `${installation.urls.publicEndpoint}/oauth` }} />}
/>
</configuration>
);
case 'edit.repo':
return (
<configuration>
<input
label="Repository"
hint={<text>Repository in the form owner/repo.</text>}
element={<textinput state="repo" placeholder="owner/repo" />}
/>
<box>
<button style="primary" label="Save" onPress={{ action: 'save.repo', repo: element.dynamicState('repo') }} />
</box>
</configuration>
);
case 'authenticate':
return (
<configuration>
<input
label="Repository"
element={<textinput state="repo" initialValue={installation.configuration!.repository!} disabled={true} />}
/>
<divider />
<input
label="Authenticate"
hint="Authorize GitBook to access your GitHub discussions."
element={<button style="secondary" label="Authorize" onPress={{ action: '@ui.url.open', url: `${installation.urls.publicEndpoint}/oauth` }} />}
/>
</configuration>
);
default:
return null;
}
},
});
117 changes: 117 additions & 0 deletions integrations/github-conversations/src/conversations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import pMap from 'p-map';
import { Octokit } from 'octokit';
import { ConversationInput } from '@gitbook/api';

export type GitHubDiscussion = {
id: string;
title: string;
url: string;
body: string;
createdAt: string;
comments: {
nodes: {
body: string;
createdAt: string;
}[];
};
};

const DISCUSSIONS_QUERY = `
query($owner: String!, $repo: String!, $cursor: String) {
repository(owner: $owner, name: $repo) {
discussions(first: 50, after: $cursor, states: CLOSED) {
pageInfo { hasNextPage endCursor }
nodes {
id
title
url
body
createdAt
comments(first: 100) {
nodes { body createdAt }
}
}
}
}
}`;

export const DISCUSSION_QUERY = `
query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
discussion(number: $number) {
id
title
url
body
createdAt
comments(first: 100) {
nodes { body createdAt }
}
}
}
}`;

/**
* Ingest closed discussions from a repository
*/
export async function ingestDiscussions(
client: Octokit,
repository: string,
onConversations: (conv: ConversationInput[]) => Promise<void>,
) {
const [owner, repo] = repository.split('/');
let cursor: string | undefined;
let hasNext = true;

while (hasNext) {
const result = await client.graphql<any>(DISCUSSIONS_QUERY, {
owner,
repo,
cursor,
});
const discussions: GitHubDiscussion[] = result.repository.discussions.nodes;
cursor = result.repository.discussions.pageInfo.endCursor;
hasNext = result.repository.discussions.pageInfo.hasNextPage;

const conversations = await pMap(
discussions,
async (discussion) => parseDiscussionAsConversation(discussion),
{ concurrency: 3 },
);

if (conversations.length > 0) {
await onConversations(conversations);
}
}
}

/** Convert a GitHub discussion to a GitBook conversation */
export async function parseDiscussionAsConversation(
discussion: GitHubDiscussion,
): Promise<ConversationInput> {
const parts = [
{
type: 'message',
role: 'user' as const,
body: discussion.body,
},
...discussion.comments.nodes.map((c) => ({
type: 'message' as const,
role: 'user' as const,
body: c.body,
})),
];

const conversation: ConversationInput = {
id: discussion.id,
subject: discussion.title,
metadata: {
url: discussion.url,
attributes: {},
createdAt: discussion.createdAt,
},
parts,
};

return conversation;
}
62 changes: 62 additions & 0 deletions integrations/github-conversations/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import { createIntegration, createOAuthHandler } from '@gitbook/runtime';
import { GitHubRuntimeContext } from './types';
import { configComponent } from './config';
import { getGitHubClient, getGitHubOAuthConfig } from './client';
import {
ingestDiscussions,
parseDiscussionAsConversation,
GitHubDiscussion,
DISCUSSION_QUERY,
} from './conversations';

export default createIntegration<GitHubRuntimeContext>({
fetch: async (request, context) => {
const url = new URL(request.url);

if (url.pathname.endsWith('/webhook')) {
const payload = await request.json<any>();
if (payload.action === 'closed' || payload.action === 'answered') {
const { installation } = context.environment;
if (!installation) {
throw new Error('Installation not found');
}
const client = await getGitHubClient(context);
const repo = installation.configuration.repository;
if (!repo) {
throw new Error('Repository not configured');
}
const [owner, name] = repo.split('/');
const result = await client.graphql<any>(DISCUSSION_QUERY, {
owner,
repo: name,
number: payload.discussion.number,
});
const discussion: GitHubDiscussion = result.repository.discussion;
const conversation = await parseDiscussionAsConversation(discussion);
await context.api.orgs.ingestConversation(installation.target.organization, [conversation]);
}
return new Response('OK', { status: 200 });
}

if (url.pathname.endsWith('/oauth')) {
const handler = createOAuthHandler(getGitHubOAuthConfig(context), {
replace: false,
});
return handler(request, context);
}

return new Response('Not found', { status: 404 });
},
components: [configComponent],
events: {
installation_setup: async (event, context) => {
const { installation } = context.environment;
if (installation?.configuration.repository && installation?.configuration.oauth_credentials) {
const client = await getGitHubClient(context);
await ingestDiscussions(client, installation.configuration.repository, async (conversations) => {
await context.api.orgs.ingestConversation(installation.target.organization, conversations);
});
}
},
},
});
13 changes: 13 additions & 0 deletions integrations/github-conversations/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { RuntimeEnvironment, RuntimeContext } from '@gitbook/runtime';

export type GitHubInstallationConfiguration = {
/** GitHub repository in the form owner/repo */
repository?: string;
/** OAuth credentials */
oauth_credentials?: {
access_token: string;
};
};

export type GitHubRuntimeEnvironment = RuntimeEnvironment<GitHubInstallationConfiguration>;
export type GitHubRuntimeContext = RuntimeContext<GitHubRuntimeEnvironment>;
3 changes: 3 additions & 0 deletions integrations/github-conversations/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"extends": "@gitbook/tsconfig/integration.json"
}
Loading