From 3a0fc1eda2c449ac27bc25b957bd1bffe8dc8be4 Mon Sep 17 00:00:00 2001 From: Valery Bugakov Date: Wed, 5 Jun 2024 10:58:36 +0800 Subject: [PATCH] Provider: add `linear-docs` provider --- pnpm-lock.yaml | 22 +++ provider/linear-docs/.gitignore | 2 + provider/linear-docs/README.md | 45 +++++ provider/linear-docs/auth.ts | 111 ++++++++++++ provider/linear-docs/index.ts | 191 +++++++++++++++++++++ provider/linear-docs/package.json | 34 ++++ provider/linear-docs/tsconfig.json | 11 ++ provider/linear-docs/vitest.config.ts | 3 + provider/linear-issues/README.md | 2 +- provider/linear-issues/auth.ts | 2 + web/content/docs/providers/linear-docs.mdx | 8 + web/pages/index/+Page.tsx | 1 + 12 files changed, 431 insertions(+), 1 deletion(-) create mode 100644 provider/linear-docs/.gitignore create mode 100644 provider/linear-docs/README.md create mode 100644 provider/linear-docs/auth.ts create mode 100644 provider/linear-docs/index.ts create mode 100644 provider/linear-docs/package.json create mode 100644 provider/linear-docs/tsconfig.json create mode 100644 provider/linear-docs/vitest.config.ts create mode 100644 web/content/docs/providers/linear-docs.mdx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 79047162..50ff5942 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -563,6 +563,28 @@ importers: specifier: workspace:* version: link:../../lib/provider + provider/linear-docs: + dependencies: + '@openctx/provider': + specifier: workspace:* + version: link:../../lib/provider + dedent: + specifier: ^1.5.3 + version: 1.5.3 + fast-xml-parser: + specifier: ^4.4.0 + version: 4.4.0 + open: + specifier: ^10.0.4 + version: 10.1.0 + server-destroy: + specifier: ^1.0.1 + version: 1.0.1 + devDependencies: + '@types/server-destroy': + specifier: ^1.0.3 + version: 1.0.3 + provider/linear-issues: dependencies: '@openctx/provider': diff --git a/provider/linear-docs/.gitignore b/provider/linear-docs/.gitignore new file mode 100644 index 00000000..2ac57e4f --- /dev/null +++ b/provider/linear-docs/.gitignore @@ -0,0 +1,2 @@ +linear_client_config.json +linear_user_credentials.json diff --git a/provider/linear-docs/README.md b/provider/linear-docs/README.md new file mode 100644 index 00000000..413e3f92 --- /dev/null +++ b/provider/linear-docs/README.md @@ -0,0 +1,45 @@ +# Linear Docs context provider for OpenCtx + +This is a context provider for [OpenCtx](https://openctx.org) that brings Linear Docs context to code AI and editors. Only items, not annotations, are supported. + +**Status:** Experimental + +## Configuration for Sourcegraph teammates + +1. Find "OpenCtx Linear Docs provider config" in 1Password and add it to your user settings. +1. Start using the provider! + +```json +"openctx.providers": { + // ...other providers... + "https://openctx.org/npm/@openctx/provider-linear-docs": { + "accessToken": "YOUR_ACCESS_TOKEN", + } +}, +``` + +## Configuration outside of Sourcegraph + +To create Linear API credentials: + +1. [Create an OAuth2 application in Linear](https://linear.app/settings/api/applications/new). +1. Save the client configuration JSON file (`linear_client_config.json`). +1. Obtain an access token for your user account: run `LINEAR_OAUTH_CLIENT_FILE=path/to/linear_client_config.json pnpm auth` and continue in your web browser. +1. The access token will be saved in a JSON file with a path printed to the console. + +Then use the following OpenCtx provider configuration: + +```json +"openctx.providers": { + // ...other providers... + "https://openctx.org/npm/@openctx/provider-linear-docs": { + "userCredentialsPath": "path/to/access_token_file_printed.json", + } +}, +``` + +## Development + +- [Source code](https://sourcegraph.com/github.com/sourcegraph/openctx/-/tree/provider/linear-docs) +- [Docs](https://openctx.org/docs/providers/linear-docs) +- License: Apache 2.0 diff --git a/provider/linear-docs/auth.ts b/provider/linear-docs/auth.ts new file mode 100644 index 00000000..355cfb61 --- /dev/null +++ b/provider/linear-docs/auth.ts @@ -0,0 +1,111 @@ +// Identical to provider/linear-issues/auth.ts. +// Keep the duplicate for now to keep things simple. +import { readFileSync, writeFileSync } from 'fs' +import http from 'http' +import path from 'path' +import url, { fileURLToPath } from 'url' +import open from 'open' +import destroyer from 'server-destroy' + +export interface LinearAuthClientConfig { + client_id: string + client_secret: string + redirect_uris: string[] +} + +export interface UserCredentials { + access_token: string +} + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const DEFAULT_USER_CREDENTIALS_PATH = path.resolve(__dirname, 'linear_user_credentials.json') +const DEFAULT_CLIENT_CONFIG_PATH = path.join(__dirname, 'linear_client_config.json') + +const clientConfigPath = process.env.LINEAR_OAUTH_CLIENT_FILE || DEFAULT_CLIENT_CONFIG_PATH +const userCredentialsPath = process.env.LINEAR_USER_CREDENTIALS_FILE || DEFAULT_USER_CREDENTIALS_PATH + +const port = process.env.PORT ? Number(process.env.PORT) : 3000 +const serverURL = `http://localhost:${port}` + +export const SCOPES = ['read'] + +export function createAccessToken(clientConfig?: LinearAuthClientConfig): Promise { + return new Promise((resolve, reject) => { + const config = + clientConfig || + (JSON.parse(readFileSync(clientConfigPath, 'utf8')) as LinearAuthClientConfig) + + const [redirectUri] = config.redirect_uris + + const server = http + .createServer(async (req, res) => { + try { + if (req.url!.includes('/oauth2callback')) { + const qs = new url.URL(req.url!, serverURL).searchParams + const code = qs.get('code') + if (!code) { + throw new Error('code is not found!') + } + res.end('Authentication successful. Please return to the console.') + server.destroy() + + const params = new URLSearchParams({ + code, + grant_type: 'authorization_code', + redirect_uri: redirectUri, + client_id: config.client_id, + client_secret: config.client_secret, + }) + + // Exchange `code` for an access token + // https://developers.linear.app/docs/oauth/authentication#id-4.-exchange-code-for-an-access-token + const tokenResponse = await fetch('https://api.linear.app/oauth/token', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: params.toString(), + }) + + const tokenData = (await tokenResponse.json()) as { access_token?: string } + + if (tokenData.access_token) { + resolve(tokenData.access_token) + } else { + reject(new Error('Failed to retrieve access token')) + } + } + } catch (e) { + reject(e) + } + }) + .listen(port, () => { + // Redirect user access requests to Linear + // https://developers.linear.app/docs/oauth/authentication#id-2.-redirect-user-access-requests-to-linear + const authorizeURL = new url.URL('https://linear.app/oauth/authorize') + authorizeURL.searchParams.set('response_type', 'code') + authorizeURL.searchParams.set('client_id', config.client_id) + authorizeURL.searchParams.set('redirect_uri', redirectUri) + authorizeURL.searchParams.set('scope', SCOPES.join(' ')) + + open(authorizeURL.toString(), { wait: false }).then(cp => cp.unref()) + }) + destroyer(server) + }) +} + +async function main() { + const accessToken = await createAccessToken() + console.log(`Got access token: ${accessToken}`) + + const userCredentials = JSON.stringify({ access_token: accessToken } satisfies UserCredentials) + writeFileSync(userCredentialsPath, userCredentials, { + encoding: 'utf8', + }) + + console.log(`Saved access token to ${userCredentialsPath}`) +} + +main() diff --git a/provider/linear-docs/index.ts b/provider/linear-docs/index.ts new file mode 100644 index 00000000..dd7bf5d3 --- /dev/null +++ b/provider/linear-docs/index.ts @@ -0,0 +1,191 @@ +import { readFileSync } from 'fs' +import type { + ItemsParams, + ItemsResult, + MentionsParams, + MentionsResult, + MetaResult, + Provider, +} from '@openctx/provider' +import dedent from 'dedent' +import { XMLBuilder } from 'fast-xml-parser' + +import type { UserCredentials } from './auth.js' + +/** Settings for the Linear Docs OpenCtx provider. */ +export type Settings = { + userCredentialsPath?: string + accessToken?: string +} + +const xmlBuilder = new XMLBuilder({ format: true }) + +interface Document { + id: string + title: string + url: string + content?: string +} + +const NUMBER_OF_DOCS_TO_FETCH = 10 + +const linearDocs: Provider = { + meta(): MetaResult { + return { name: 'Linear Docs', mentions: {} } + }, + + async mentions(params: MentionsParams, settingsInput: Settings): Promise { + let docs: Document[] = [] + + if (params.query) { + const variables = { term: params.query, first: NUMBER_OF_DOCS_TO_FETCH } + const response = await linearApiRequest(documentSearchQuery, variables, settingsInput) + docs = response.data.searchDocuments.nodes as Document[] + } else { + const variables = { first: NUMBER_OF_DOCS_TO_FETCH } + const response = await linearApiRequest(recentDocumentsQuery, variables, settingsInput) + docs = response.data.documents.nodes as Document[] + } + + const mentions = (docs ?? []).map(doc => ({ + title: doc.title, + uri: doc.url, + })) + + return mentions + }, + + async items(params: ItemsParams, settingsInput: Settings): Promise { + if (!params.mention) { + return [] + } + + const documentId = parseDocumentIDFromURL(params.mention.uri) + if (!documentId) { + return [] + } + + const variables = { id: documentId } + const response = await linearApiRequest(documentWithContentQuery, variables, settingsInput) + const document = response.data.document as Document + + const documentInfo = xmlBuilder.build({ + title: document.title, + content: document.content || '', + url: document.url, + }) + const content = dedent` + Here is the Linear document. Use it to check if it helps. + Ignore it if it is not relevant. + + ${documentInfo} + ` + + return [ + { + title: document.title, + url: document.url, + ai: { + content, + }, + }, + ] + }, +} + +export default linearDocs + +function getAccessToken(settings: Settings): string { + if (settings?.accessToken) { + return settings.accessToken + } + + if (settings.userCredentialsPath) { + const userCredentialsString = readFileSync(settings.userCredentialsPath, 'utf-8') + const userCredentials = JSON.parse(userCredentialsString) as Partial + + if (!userCredentials.access_token) { + throw new Error(`access_token not found in ${settings.userCredentialsPath}`) + } + + return userCredentials.access_token + } + + throw new Error( + 'must provide a Linear user credentials path in the `userCredentialsPath` settings field or an accessToken in the linearClientOptions' + ) +} + +async function linearApiRequest( + query: string, + variables: object, + settings: Settings +): Promise<{ data: any }> { + const accessToken = getAccessToken(settings) + const response = await fetch('https://api.linear.app/graphql', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${accessToken}`, + }, + body: JSON.stringify({ query, variables }), + }) + + if (!response.ok) { + const errorBody = await response.text() + console.error( + `Linear API request failed: ${response.status} - ${response.statusText}\n${errorBody}` + ) + throw new Error(`Linear API request failed: ${response.statusText}`) + } + + const json = (await response.json()) as { data: object } + + if (!json.data) { + throw new Error('Linear API request failed: no data') + } + + return json +} + +function parseDocumentIDFromURL(urlStr: string): string | undefined { + const url = new URL(urlStr) + if (!url.hostname.endsWith('linear.app')) { + return undefined + } + const match = url.pathname.match(/\/document\/.+-([a-zA-Z0-9]+)$/) + return match ? match[1] : undefined +} + +const recentDocumentsQuery = ` + query RecentDocuments($first: Int!) { + documents(first: $first, orderBy: updatedAt) { + nodes { + id + title + url + } + } + } +` +const documentSearchQuery = ` + query DocumentSearch($term: String!, $first: Int!) { + searchDocuments(term: $term, first: $first, orderBy: updatedAt) { + nodes { + id + title + url + } + } + } +` +const documentWithContentQuery = ` + query DocumentWithContent($id: String!) { + document(id: $id) { + id + title + url + content + } + } +` diff --git a/provider/linear-docs/package.json b/provider/linear-docs/package.json new file mode 100644 index 00000000..456878c6 --- /dev/null +++ b/provider/linear-docs/package.json @@ -0,0 +1,34 @@ +{ + "name": "@openctx/provider-linear-docs", + "version": "0.0.1", + "description": "Linear Docs context for code AI and editors (OpenCtx provider)", + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/sourcegraph/openctx", + "directory": "provider/linear-docs" + }, + "type": "module", + "main": "dist/bundle.js", + "types": "dist/index.d.ts", + "files": ["dist/bundle.js", "dist/index.d.ts"], + "sideEffects": false, + "scripts": { + "build": "tsc --build", + "bundle:watch": "pnpm run bundle --watch", + "bundle": "esbuild --main-fields=module,main --log-level=error --platform=node --bundle --format=esm --outfile=dist/bundle.js index.ts", + "prepublishOnly": "tsc --build --clean && pnpm run --silent bundle", + "test": "vitest", + "auth": "node --no-warnings=ExperimentalWarning --es-module-specifier-resolution=node --loader ts-node/esm/transpile-only auth.ts" + }, + "dependencies": { + "@openctx/provider": "workspace:*", + "dedent": "^1.5.3", + "fast-xml-parser": "^4.4.0", + "open": "^10.0.4", + "server-destroy": "^1.0.1" + }, + "devDependencies": { + "@types/server-destroy": "^1.0.3" + } +} diff --git a/provider/linear-docs/tsconfig.json b/provider/linear-docs/tsconfig.json new file mode 100644 index 00000000..4ff7ab81 --- /dev/null +++ b/provider/linear-docs/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../.config/tsconfig.base.json", + "compilerOptions": { + "rootDir": ".", + "outDir": "dist", + "lib": ["ESNext"], + }, + "include": ["*.ts"], + "exclude": ["dist", "vitest.config.ts"], + "references": [{ "path": "../../lib/provider" }], +} diff --git a/provider/linear-docs/vitest.config.ts b/provider/linear-docs/vitest.config.ts new file mode 100644 index 00000000..abed6b21 --- /dev/null +++ b/provider/linear-docs/vitest.config.ts @@ -0,0 +1,3 @@ +import { defineConfig } from 'vitest/config' + +export default defineConfig({}) diff --git a/provider/linear-issues/README.md b/provider/linear-issues/README.md index ec1ab3a2..6b280721 100644 --- a/provider/linear-issues/README.md +++ b/provider/linear-issues/README.md @@ -6,7 +6,7 @@ This is a context provider for [OpenCtx](https://openctx.org) that brings Linear ## Configuration for Sourcegraph teammates -1. Find "OpenCtx Linear provider config" in 1Password and add it to your user settings. +1. Find "OpenCtx Linear Issues provider config" in 1Password and add it to your user settings. 1. Start using the provider! ```json diff --git a/provider/linear-issues/auth.ts b/provider/linear-issues/auth.ts index 3514ff63..fffd035b 100644 --- a/provider/linear-issues/auth.ts +++ b/provider/linear-issues/auth.ts @@ -1,3 +1,5 @@ +// Identical to provider/linear-docs/auth.ts. +// Keep the duplicate for now to keep things simple. import { readFileSync, writeFileSync } from 'fs' import http from 'http' import path from 'path' diff --git a/web/content/docs/providers/linear-docs.mdx b/web/content/docs/providers/linear-docs.mdx new file mode 100644 index 00000000..3e348b29 --- /dev/null +++ b/web/content/docs/providers/linear-docs.mdx @@ -0,0 +1,8 @@ +export const info = { + title: 'Linear Docs', + group: 'providers', +} + +import Readme from '../../../../provider/linear-docs/README.md' + + diff --git a/web/pages/index/+Page.tsx b/web/pages/index/+Page.tsx index 4618c7e2..d55a6dde 100644 --- a/web/pages/index/+Page.tsx +++ b/web/pages/index/+Page.tsx @@ -61,6 +61,7 @@ const INTEGRATIONS: IntegrationItem[] = [ { name: 'DevDocs', type: 'provider', slug: 'devdocs' }, { name: 'Jira', type: 'provider', slug: 'jira' }, { name: 'Linear Issues', type: 'provider', slug: 'linear-issues' }, + { name: 'Linear Docs', type: 'provider', slug: 'linear-docs' }, { name: 'Slack', type: 'provider', slug: 'slack' }, { name: 'Google Docs', type: 'provider', slug: 'google-docs' }, { name: 'Notion', type: 'provider', slug: 'notion' },