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
22 changes: 22 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions provider/linear-docs/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
linear_client_config.json
linear_user_credentials.json
45 changes: 45 additions & 0 deletions provider/linear-docs/README.md
Original file line number Diff line number Diff line change
@@ -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
111 changes: 111 additions & 0 deletions provider/linear-docs/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Identical to provider/linear-issues/auth.ts.
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is identical, should we just created a shared lib file for them, or import one from the other?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought about it but decided to keep it simple for now with code duplication. I do not expect this code (local auth scripts) to be changed. WDYT?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's a fair whack of code… but we don't have CI that runs across both if you just intend on changing one… so probably not a bad idea to keep separate.

// 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<string> {
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()
191 changes: 191 additions & 0 deletions provider/linear-docs/index.ts
Original file line number Diff line number Diff line change
@@ -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<Settings> = {
meta(): MetaResult {
return { name: 'Linear Docs', mentions: {} }
},

async mentions(params: MentionsParams, settingsInput: Settings): Promise<MentionsResult> {
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<ItemsResult> {
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<UserCredentials>

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
}
}
`
Loading