diff --git a/apps/web/src/getPlugin/getPluginOptions.tsx b/apps/web/src/getPlugin/getPluginOptions.tsx index 0f012683..edc57376 100644 --- a/apps/web/src/getPlugin/getPluginOptions.tsx +++ b/apps/web/src/getPlugin/getPluginOptions.tsx @@ -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) => ({ /** @@ -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, diff --git a/plugin-apps/google-calendar-api/api/auth/callback.ts b/plugin-apps/google-calendar-api/api/auth/callback.ts index b6474798..dedcab41 100644 --- a/plugin-apps/google-calendar-api/api/auth/callback.ts +++ b/plugin-apps/google-calendar-api/api/auth/callback.ts @@ -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: { diff --git a/plugin-apps/linear-api/.gitignore b/plugin-apps/linear-api/.gitignore new file mode 100644 index 00000000..e985853e --- /dev/null +++ b/plugin-apps/linear-api/.gitignore @@ -0,0 +1 @@ +.vercel diff --git a/plugin-apps/linear-api/README.md b/plugin-apps/linear-api/README.md new file mode 100644 index 00000000..d8a89564 --- /dev/null +++ b/plugin-apps/linear-api/README.md @@ -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. \ No newline at end of file diff --git a/plugin-apps/linear-api/api/auth/callback.ts b/plugin-apps/linear-api/api/auth/callback.ts new file mode 100644 index 00000000..8be45bba --- /dev/null +++ b/plugin-apps/linear-api/api/auth/callback.ts @@ -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`); +}; diff --git a/plugin-apps/linear-api/api/auth/index.ts b/plugin-apps/linear-api/api/auth/index.ts new file mode 100644 index 00000000..0ea0ad07 --- /dev/null +++ b/plugin-apps/linear-api/api/auth/index.ts @@ -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()}`); +}; diff --git a/plugin-apps/linear-api/api/auth/refresh.ts b/plugin-apps/linear-api/api/auth/refresh.ts new file mode 100644 index 00000000..6f7b4c8d --- /dev/null +++ b/plugin-apps/linear-api/api/auth/refresh.ts @@ -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", + }, + }); +}; diff --git a/plugin-apps/linear-api/package.json b/plugin-apps/linear-api/package.json new file mode 100644 index 00000000..e6eb48c2 --- /dev/null +++ b/plugin-apps/linear-api/package.json @@ -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 +} diff --git a/plugins/google-calendar/src/server.ts b/plugins/google-calendar/src/server.ts index 473b1eca..79f6afb2 100644 --- a/plugins/google-calendar/src/server.ts +++ b/plugins/google-calendar/src/server.ts @@ -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 => { const accountsTokens = params.accountsTokens ?? (await getTokensFromStore()); diff --git a/plugins/linear/out/plugin.json b/plugins/linear/out/plugin.json new file mode 100644 index 00000000..3f15d6fd --- /dev/null +++ b/plugins/linear/out/plugin.json @@ -0,0 +1,6 @@ +{ + "version": "0.1.0", + "slug": "linear", + "server": true, + "web": true +} \ No newline at end of file diff --git a/plugins/linear/out/server.js b/plugins/linear/out/server.js new file mode 100644 index 00000000..812db7f2 --- /dev/null +++ b/plugins/linear/out/server.js @@ -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; diff --git a/plugins/linear/out/web.js b/plugins/linear/out/web.js new file mode 100644 index 00000000..1e8208f3 --- /dev/null +++ b/plugins/linear/out/web.js @@ -0,0 +1,227 @@ +const v = (m) => ({ plugin: m }); +var l = "/Users/richardguerre/Projects/flow/plugins/linear/src/web.tsx"; +const E = v((m) => { + const e = m.React, r = m.components, _ = () => { + var t; + const n = m.operations.useLazyQuery({ + operationName: "accounts" + }); + return /* @__PURE__ */ e.createElement("div", { __self: void 0, __source: { + fileName: l, + lineNumber: 13, + columnNumber: 7 + } }, (t = n == null ? void 0 : n.data) == null ? void 0 : t.map((i) => { + const o = m.dayjs(i.expiresAt); + return /* @__PURE__ */ e.createElement("div", { key: i.email, className: "flex flex-col gap-2 rounded w-full bg-background-50 shadow px-4 py-2", __self: void 0, __source: { + fileName: l, + lineNumber: 17, + columnNumber: 13 + } }, /* @__PURE__ */ e.createElement("div", { className: "font-semibold", __self: void 0, __source: { + fileName: l, + lineNumber: 21, + columnNumber: 15 + } }, i.email), /* @__PURE__ */ e.createElement("div", { __self: void 0, __source: { + fileName: l, + lineNumber: 22, + columnNumber: 15 + } }, "Expires: ", o.fromNow())); + })); + }, N = (n) => { + const [t, i] = e.useState(!1), { + register: o, + handleSubmit: s, + control: u + } = m.reactHookForm.useForm(), d = async (a) => { + console.log(a); + const c = n.views.find((b) => b.id === a.viewId); + c && await m.operations.mutation({ + operationName: "createList", + input: { + listName: a.name, + viewId: c.id, + account: c.account + } + }); + }; + return /* @__PURE__ */ e.createElement(r.Dialog, { open: t, onOpenChange: i, __self: void 0, __source: { + fileName: l, + lineNumber: 54, + columnNumber: 7 + } }, /* @__PURE__ */ e.createElement("form", { onSubmit: s(d), __self: void 0, __source: { + fileName: l, + lineNumber: 55, + columnNumber: 9 + } }, /* @__PURE__ */ e.createElement(r.DialogTitle, { __self: void 0, __source: { + fileName: l, + lineNumber: 56, + columnNumber: 11 + } }, "Create List"), /* @__PURE__ */ e.createElement(r.DialogContent, { __self: void 0, __source: { + fileName: l, + lineNumber: 57, + columnNumber: 11 + } }, /* @__PURE__ */ e.createElement(r.FormInput, { ...o("name", { + required: "The list must be named something 😄" + }), label: "Name", __self: void 0, __source: { + fileName: l, + lineNumber: 58, + columnNumber: 13 + } }), /* @__PURE__ */ e.createElement(r.FormSelect, { name: "viewId", control: u, __self: void 0, __source: { + fileName: l, + lineNumber: 62, + columnNumber: 13 + } }, /* @__PURE__ */ e.createElement(r.SelectTrigger, { className: "max-w-xs", __self: void 0, __source: { + fileName: l, + lineNumber: 63, + columnNumber: 15 + } }, "Linear view"), /* @__PURE__ */ e.createElement(r.SelectContent, { __self: void 0, __source: { + fileName: l, + lineNumber: 64, + columnNumber: 15 + } }, n.views.map((a) => /* @__PURE__ */ e.createElement(r.SelectItem, { key: a.id, value: a.id, __self: void 0, __source: { + fileName: l, + lineNumber: 66, + columnNumber: 19 + } }, a.name))))), /* @__PURE__ */ e.createElement(r.DialogFooter, { __self: void 0, __source: { + fileName: l, + lineNumber: 73, + columnNumber: 11 + } }, /* @__PURE__ */ e.createElement(r.Button, { __self: void 0, __source: { + fileName: l, + lineNumber: 74, + columnNumber: 13 + } }, "Create")))); + }, f = () => { + var s; + const [n, t] = e.useState(!1), i = m.operations.useLazyQuery({ + operationName: "lists" + }), o = m.operations.useLazyQuery({ + operationName: "views" + }); + return /* @__PURE__ */ e.createElement("div", { __self: void 0, __source: { + fileName: l, + lineNumber: 91, + columnNumber: 7 + } }, (s = i == null ? void 0 : i.data) == null ? void 0 : s.map((u) => /* @__PURE__ */ e.createElement("div", { key: u.name, className: "flex flex-col gap-2 rounded w-full bg-background-50 shadow px-4 py-2", __self: void 0, __source: { + fileName: l, + lineNumber: 94, + columnNumber: 13 + } }, /* @__PURE__ */ e.createElement("div", { className: "font-semibold", __self: void 0, __source: { + fileName: l, + lineNumber: 98, + columnNumber: 15 + } }, u.name), /* @__PURE__ */ e.createElement("div", { __self: void 0, __source: { + fileName: l, + lineNumber: 99, + columnNumber: 15 + } }, u.description), /* @__PURE__ */ e.createElement(r.Button, { danger: !0, onClick: async () => { + await m.operations.mutation({ + operationName: "deleteList", + input: { + listId: u.id + } + }); + }, __self: void 0, __source: { + fileName: l, + lineNumber: 100, + columnNumber: 15 + } }, "Remove"))), /* @__PURE__ */ e.createElement(r.Button, { onClick: () => t(!0), __self: void 0, __source: { + fileName: l, + lineNumber: 116, + columnNumber: 9 + } }, "Create List"), n && /* @__PURE__ */ e.createElement(N, { views: (o == null ? void 0 : o.data) ?? [], __self: void 0, __source: { + fileName: l, + lineNumber: 117, + columnNumber: 18 + } })); + }; + return { + name: "Linear", + settings: { + syncAllViews: { + type: "custom", + render: () => { + const [n, t] = e.useState(!1); + return /* @__PURE__ */ e.createElement("div", { __self: void 0, __source: { + fileName: l, + lineNumber: 130, + columnNumber: 13 + } }, /* @__PURE__ */ e.createElement(r.Button, { onClick: async () => { + t(!0), await m.operations.mutation({ + operationName: "syncAllViews" + }), t(!1); + }, loading: n, __self: void 0, __source: { + fileName: l, + lineNumber: 131, + columnNumber: 15 + } }, "Refresh All Lists"), /* @__PURE__ */ e.createElement("div", { __self: void 0, __source: { + fileName: l, + lineNumber: 143, + columnNumber: 15 + } }, "This will refresh all lists and their issues. This may take a while.")); + } + }, + lists: { + type: "custom", + render: () => /* @__PURE__ */ e.createElement(r.ErrorBoundary, { fallbackRender: ({ + error: n + }) => /* @__PURE__ */ e.createElement("p", { className: "text-sm text-negative-600", __self: void 0, __source: { + fileName: l, + lineNumber: 154, + columnNumber: 24 + } }, n.message), __self: void 0, __source: { + fileName: l, + lineNumber: 152, + columnNumber: 13 + } }, /* @__PURE__ */ e.createElement(e.Suspense, { fallback: "Loading lists...", __self: void 0, __source: { + fileName: l, + lineNumber: 157, + columnNumber: 15 + } }, /* @__PURE__ */ e.createElement(f, { __self: void 0, __source: { + fileName: l, + lineNumber: 158, + columnNumber: 17 + } }))) + }, + "connect-account": { + type: "custom", + render: () => /* @__PURE__ */ e.createElement("div", { className: "flex flex-col gap-2", __self: void 0, __source: { + fileName: l, + lineNumber: 168, + columnNumber: 13 + } }, /* @__PURE__ */ e.createElement("a", { href: `${m.serverOrigin}/api/plugin/linear/auth`, __self: void 0, __source: { + fileName: l, + lineNumber: 169, + columnNumber: 15 + } }, /* @__PURE__ */ e.createElement(r.Button, { __self: void 0, __source: { + fileName: l, + lineNumber: 170, + columnNumber: 17 + } }, "Connect Linear")), /* @__PURE__ */ e.createElement(r.ErrorBoundary, { fallbackRender: ({ + error: n + }) => { + var t, i, o; + return ((o = (i = (t = n.cause) == null ? void 0 : t[0]) == null ? void 0 : i.extensions) == null ? void 0 : o.code) === "NOT_AUTHENTICATED" ? /* @__PURE__ */ e.createElement(e.Fragment, null) : /* @__PURE__ */ e.createElement("p", { className: "text-sm text-negative-600", __self: void 0, __source: { + fileName: l, + lineNumber: 177, + columnNumber: 26 + } }, n.message); + }, __self: void 0, __source: { + fileName: l, + lineNumber: 172, + columnNumber: 15 + } }, /* @__PURE__ */ e.createElement(e.Suspense, { fallback: "Loading connected accounts...", __self: void 0, __source: { + fileName: l, + lineNumber: 180, + columnNumber: 17 + } }, /* @__PURE__ */ e.createElement(_, { __self: void 0, __source: { + fileName: l, + lineNumber: 181, + columnNumber: 19 + } })))) + } + } + }; +}); +export { + E as default +}; diff --git a/plugins/linear/package.json b/plugins/linear/package.json new file mode 100644 index 00000000..21353aa1 --- /dev/null +++ b/plugins/linear/package.json @@ -0,0 +1,19 @@ +{ + "name": "@flowdev/plugin-linear", + "description": "Official Linear plugin for Flow.", + "version": "0.1.0", + "private": false, + "scripts": { + "build:web": "bun --bun run vite build -c vite.web.ts", + "build:server": "bun --bun run vite build -c vite.server.ts", + "build": "bun run build:web && bun run build:server", + "dev:web": "bun --bun run vite build -c vite.web.ts --watch" + }, + "dependencies": { + "@flowdev/plugin": "workspace:*" + }, + "devDependencies": { + "@vitejs/plugin-react": "4.1.0", + "vite": "4.4.11" + } +} diff --git a/plugins/linear/src/global.d.ts b/plugins/linear/src/global.d.ts new file mode 100644 index 00000000..f1decbd2 --- /dev/null +++ b/plugins/linear/src/global.d.ts @@ -0,0 +1,91 @@ +import React from "@types/react"; + +declare global { + type React = typeof React; + + type AccountsOperationData = { + email: string; + expiresAt: string; + }[]; + + type ListsOperationData = { + id: number; + name: string; + slug: string | null; + description: string; + linkedView: { + id: string; + name: string; + icon: string | null; + color: string | null; + account: string; + }; + }[]; + + type ViewsOperationData = ListsOperationData[number]["linkedView"][]; + + type CreateListOperationInput = { + listName: string; + viewId: string; + account: string; + }; + + type DeleteListOperationInput = { + listId: ListsOperationData[number]["id"]; + }; + + type LinearIssueState = { + id: string; + name: string; + type: "triage" | "backlog" | "unstarted" | "started" | "completed" | "canceled"; + /** Hex color */ + color: string; + }; + + type LinearIssue = { + id: string; + title: string; + description: string | null; + state: LinearIssueState; + comments: { + edges: { + node: LinearComment & { + children: { + edges: { + node: LinearComment; + }[]; + }; + }; + }[]; + }; + }; + + type LinearComment = { + id: string; + body: string; + url: string; + updatedAt: string; + user: { + id: string; + isMe: boolean; + name: string; + displayName: string; + avatarUrl: string; + } | null; + botActor: { + id: string; + name: string; + avatarUrl: string; + } | null; + }; + + type LinearIssueItemMin = { + id: string; + title: string; + state: LinearIssueState; + }; + + type LinearIssueItemFull = LinearIssueItemMi & { + description: string; + }; +} diff --git a/plugins/linear/src/server.ts b/plugins/linear/src/server.ts new file mode 100644 index 00000000..9862350e --- /dev/null +++ b/plugins/linear/src/server.ts @@ -0,0 +1,730 @@ +import { Prisma, definePlugin } from "@flowdev/plugin/server"; + +const ACCOUNT_TOKENS_STORE_KEY = "account-tokens"; +const LISTS_STORE_KEY = "lists"; + +export default definePlugin((opts) => { + const PROCESS_WEBHOOK_EVENT_JOB_NAME = `${opts.pluginSlug}-process-webhook`; + const SYNC_ALL_VIEWS_JOB_NAME = `${opts.pluginSlug}-sync-all-views`; + const SYNC_VIEW_JOB_NAME = `${opts.pluginSlug}-sync-view`; + const UPSERT_ISSUE_JOB = `${opts.pluginSlug}-upsert-item-from-issue`; + + const gqlRequest = async (query: string, params: { token: string; variables?: object }) => { + const res = await fetch("https://api.linear.app/graphql", { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.token}`, + }, + body: JSON.stringify({ query, variables: params.variables }), + }); + const json = await res.json(); + if (json.errors) { + throw new opts.GraphQLError(`GitStart API error: ${json.errors[0].message}`); + } + return json.data as T; + }; + + const getTokensFromStore = async () => { + const accountsTokensItem = + await opts.store.getPluginItem(ACCOUNT_TOKENS_STORE_KEY); + if (!accountsTokensItem) { + throw new opts.GraphQLError("User not authenticated.", { + extensions: { + code: "NOT_AUTHENTICATED", + userFriendlyMessage: + "You are not authenticated and will need to connect your Linear account(s) first.", + }, + }); + } + return accountsTokensItem.value; + }; + + const operatonLists = async (listIsInStore?: ListsInStore) => { + const listsItem = await opts.store.getPluginItem(LISTS_STORE_KEY); + if (!listsItem) { + return { data: [] }; + } + const tokenItem = await getTokensFromStore(); + if (!tokenItem) { + return { + data: [], + }; + } + const lists = await opts.prisma.list.findMany({ + where: { id: { in: Object.keys(listsItem.value).map(parseInt) } }, + }); + + return { + data: Object.entries(listsItem.value).map(([listId, listInfo]) => { + const list = lists.find((list) => list.id === parseInt(listId)); + return { + id: list?.id ?? parseInt(listId), + name: list?.name ?? "Unknown or deleted list", + description: list?.description ?? "This list may have been deleted or was left unamed", + slug: list?.slug ?? null, + linkedView: { + id: listInfo.view.id, + name: listInfo.view.name, + color: listInfo.view.color, + icon: listInfo.view.icon, + account: listInfo.account, + }, + }; + }) satisfies ListsOperationData, + }; + }; + + const isRelevantWebhookEvent = (event: WebhookEvent) => { + if (event.type === "Issue" && event.action === "update") { + return true; + } else if (event.type === "Comment") { + return true; + } else { + return false; + } + }; + + return { + onRequest: async (req) => { + if (req.path === "/auth") { + return Response.redirect( + // `https://linear-api-flow-dev.vercel.app/api/auth?api_endpoint=${opts.serverOrigin}/api/plugin/${opts.pluginSlug}/auth/callback`, + `http://localhost:4321/api/auth?api_endpoint=${opts.serverOrigin}/api/plugin/${opts.pluginSlug}/auth/callback`, + ); + } else if (req.path === "/auth/callback") { + const accountsTokensItem = + await opts.store.getPluginItem(ACCOUNT_TOKENS_STORE_KEY); + const body = req.body as AuthCallbackData; + const viewerQuery = await gqlRequest<{ + viewer: { email: string; organization: { id: string; name: string } }; + }>( + /* GraphQL */ ` + query { + viewer { + email + organization { + id + name + } + } + } + `, + { token: body.access_token }, + ); + + const tokensData = { + ...body, + expires_at: opts + .dayjs() + .add((body.expires_in ?? 10) - 10, "seconds") // -10 is a 10 second buffer to account for latency in network requests + .toISOString(), + email: viewerQuery.viewer.email, + organizationId: viewerQuery.viewer.organization.id, + organizationName: viewerQuery.viewer.organization.name, + } as Tokens; + if ("expires_in" in tokensData) delete tokensData.expires_in; // delete expires_in because it's not needed + + await opts.store.setSecretItem(ACCOUNT_TOKENS_STORE_KEY, { + ...(accountsTokensItem?.value ?? {}), + [tokensData.email]: tokensData, + }); + return new Response(); // return 200 + } else if (req.path === "/events/webhook" && req.request.method === "POST") { + const event = req.body as WebhookEvent; + if (!isRelevantWebhookEvent(event)) { + console.log("❌ Could not find Linear issue ID in req.body"); + return new Response(); // return 200 as the webhook event doesn't concern the plugin and we don't want Linear to retry sending it. + } + await opts.pgBoss.send(PROCESS_WEBHOOK_EVENT_JOB_NAME, { + event, + } satisfies JobProcessWebhookEvent); + return new Response(); + } + + return new Response(); + }, + operations: { + /** Get the connected Linear account, if any. */ + accounts: async () => { + const tokenItem = await getTokensFromStore(); + if (!tokenItem) { + return { + data: [], + }; + } + return { + data: Object.entries(tokenItem).map(([email, tokens]) => ({ + email, + expiresAt: tokens.expires_at, + })) satisfies AccountsOperationData, + }; + }, + lists: operatonLists, + views: async () => { + const tokenItem = await getTokensFromStore(); + if (!tokenItem) { + return { + data: [], + }; + } + const views: ViewsOperationData = []; + for (const [account, tokens] of Object.entries(tokenItem)) { + const query = await gqlRequest<{ + customViews: { + edges: { + node: { + id: string; + name: string; + icon: string | null; + color: string | null; + }; + }[]; + }; + }>( + /* GraphQL */ ` + query ViewsQuery { + customViews { + edges { + node { + id + name + icon + color + } + } + } + } + `, + { token: tokens.access_token }, + ).catch(() => null); + views.push( + ...(query?.customViews.edges.map( + (viewEdge) => + ({ + id: viewEdge.node.id, + name: viewEdge.node.name, + icon: viewEdge.node.icon, + color: viewEdge.node.color, + account, + }) satisfies ViewsOperationData[number], + ) ?? []), + ); + } + return { data: views }; + }, + createList: async (input: CreateListOperationInput) => { + /** + * Input: + * - account + * - viewId + * - list name + */ + if (!input.account || !input.viewId || !input.listName) { + throw new opts.GraphQLError("Missing an input", { + extensions: { + code: "CREATE_LIST_MISSING_INPUT", + userFriendlyMessage: "Missing an input. Either `account`, `viewId` or `listName`", + }, + }); + } + const tokenItem = await getTokensFromStore(); + const listsItem = await opts.store.getPluginItem(LISTS_STORE_KEY); + if ( + !Object.values(listsItem?.value ?? {}).find( + (listInfo) => listInfo.view.id === input.viewId, + ) + ) { + throw new opts.GraphQLError("List with this view already exists.", { + extensions: { + code: "CREATE_LIST_ALREADY_EXISTS", + userFriendlyMessage: "A list with this view already exists.", + }, + }); + } + const viewQuery = await gqlRequest<{ + customView: { + id: string; + name: string; + color: string; + icon: string; + } | null; + }>( + /* GraphQL */ ` + query ViewQuery($viewId: String!) { + customView(id: $viewId) { + id + name + color + icon + } + } + `, + { + token: tokenItem[input.account].access_token, + variables: { viewId: input.viewId }, + }, + ); + if (!viewQuery.customView) { + throw new opts.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 createdList = await opts.prisma.list.create({ + data: { + name: input.listName, + slug: input.listName + .toLowerCase() + .replace(/\s/g, "-") + .replace(/['#?]/g, " ") + .slice(0, 50), + description: "List created from Linear plugin.", + }, + }); + const updatedListsInStore = await opts.store.setItem(LISTS_STORE_KEY, { + ...(listsItem?.value ?? {}), + [createdList.id]: { + account: input.account, + view: { + id: input.viewId, + color: viewQuery.customView.color, + icon: viewQuery.customView.icon, + name: viewQuery.customView.name, + }, + }, + }); + if (Object.keys(updatedListsInStore.value).length === 1) { + // setup webhook + const webhook = await gqlRequest<{ + webhookCreate: { + success: boolean; + webhook: { + id: string; + enabled: boolean; + }; + }; + }>( + /* GraphQL */ ` + mutation CreateWebhook($url: String!) { + webhookCreate( + input: { url: $url, allPublicTeams: true, resourceTypes: ["Comment", "Issue"] } + ) { + success + webhook { + id + enabled + } + } + } + `, + { + token: tokenItem[input.account].access_token, + variables: { + url: `${opts.serverOrigin}/api/plugin/${opts.pluginSlug}/events/webhook`, + }, + }, + ); + if (!webhook.webhookCreate.success) { + throw new opts.GraphQLError("Failed to create a webhook.", { + extensions: { + code: "CREATE_LIST_FAILED_WEBHOOK", + userFriendlyMessage: + "Failed to connect to Linear. Please try again or contact the Flow team for help.", + }, + }); + } + } + return { operationName: "lists", data: await operatonLists(updatedListsInStore) }; + }, + deleteList: async (input: DeleteListOperationInput) => { + if (!input.listId) { + throw new opts.GraphQLError("Missing an input", { + extensions: { + code: "DELETE_LIST_MISSING_INPUT", + userFriendlyMessage: "Missing an input. `listId` is required.", + }, + }); + } + const listsItem = await opts.store.getPluginItem(LISTS_STORE_KEY); + if (!listsItem?.value[input.listId]) { + throw new opts.GraphQLError("List with this id doesn't exist.", { + extensions: { + code: "DELETE_LIST_DOESNT_EXIST", + userFriendlyMessage: "A list with this id doesn't exist.", + }, + }); + } + await opts.prisma.list.delete({ where: { id: input.listId } }); + const { [input.listId]: _, ...newListsInStore } = listsItem.value; + const updatedListsInStore = await opts.store.setItem( + LISTS_STORE_KEY, + newListsInStore, + ); + return { operationName: "lists", data: await operatonLists(updatedListsInStore) }; + }, + syncView: async (input) => { + if (!input.listId || !input.viewId || !input.account) { + throw new opts.GraphQLError("Missing an input", { + extensions: { + code: "SYNC_VIEW_MISSING_INPUT", + userFriendlyMessage: + "Missing an input. `listId`, `viewId` and `account` are required.", + }, + }); + } + const tokenItem = await getTokensFromStore(); + if (!tokenItem) { + throw new opts.GraphQLError("User not authenticated.", { + extensions: { + code: "NOT_AUTHENTICATED", + userFriendlyMessage: + "You are not authenticated and will need to connect your Linear account(s) first.", + }, + }); + } + await opts.pgBoss.send(SYNC_VIEW_JOB_NAME, { + listId: input.listId, + viewId: input.viewId, + token: tokenItem[input.account].access_token, + } as JobSyncView); + return { data: true }; + }, + syncAllViews: async () => { + await opts.pgBoss.send(SYNC_ALL_VIEWS_JOB_NAME, {}); + return { data: true }; + }, + }, + handlePgBossWork: (work) => [ + work(SYNC_ALL_VIEWS_JOB_NAME, async () => { + const listsItem = await opts.store.getPluginItem(LISTS_STORE_KEY); + if (!listsItem) return; + const tokenItem = await getTokensFromStore(); + if (!tokenItem) return; + for (const [listId, { view, account }] of Object.entries(listsItem.value)) { + await opts.pgBoss.send(SYNC_VIEW_JOB_NAME, { + listId: parseInt(listId), + viewId: view.id, + token: tokenItem[account].access_token, + } as JobSyncView); + } + }), + work(SYNC_VIEW_JOB_NAME, async (job) => { + const { viewId, listId, token } = job.data as JobSyncView; + const query = await gqlRequest<{ + customView: { + issues: { + edges: { + node: LinearIssue; + }[]; + }; + }; + }>( + /* GraphQL */ ` + query GetView($viewId: String!) { + customView(id: $viewId) { + issues { + edges { + node { + ...LinearIssue + } + } + } + } + } + ${linearIssueFragment} + `, + { token, variables: { viewId } }, + ); + + for (const { node: issue } of query.customView.issues.edges) { + await opts.pgBoss.send(UPSERT_ISSUE_JOB, { issue, listId } as JobUpsertIssue); + } + }), + work(UPSERT_ISSUE_JOB, { batchSize: 5 }, async (jobs) => { + // process 5 events at a time to go a little faster (processing 100 at a time might consume too much memory as webhook events and fetched issues can be large, especially if it includes conference data). + for (const job of jobs) { + const { issue, listId } = job.data as JobUpsertIssue; + const item = await opts.prisma.item.findFirst({ + where: { + pluginDatas: { some: { originalId: issue.id, pluginSlug: opts.pluginSlug } }, + }, + include: { + pluginDatas: { + where: { originalId: issue.id, pluginSlug: opts.pluginSlug }, + select: { id: true }, + }, + }, + }); + + if (!item && !listId) { + console.log("❌ Could not upsert issue as it's unknown which list it belongs to"); + return; + } + + // TODO: have and use user setting to configure terminal status(es). + const isRelevant = issue.state.type !== "canceled" && issue.state.type !== "completed"; + + if (!item && !isRelevant) { + console.log("❌ Issue not upserted as it's not relevant and it's not in the database."); + return; + } + + const itemCommonBetweenUpdateAndCreate = { + title: issue.title, + isRelevant, + inboxPoints: 10, // TODO: make it configurable by the user. + list: { connect: { id: listId } }, + } satisfies Prisma.ItemUpdateInput; + + const min = { + id: issue.id, + title: issue.title, + state: issue.state, + } satisfies LinearIssueItemMin; + const full = { + ...issue, + ...min, + } satisfies LinearIssueItemFull; + + if (item) { + await opts.prisma.item.update({ + where: { id: item.id }, + data: { + ...itemCommonBetweenUpdateAndCreate, + pluginDatas: { + update: { + where: { id: item.pluginDatas[0]?.id }, + data: { min, full }, + }, + }, + }, + }); + } else { + await opts.prisma.item.create({ + data: { + ...itemCommonBetweenUpdateAndCreate, + pluginDatas: { + create: { + pluginSlug: opts.pluginSlug, + originalId: issue.id, + min, + full, + }, + }, + }, + }); + } + + console.log("✔ Upserted item from Linear issue", issue.id); + } + }), + work(PROCESS_WEBHOOK_EVENT_JOB_NAME, async (job) => { + const { event } = job.data as JobProcessWebhookEvent; + console.log("Processing webhook event", event.type, event.action, event.data.id); + let issueId: string | null = null; + if (event.type === "Issue") { + issueId = event.data.id; + } else if (event.type === "Comment") { + issueId = event.data.issue.id; + } + if (!issueId) { + console.log("❌ Could not find Linear issue ID in req.body"); + return; + } + const tokens = await getTokensFromStore(); + if (!tokens) { + console.log("❌ Could not process webhook event as no tokens are found"); + return; + } + const token = Object.values(tokens).find( + (token) => token.organizationId === event.organizationId, + ); + if (!token) { + console.log( + "❌ Could not process webhook event as no token is found for the organization of the event", + ); + return; + } + const issueQuery = await gqlRequest<{ + issue: LinearIssue; + }>( + /* GraphQL */ ` + query GetIssue($issueId: String!) { + issue(id: $issueId) { + ...LinearIssue + } + } + ${linearIssueFragment} + `, + { token: token.access_token, variables: { issueId } }, + ); + await opts.pgBoss.send(UPSERT_ISSUE_JOB, { issue: issueQuery.issue } as JobUpsertIssue); + }), + ], + }; +}); + +type AccountsTokens = { + [account: string]: Tokens; +}; + +type AuthCallbackData = { + access_token: string; + token_type: string; + expires_in: number; + scope: string; +}; + +type Tokens = { + access_token: string; + token_type: string; + expires_at: string; + scope: string; + email: string; + organizationId: string; + organizationName: string; +}; + +type JobSyncView = { viewId: string; listId: number; token: string }; +type JobUpsertIssue = { issue: LinearIssue; listId?: number }; +type JobProcessWebhookEvent = { event: WebhookEvent }; + +type WebhookEvent = WebhookEventIssueUpdate | WebhookEventCommentCreate; +type WebhookEventCommentCreate = { + type: "Comment"; + action: "create"; + createdAt: string; + data: { + id: string; + createdAt: string; + updatedAt: string; + body: string; + issueId: string; + parentId?: string; + userId: string; + reactionData: any[]; + issue: { + id: string; + title: string; + }; + user: { + id: string; + name: string; + }; + }; + url: string; + organizationId: string; + webhookTimestamp: number; + webhookId: string; +}; + +type WebhookEventIssueAttributes = { + updatedAt: string; + number: number; + title: string; + priority: number; + boardOrder: number; + sortOrder: number; + teamId: string; + previousIdentifiers: string[]; + creatorId: string; + stateId: string; + priorityLabel: string; + url: string; + subscriberIds: string[]; + labelIds: string[]; + state: { + id: string; + color: string; + name: string; + type: string; + }; + team: { + id: string; + key: string; + name: string; + }; + labels: { + id: string; + color: string; + name: string; + }[]; + description: string; + descriptionData: string; +}; +type WebhookEventIssueUpdate = { + type: "Issue"; + action: "update"; + createdAt: string; + data: WebhookEventIssueAttributes & { + id: string; + createdAt: string; + }; + updatedFrom: Partial; + url: string; + organizationId: string; + webhookTimestamp: number; + webhookId: string; +}; + +type ListsInStore = Record< + number, + { + view: { id: string; name: string; icon: string | null; color: string | null }; + account: string; + } +>; + +const linearCommentFragment = /* GraphQL */ ` + fragment LinearComment on Comment { + id + body + url + updatedAt + user { + id + isMe + name + displayName + avatarUrl + } + botActor { + id + name + avatarUrl + } + } +`; + +const linearIssueFragment = /* GraphQL */ ` + fragment LinearIssue on Issue { + id + title + state { + id + name + type + color + } + description + comments { + edges { + node { + ...LinearComment + children { + edges { + node { + ...LinearComment + } + } + } + } + } + } + } + ${linearCommentFragment} +`; diff --git a/plugins/linear/src/web.tsx b/plugins/linear/src/web.tsx new file mode 100644 index 00000000..e8f7389a --- /dev/null +++ b/plugins/linear/src/web.tsx @@ -0,0 +1,190 @@ +import { definePlugin } from "@flowdev/plugin/web"; + +export default definePlugin((opts) => { + // @ts-ignore as React is used during compilation and is required to make sure the plugin works with the host's React version + const React = opts.React; + const Flow = opts.components; + + const Accounts = () => { + const accountsQuery = opts.operations.useLazyQuery({ + operationName: "accounts", + }); + return ( +
+ {accountsQuery?.data?.map((account) => { + const expiresAt = opts.dayjs(account.expiresAt); + return ( +
+
{account.email}
+
Expires: {expiresAt.fromNow()}
+
+ ); + })} +
+ ); + }; + + const CreateListDialog = (props: { views: ViewsOperationData }) => { + const [open, setOpen] = React.useState(false); + type Values = { + name: string; + viewId: string; + }; + const { register, handleSubmit, control } = opts.reactHookForm.useForm(); + const onSubmit = async (values: Values) => { + console.log(values); + const view = props.views.find((view) => view.id === values.viewId); + if (!view) { + return; + } + await opts.operations.mutation({ + operationName: "createList", + input: { + listName: values.name, + viewId: view.id, + account: view.account, + } satisfies CreateListOperationInput, + }); + return; + }; + return ( + +
+ Create List + + + + Linear view + + {props.views.map((view) => ( + + {view.name} + + ))} + + + + + Create + +
+
+ ); + }; + + const Lists = () => { + const [open, setOpen] = React.useState(false); + const listsQuery = opts.operations.useLazyQuery({ + operationName: "lists", + }); + const viewsQuery = opts.operations.useLazyQuery({ + operationName: "views", + }); + + return ( +
+ {listsQuery?.data?.map((list) => { + return ( +
+
{list.name}
+
{list.description}
+ { + await opts.operations.mutation({ + operationName: "deleteList", + input: { + listId: list.id, + } satisfies DeleteListOperationInput, + }); + }} + > + Remove + +
+ ); + })} + setOpen(true)}>Create List + {open && } +
+ ); + }; + + return { + name: "Linear", + settings: { + "connect-account": { + type: "custom", + render: () => { + return ( +
+ + Connect Linear + + { + if (error.cause?.[0]?.extensions?.code === "NOT_AUTHENTICATED") { + return <>; + } + return

{error.message}

; + }} + > + + + +
+
+ ); + }, + }, + lists: { + type: "custom", + render: () => { + return ( + { + return

{error.message}

; + }} + > + + + +
+ ); + }, + }, + syncAllViews: { + type: "custom", + render: () => { + const [loading, setLoading] = React.useState(false); + return ( +
+ { + setLoading(true); + await opts.operations.mutation({ + operationName: "syncAllViews", + }); + setLoading(false); + }} + loading={loading} + > + Refresh All Lists + +
This will refresh all lists and their issues. This may take a while.
+
+ ); + }, + }, + }, + }; +}); diff --git a/plugins/linear/tsconfig.json b/plugins/linear/tsconfig.json new file mode 100644 index 00000000..98e18c9c --- /dev/null +++ b/plugins/linear/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ESNext", + "useDefineForClassFields": true, + "lib": ["DOM", "DOM.Iterable", "ESNext"], + "allowJs": true, + "skipLibCheck": true, + "esModuleInterop": false, + "allowSyntheticDefaultImports": true, + "strict": true, + "forceConsistentCasingInFileNames": true, + "module": "ESNext", + "moduleResolution": "Node", + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "noUnusedLocals": true /* Enable error reporting when local variables aren't read. */, + "jsx": "react-jsx", + "disableSourceOfProjectReferenceRedirect": false + }, + "include": ["src"] +} diff --git a/plugins/linear/vite.server.ts b/plugins/linear/vite.server.ts new file mode 100644 index 00000000..554ba25b --- /dev/null +++ b/plugins/linear/vite.server.ts @@ -0,0 +1,36 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { + outDir: "out", + emptyOutDir: false, + lib: { + name: "flow-linear", + entry: "src/server.ts", + formats: ["cjs"], + fileName: () => "server.js", + }, + rollupOptions: { + external: [ + "fs", + "url", + "stream", + "util", + "https", + "querystring", + "http2", + "zlib", + "process", + "child_process", + "os", + "path", + "events", + "crypto", + "buffer", + "tls", + "assert", + "net", + ], + }, + }, +}); diff --git a/plugins/linear/vite.web.ts b/plugins/linear/vite.web.ts new file mode 100644 index 00000000..8a34ca85 --- /dev/null +++ b/plugins/linear/vite.web.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + build: { + outDir: "out", + emptyOutDir: false, + lib: { + name: "flow-linear", + entry: "src/web.tsx", + formats: ["es"], + fileName: () => "web.js", + }, + }, + define: { + "process.env.NODE_ENV": '"production"', + }, + plugins: [react({ jsxRuntime: "classic" })], +});