Skip to content

Commit

Permalink
Merge pull request #37 from richardguerre/plugin-linear
Browse files Browse the repository at this point in the history
  • Loading branch information
richardguerre authored Mar 15, 2024
2 parents fe6057a + 95788d7 commit da19c3d
Show file tree
Hide file tree
Showing 19 changed files with 1,603 additions and 2 deletions.
22 changes: 22 additions & 0 deletions apps/web/src/getPlugin/getPluginOptions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,18 @@ import { Textarea } from "@flowdev/ui/Textarea";
import { FormTextarea } from "@flowdev/ui/FormTextarea";
import { TaskTitleInput } from "../components/TaskTitle";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@flowdev/ui/Tooltip";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogLoading,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
} from "@flowdev/ui/Dialog";

export const getPluginOptions = (slug: string) => ({
/**
Expand Down Expand Up @@ -154,6 +166,16 @@ export const getPluginOptions = (slug: string) => ({
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogLoading,
DialogOverlay,
DialogPortal,
DialogTitle,
DialogTrigger,
Badge,
CardActionButton,
Combobox,
Expand Down
2 changes: 1 addition & 1 deletion plugin-apps/google-calendar-api/api/auth/callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ export default async (request: Request) => {
const storeTokenResponse = await fetch(apiEndpoint, {
method: "POST",
body: JSON.stringify({
email: userInfo.email,
email: userInfo.email, // email used as the key of the `account-tokens` store item (see plugins/google-calendar/src/server.ts)
...tokenData,
}),
headers: {
Expand Down
1 change: 1 addition & 0 deletions plugin-apps/linear-api/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.vercel
13 changes: 13 additions & 0 deletions plugin-apps/linear-api/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
# API for the Linear plugin for Flow

This is a simple API using [Vercel Edge Functions](https://vercel.com/docs/concepts/functions/edge-functions/quickstart) that handles the OAuth flow for the Linear plugin for Flow.

The OAuth flow cannot be part of the `linear/out/server.js` as the Linear client secret cannot be exposed. This API is deployed independently from the plugin itself on Vercel.

## What does it do?

It handles the OAuth flow for the Linear plugin with the following routes:

- `api/auth` - redirects to Linear's OAuth consent screen.
- `api/auth/callback` - handles the callback from Linear's OAuth consent screen and exchanges the code for an access token. Once it has the access token (+ refresh token + expiry), it calls on the plugin's `api/plugin/linear/auth/callback` endpoint in the user's Flow instance to store the access token (+ refresh token + expiry) in the user's Flow instance.
- `api/auth/refresh` - handles the refresh of the access token. It calls on the plugin's `api/plugin/linear/auth/callback` endpoint in the user's Flow instance to store the new access token (+ refresh token + expiry) in the user's Flow instance.
65 changes: 65 additions & 0 deletions plugin-apps/linear-api/api/auth/callback.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/**
* This endpoint handles the callback from Linear's OAuth consent screen and exchanges the code for an access token.
*
* Once the access token is retrieved, it is stored in the user's Flow instance using the API endpoint provided in the state parameter,
* and the user is redirected to the Flow instance (the origin part of the API endpoint)
*/
export const config = {
runtime: "edge",
};

export default async (request: Request) => {
const requestUrl = new URL(request.url);
const code = requestUrl.searchParams.get("code");
const apiEndpoint = requestUrl.searchParams.get("state"); // the state contains the API endpoint to store the tokenData in the user's Flow instance. See api/auth/index.ts for more details.

if (!code) {
return new Response("Missing code in search params", { status: 400 });
} else if (!apiEndpoint) {
return new Response(
"Missing state (i.e the API endpoint to store the tokens) in search params",
{ status: 400 },
);
}

const body = new URLSearchParams({
code,
redirect_uri: `${process.env.REDIRECT_URL_ORIGIN ?? requestUrl.origin}/api/auth/callback`,
client_id: process.env.CLIENT_ID!,
client_secret: process.env.CLIENT_SECRET!,
grant_type: "authorization_code",
});

const tokenResponse = await fetch("https://api.linear.app/oauth/token", {
method: "POST",
body,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});

const data = await tokenResponse.json();

if (data.error) {
return new Response(`Err: ${data.error} - ${data.error_description}`, {
status: 400,
});
}
if (!data.access_token) {
return new Response("Failed to get access token", { status: 500 });
}

const storeTokenResponse = await fetch(apiEndpoint, {
method: "POST",
body: JSON.stringify(data),
headers: { "Content-Type": "application/json" },
});

if (!storeTokenResponse.ok) {
return new Response("Failed to store token data", { status: 500 });
}

const flowInstanceOrigin = new URL(apiEndpoint).origin;

return Response.redirect(`${flowInstanceOrigin}/settings/plugin/linear`);
};
27 changes: 27 additions & 0 deletions plugin-apps/linear-api/api/auth/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* This endpoint redirects to Google's OAuth consent screen.
*/
export const config = {
runtime: "edge",
};

export default (request: Request) => {
const requestUrl = new URL(request.url);
const state = requestUrl.searchParams.get("api_endpoint"); // This is the API endpoint that will be used to store the tokenData in the user's Flow instance. See api/auth/callback.ts for more details.
if (!state) {
return new Response(
"Missing api_endpoint in search params to know which API to hit once the access token is retrieved.",
{ status: 400 },
);
}
const searchParams = new URLSearchParams({
client_id: process.env.CLIENT_ID!,
redirect_uri: `${process.env.REDIRECT_URL_ORIGIN ?? requestUrl.origin}/api/auth/callback`,
response_type: "code",
scope: "read,write",
state,
prompt: "consent",
});

return Response.redirect(`https://linear.app/oauth/authorize?${searchParams.toString()}`);
};
46 changes: 46 additions & 0 deletions plugin-apps/linear-api/api/auth/refresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
/**
* This endpoint handles refreshing the access token given a refresh token.
*
* FIXME: this endpoint has not been tested. it is unsure if linear has a refresh token endpoint/mechanism.
*/
export const config = {
runtime: "edge",
};

export default async (request: Request) => {
const requestUrl = new URL(request.url);
const refreshToken = requestUrl.searchParams.get("refresh_token");

if (!refreshToken) {
return new Response("Missing refresh_token in search params", { status: 400 });
}

const body = new URLSearchParams({
client_id: process.env.CLIENT_ID!,
client_secret: process.env.CLIENT_SECRET!,
refresh_token: refreshToken,
grant_type: "refresh_token",
});

const tokenResponse = await fetch("https://api.linear.app/oauth/token", {
method: "POST",
body,
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
});

const tokenData = await tokenResponse.json();

if (tokenData.error) {
return new Response(`Err: ${tokenData.error} - ${tokenData.error_description}`, {
status: 400,
});
}

return new Response(JSON.stringify(tokenData), {
headers: {
"Content-Type": "application/json",
},
});
};
5 changes: 5 additions & 0 deletions plugin-apps/linear-api/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"name": "@flowdev/plugin-linear-api",
"description": "API for the Google Calendar plugin for Flow. See plugins/linear for more information.",
"private": true
}
2 changes: 1 addition & 1 deletion plugins/google-calendar/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export default definePlugin((opts) => {
* @throws If the user is not authenticated.
* @throws If the access token could not be refreshed.
* @example
* const tokens = await getTokens(params);
* const tokens = await getRefreshedTokens(params);
*/
const getRefreshedTokens = async (params: GetTokenParams): Promise<Tokens> => {
const accountsTokens = params.accountsTokens ?? (await getTokensFromStore());
Expand Down
6 changes: 6 additions & 0 deletions plugins/linear/out/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"version": "0.1.0",
"slug": "linear",
"server": true,
"web": true
}
82 changes: 82 additions & 0 deletions plugins/linear/out/server.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
"use strict";const E=t=>({plugin:t}),I="account-tokens",d="lists",p=E(t=>{const _=`${t.pluginSlug}-process-webhook`,h=`${t.pluginSlug}-sync-all-views`,l=`${t.pluginSlug}-sync-view`,v=`${t.pluginSlug}-upsert-item-from-issue`,u=async(e,i)=>{const n=await(await fetch("https://gateway.gitstart.dev/graphql",{method:"POST",headers:{"Content-Type":"application/json",Authorization:`Bearer ${i.token}`},body:JSON.stringify({query:e,variables:i.variables})})).json();if(n.errors)throw new t.GraphQLError(`GitStart API error: ${n.errors[0].message}`);return n.data},c=async()=>{const e=await t.store.getPluginItem(I);if(!e)throw new t.GraphQLError("User not authenticated.",{extensions:{code:"NOT_AUTHENTICATED",userFriendlyMessage:"You are not authenticated and will need to connect your Linear account(s) first."}});return e.value},w=async e=>{const i=await t.store.getPluginItem(d);if(!i)return{data:[]};if(!await c())return{data:[]};const n=await t.prisma.list.findMany({where:{id:{in:Object.keys(i.value).map(parseInt)}}});return{data:Object.entries(i.value).map(([s,r])=>{const o=n.find(g=>g.id===parseInt(s));return{id:(o==null?void 0:o.id)??parseInt(s),name:(o==null?void 0:o.name)??"Unknown or deleted list",description:(o==null?void 0:o.description)??"This list may have been deleted or was left unamed",slug:(o==null?void 0:o.slug)??null,linkedView:{id:r.view.id,name:r.view.name,color:r.view.color,icon:r.view.icon,account:r.account}}})}};return{onRequest:async e=>{if(e.path==="/auth")return Response.redirect(`https://linear-api-flow-dev.vercel.app/api/auth?api_endpoint=${t.serverOrigin}/api/plugin/${t.pluginSlug}/auth/callback`);if(e.path==="/auth/callback"){const i=await t.store.getPluginItem(I),a=e.body,n={...a,expires_at:t.dayjs().add((a.expires_in??10)-10,"seconds").toISOString()};return"expires_in"in n&&delete n.expires_in,await t.store.setSecretItem(I,{...(i==null?void 0:i.value)??{},[n.email]:n}),new Response}else if(e.path==="/events/webhook"&&e.request.method==="POST"){const i=e.body;return i.id?(await t.pgBoss.send(_,{linearIssue:i}),new Response):(console.log("❌ Could not find Linear issue ID in req.body"),new Response)}return new Response},operations:{accounts:async()=>{const e=await c();return e?{data:Object.entries(e.value).map(([i,a])=>({email:i,expiresAt:a.expires_at}))}:{data:[]}},lists:w,views:async()=>{const e=await c();if(!e)return{data:[]};const i=[];for(const[a,n]of Object.entries(e)){const s=await u(`
query ViewsQuery {
customViews {
edges {
node {
id
name
icon
color
}
}
}
}
`,{token:n.access_token}).catch(()=>null);i.push(...(s==null?void 0:s.customViews.edges.map(r=>({id:r.node.id,name:r.node.name,icon:r.node.icon,color:r.node.color,account:a})))??[])}return{data:i}},createList:async e=>{if(!e.account||!e.viewId||!e.listName)throw new t.GraphQLError("Missing an input",{extensions:{code:"CREATE_LIST_MISSING_INPUT",userFriendlyMessage:"Missing an input. Either `account`, `viewId` or `listName`"}});const i=await c(),a=await t.store.getPluginItem(d);if(!Object.values((a==null?void 0:a.value)??{}).find(r=>r.view.id===e.viewId))throw new t.GraphQLError("List with this view already exists.",{extensions:{code:"CREATE_LIST_ALREADY_EXISTS",userFriendlyMessage:"A list with this view already exists."}});const n=await u(`
query ViewQuery($viewId: String!) {
customView(id: $viewId) {
id
name
color
icon
}
}
`,{token:i[e.account].access_token,variables:{viewId:e.viewId}});if(!n.customView)throw new t.GraphQLError("No view exists with that id.",{extensions:{code:"CREATE_LIST_NO_VIEW_EXISTS",userFriendlyMessage:"The selected view doesn't seem to exist in Linear. Make sure the view exists in your Linear and refresh the page."}});const s=await t.prisma.list.create({data:{name:e.listName,slug:e.listName.toLowerCase().replace(/\s/g,"-").replace(/['#?]/g," ").slice(0,50),description:"List created from Linear plugin."}});return await t.store.setItem(d,{...(a==null?void 0:a.value)??{},[s.id]:{account:e.account,view:{id:e.viewId,color:n.customView.color,icon:n.customView.icon,name:n.customView.name}}}),{operationName:"lists",data:await w()}},deleteList:async e=>{if(!e.listId)throw new t.GraphQLError("Missing an input",{extensions:{code:"DELETE_LIST_MISSING_INPUT",userFriendlyMessage:"Missing an input. `listId` is required."}});const i=await t.store.getPluginItem(d);if(!(i!=null&&i.value[e.listId]))throw new t.GraphQLError("List with this id doesn't exist.",{extensions:{code:"DELETE_LIST_DOESNT_EXIST",userFriendlyMessage:"A list with this id doesn't exist."}});await t.prisma.list.delete({where:{id:e.listId}});const{[e.listId]:a,...n}=i.value;return await t.store.setItem(d,n),{operationName:"lists",data:await w()}},syncView:async e=>{if(!e.listId||!e.viewId||!e.account)throw new t.GraphQLError("Missing an input",{extensions:{code:"SYNC_VIEW_MISSING_INPUT",userFriendlyMessage:"Missing an input. `listId`, `viewId` and `account` are required."}});const i=await c();if(!i)throw new t.GraphQLError("User not authenticated.",{extensions:{code:"NOT_AUTHENTICATED",userFriendlyMessage:"You are not authenticated and will need to connect your Linear account(s) first."}});return await t.pgBoss.send(l,{listId:e.listId,viewId:e.viewId,token:i[e.account].access_token}),{data:!0}},syncAllViews:async()=>(await t.pgBoss.send(h,{}),{data:!0})},handlePgBossWork:e=>[e(h,async()=>{const i=await t.store.getPluginItem(d);if(!i)return;const a=await c();if(a)for(const[n,{view:s,account:r}]of Object.entries(i.value))await t.pgBoss.send(l,{listId:parseInt(n),viewId:s.id,token:a[r].access_token})}),e(l,async i=>{const{viewId:a,listId:n,token:s}=i.data,r=await u(`
query GetView($viewId: String!) {
customView(id: $viewId) {
issues {
edges {
node {
...LinearIssue
}
}
}
}
}
${L}
`,{token:s,variables:{viewId:a}});for(const{node:o}of r.customView.issues.edges)await t.pgBoss.send(v,{issue:o,listId:n})}),e(v,{batchSize:5},async i=>{var a;for(const n of i){const{issue:s,listId:r}=n.data,o=await t.prisma.item.findFirst({where:{pluginDatas:{some:{originalId:s.id,pluginSlug:t.pluginSlug}}},include:{pluginDatas:{where:{originalId:s.id,pluginSlug:t.pluginSlug},select:{id:!0}}}}),g=!0,S={title:s.title,isRelevant:g,inboxPoints:10,list:{connect:{id:r}}},m={id:s.id,title:s.title,state:s.state},y={...s,...m};o?await t.prisma.item.update({where:{id:o.id},data:{...S,pluginDatas:{update:{where:{id:(a=o.pluginDatas[0])==null?void 0:a.id},data:{min:m,full:y}}}}}):await t.prisma.item.create({data:{...S,pluginDatas:{create:{pluginSlug:t.pluginSlug,originalId:s.id,min:m,full:y}}}}),console.log("✔ Upserted item from Linear issue",s.id)}})]}}),f=`
fragment LinearComment on Comment {
id
body
url
updatedAt
user {
id
isMe
name
displayName
avatarUrl
}
botActor {
id
name
avatarUrl
}
}
`,L=`
fragment LinearIssue on Issue {
id
title
state {
id
name
type
color
}
description
comments {
edges {
node {
...LinearComment
children {
edges {
node {
...LinearComment
}
}
}
}
}
}
}
${f}
`;module.exports=p;
Loading

0 comments on commit da19c3d

Please sign in to comment.