Skip to content

Commit 01df1f1

Browse files
committed
Remove GitHub conversations icon
1 parent 562bc1d commit 01df1f1

File tree

9 files changed

+380
-0
lines changed

9 files changed

+380
-0
lines changed
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# GitHub Conversations Integration
2+
3+
This integration ingests resolved or closed GitHub Discussions into GitBook.
4+
5+
## Setup Instructions for development
6+
7+
1. Create a GitHub OAuth application.
8+
2. Set `https://<integration-domain>/v1/integrations/github-conversations/integration/oauth` as the redirect URL.
9+
3. Configure a webhook on your repository for the `discussion` event with `https://<integration-domain>/v1/integrations/github-conversations/integration/webhook` as the URL.
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
name: github-conversations
2+
title: GitHub Discussions
3+
description: Ingest resolved or closed GitHub Discussions into GitBook for auto-improvements.
4+
visibility: public
5+
script: ./src/index.ts
6+
summary: |
7+
# Overview
8+
9+
Automatically get AI-suggested change requests for your docs based on GitHub community discussions.
10+
scopes:
11+
- conversations:ingest
12+
organization: gitbook
13+
configurations:
14+
account:
15+
componentId: config
16+
secrets:
17+
CLIENT_ID: ${{ env.GITHUB_CLIENT_ID }}
18+
CLIENT_SECRET: ${{ env.GITHUB_CLIENT_SECRET }}
19+
target: organization
20+
envs:
21+
staging:
22+
secrets:
23+
CLIENT_ID: ${{ "op://gitbook-integrations/GithubConversationsStaging/CLIENT_ID" }}
24+
CLIENT_SECRET: ${{ "op://gitbook-integrations/GithubConversationsStaging/CLIENT_SECRET" }}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
{
2+
"name": "@gitbook/integration-github-conversations",
3+
"version": "0.0.1",
4+
"private": true,
5+
"dependencies": {
6+
"@gitbook/runtime": "*",
7+
"@gitbook/api": "*",
8+
"octokit": "^4.0.2",
9+
"p-map": "^7.0.3"
10+
},
11+
"devDependencies": {
12+
"@gitbook/cli": "workspace:*",
13+
"@gitbook/tsconfig": "workspace:*"
14+
},
15+
"scripts": {
16+
"typecheck": "tsc --noEmit",
17+
"check": "gitbook check",
18+
"publish-integrations-staging": "gitbook publish . --env staging"
19+
}
20+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import { Octokit } from 'octokit';
2+
import { GitHubRuntimeContext } from './types';
3+
import { OAuthConfig, getOAuthToken } from '@gitbook/runtime';
4+
5+
/** Get OAuth configuration for GitHub */
6+
export function getGitHubOAuthConfig(context: GitHubRuntimeContext): OAuthConfig {
7+
return {
8+
redirectURL: `${context.environment.integration.urls.publicEndpoint}/oauth`,
9+
clientId: context.environment.secrets.CLIENT_ID,
10+
clientSecret: context.environment.secrets.CLIENT_SECRET,
11+
authorizeURL: 'https://github.com/login/oauth/authorize',
12+
accessTokenURL: 'https://github.com/login/oauth/access_token',
13+
scopes: ['read:discussion'],
14+
prompt: 'consent',
15+
};
16+
}
17+
18+
/** Initialize a GitHub API client */
19+
export async function getGitHubClient(context: GitHubRuntimeContext) {
20+
const { installation } = context.environment;
21+
if (!installation) {
22+
throw new Error('Installation not found');
23+
}
24+
const { oauth_credentials } = installation.configuration;
25+
if (!oauth_credentials) {
26+
throw new Error('GitHub OAuth credentials not found');
27+
}
28+
29+
const token = await getOAuthToken(oauth_credentials, getGitHubOAuthConfig(context), context);
30+
31+
return new Octokit({ auth: token });
32+
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { createComponent, InstallationConfigurationProps } from '@gitbook/runtime';
2+
import { GitHubRuntimeContext, GitHubRuntimeEnvironment } from './types';
3+
4+
export const configComponent = createComponent<
5+
InstallationConfigurationProps<GitHubRuntimeEnvironment>,
6+
{ step: 'edit.repo' | 'authenticate' | 'initial'; repo?: string },
7+
{ action: 'save.repo' | 'edit.repo' },
8+
GitHubRuntimeContext
9+
>({
10+
componentId: 'config',
11+
initialState: (props) => {
12+
const { installation } = props;
13+
if (installation.configuration?.repository && installation.configuration?.oauth_credentials) {
14+
return { step: 'initial' as const };
15+
}
16+
if (installation.configuration?.repository) {
17+
return { step: 'authenticate' as const };
18+
}
19+
return { step: 'edit.repo' as const, repo: '' };
20+
},
21+
action: async (element, action, context) => {
22+
switch (action.action) {
23+
case 'edit.repo':
24+
return {
25+
state: {
26+
step: 'edit.repo',
27+
repo: context.environment.installation?.configuration?.repository ?? '',
28+
},
29+
};
30+
case 'save.repo':
31+
await context.api.integrations.updateIntegrationInstallation(
32+
context.environment.integration.name,
33+
context.environment.installation!.id,
34+
{
35+
configuration: {
36+
repository: action.repo,
37+
},
38+
},
39+
);
40+
return { state: { step: 'authenticate' } };
41+
}
42+
},
43+
render: async (element, context) => {
44+
const { installation } = context.environment;
45+
if (!installation) {
46+
return null;
47+
}
48+
switch (element.state.step) {
49+
case 'initial':
50+
return (
51+
<configuration>
52+
<input
53+
label="Repository"
54+
hint={<text>The integration is configured with the following repository:</text>}
55+
element={<textinput state="repo" initialValue={installation.configuration!.repository!} disabled={true} />}
56+
/>
57+
<box>
58+
<button style="secondary" label="Edit configuration" onPress={{ action: 'edit.repo' }} />
59+
</box>
60+
<divider />
61+
<input
62+
label="Authenticate"
63+
hint="Authorize GitBook to access your GitHub discussions."
64+
element={<button style="secondary" label="Authorize" onPress={{ action: '@ui.url.open', url: `${installation.urls.publicEndpoint}/oauth` }} />}
65+
/>
66+
</configuration>
67+
);
68+
case 'edit.repo':
69+
return (
70+
<configuration>
71+
<input
72+
label="Repository"
73+
hint={<text>Repository in the form owner/repo.</text>}
74+
element={<textinput state="repo" placeholder="owner/repo" />}
75+
/>
76+
<box>
77+
<button style="primary" label="Save" onPress={{ action: 'save.repo', repo: element.dynamicState('repo') }} />
78+
</box>
79+
</configuration>
80+
);
81+
case 'authenticate':
82+
return (
83+
<configuration>
84+
<input
85+
label="Repository"
86+
element={<textinput state="repo" initialValue={installation.configuration!.repository!} disabled={true} />}
87+
/>
88+
<divider />
89+
<input
90+
label="Authenticate"
91+
hint="Authorize GitBook to access your GitHub discussions."
92+
element={<button style="secondary" label="Authorize" onPress={{ action: '@ui.url.open', url: `${installation.urls.publicEndpoint}/oauth` }} />}
93+
/>
94+
</configuration>
95+
);
96+
default:
97+
return null;
98+
}
99+
},
100+
});
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import pMap from 'p-map';
2+
import { Octokit } from 'octokit';
3+
import { ConversationInput } from '@gitbook/api';
4+
5+
export type GitHubDiscussion = {
6+
id: string;
7+
title: string;
8+
url: string;
9+
body: string;
10+
createdAt: string;
11+
comments: {
12+
nodes: {
13+
body: string;
14+
createdAt: string;
15+
}[];
16+
};
17+
};
18+
19+
const DISCUSSIONS_QUERY = `
20+
query($owner: String!, $repo: String!, $cursor: String) {
21+
repository(owner: $owner, name: $repo) {
22+
discussions(first: 50, after: $cursor, states: CLOSED) {
23+
pageInfo { hasNextPage endCursor }
24+
nodes {
25+
id
26+
title
27+
url
28+
body
29+
createdAt
30+
comments(first: 100) {
31+
nodes { body createdAt }
32+
}
33+
}
34+
}
35+
}
36+
}`;
37+
38+
export const DISCUSSION_QUERY = `
39+
query($owner: String!, $repo: String!, $number: Int!) {
40+
repository(owner: $owner, name: $repo) {
41+
discussion(number: $number) {
42+
id
43+
title
44+
url
45+
body
46+
createdAt
47+
comments(first: 100) {
48+
nodes { body createdAt }
49+
}
50+
}
51+
}
52+
}`;
53+
54+
/**
55+
* Ingest closed discussions from a repository
56+
*/
57+
export async function ingestDiscussions(
58+
client: Octokit,
59+
repository: string,
60+
onConversations: (conv: ConversationInput[]) => Promise<void>,
61+
) {
62+
const [owner, repo] = repository.split('/');
63+
let cursor: string | undefined;
64+
let hasNext = true;
65+
66+
while (hasNext) {
67+
const result = await client.graphql<any>(DISCUSSIONS_QUERY, {
68+
owner,
69+
repo,
70+
cursor,
71+
});
72+
const discussions: GitHubDiscussion[] = result.repository.discussions.nodes;
73+
cursor = result.repository.discussions.pageInfo.endCursor;
74+
hasNext = result.repository.discussions.pageInfo.hasNextPage;
75+
76+
const conversations = await pMap(
77+
discussions,
78+
async (discussion) => parseDiscussionAsConversation(discussion),
79+
{ concurrency: 3 },
80+
);
81+
82+
if (conversations.length > 0) {
83+
await onConversations(conversations);
84+
}
85+
}
86+
}
87+
88+
/** Convert a GitHub discussion to a GitBook conversation */
89+
export async function parseDiscussionAsConversation(
90+
discussion: GitHubDiscussion,
91+
): Promise<ConversationInput> {
92+
const parts = [
93+
{
94+
type: 'message',
95+
role: 'user' as const,
96+
body: discussion.body,
97+
},
98+
...discussion.comments.nodes.map((c) => ({
99+
type: 'message' as const,
100+
role: 'user' as const,
101+
body: c.body,
102+
})),
103+
];
104+
105+
const conversation: ConversationInput = {
106+
id: discussion.id,
107+
subject: discussion.title,
108+
metadata: {
109+
url: discussion.url,
110+
attributes: {},
111+
createdAt: discussion.createdAt,
112+
},
113+
parts,
114+
};
115+
116+
return conversation;
117+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { createIntegration, createOAuthHandler } from '@gitbook/runtime';
2+
import { GitHubRuntimeContext } from './types';
3+
import { configComponent } from './config';
4+
import { getGitHubClient, getGitHubOAuthConfig } from './client';
5+
import {
6+
ingestDiscussions,
7+
parseDiscussionAsConversation,
8+
GitHubDiscussion,
9+
DISCUSSION_QUERY,
10+
} from './conversations';
11+
12+
export default createIntegration<GitHubRuntimeContext>({
13+
fetch: async (request, context) => {
14+
const url = new URL(request.url);
15+
16+
if (url.pathname.endsWith('/webhook')) {
17+
const payload = await request.json<any>();
18+
if (payload.action === 'closed' || payload.action === 'answered') {
19+
const { installation } = context.environment;
20+
if (!installation) {
21+
throw new Error('Installation not found');
22+
}
23+
const client = await getGitHubClient(context);
24+
const repo = installation.configuration.repository;
25+
if (!repo) {
26+
throw new Error('Repository not configured');
27+
}
28+
const [owner, name] = repo.split('/');
29+
const result = await client.graphql<any>(DISCUSSION_QUERY, {
30+
owner,
31+
repo: name,
32+
number: payload.discussion.number,
33+
});
34+
const discussion: GitHubDiscussion = result.repository.discussion;
35+
const conversation = await parseDiscussionAsConversation(discussion);
36+
await context.api.orgs.ingestConversation(installation.target.organization, [conversation]);
37+
}
38+
return new Response('OK', { status: 200 });
39+
}
40+
41+
if (url.pathname.endsWith('/oauth')) {
42+
const handler = createOAuthHandler(getGitHubOAuthConfig(context), {
43+
replace: false,
44+
});
45+
return handler(request, context);
46+
}
47+
48+
return new Response('Not found', { status: 404 });
49+
},
50+
components: [configComponent],
51+
events: {
52+
installation_setup: async (event, context) => {
53+
const { installation } = context.environment;
54+
if (installation?.configuration.repository && installation?.configuration.oauth_credentials) {
55+
const client = await getGitHubClient(context);
56+
await ingestDiscussions(client, installation.configuration.repository, async (conversations) => {
57+
await context.api.orgs.ingestConversation(installation.target.organization, conversations);
58+
});
59+
}
60+
},
61+
},
62+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { RuntimeEnvironment, RuntimeContext } from '@gitbook/runtime';
2+
3+
export type GitHubInstallationConfiguration = {
4+
/** GitHub repository in the form owner/repo */
5+
repository?: string;
6+
/** OAuth credentials */
7+
oauth_credentials?: {
8+
access_token: string;
9+
};
10+
};
11+
12+
export type GitHubRuntimeEnvironment = RuntimeEnvironment<GitHubInstallationConfiguration>;
13+
export type GitHubRuntimeContext = RuntimeContext<GitHubRuntimeEnvironment>;
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
{
2+
"extends": "@gitbook/tsconfig/integration.json"
3+
}

0 commit comments

Comments
 (0)