├── .do └── deploy.template.yaml ├── .dockerignore ├── .env.example ├── .eslintrc ├── .github ├── pull_request_template.md └── workflows │ └── build.yml ├── .gitignore ├── .prettierrc ├── CONTRIBUTING.md ├── Dockerfile ├── LICENSE ├── Procfile ├── README.md ├── Setup.md ├── TROUBLESHOOTING.md ├── components ├── ContextProvider.tsx ├── Dashboard.tsx ├── DeployButton.tsx ├── Footer.tsx ├── GitHubAuthButton.tsx ├── Header.tsx ├── Landing.tsx ├── LinearAuthButton.tsx ├── LoginButton.tsx ├── LogoShelf.tsx ├── PageHead.tsx ├── SyncArrow.tsx ├── Tooltip.tsx ├── icons │ └── GitHubLogo.tsx └── logos │ ├── AmieLogo.tsx │ ├── Cal.tsx │ ├── CommonKnowledgeLogo.tsx │ ├── Livepeer.tsx │ ├── Novu.tsx │ ├── PostHog.tsx │ └── Vercel.tsx ├── next-env.d.ts ├── next.config.js ├── package.json ├── pages ├── _app.tsx ├── api │ ├── github │ │ ├── save.ts │ │ ├── token.ts │ │ └── webhook.ts │ ├── health-check.ts │ ├── index.ts │ ├── linear │ │ ├── save.ts │ │ ├── team │ │ │ └── [id].ts │ │ └── token.ts │ ├── save.ts │ ├── syncs.ts │ └── utils.ts └── index.tsx ├── pnpm-lock.yaml ├── postcss.config.js ├── prisma ├── index.ts ├── migrations │ ├── 20220921033234_init │ │ └── migration.sql │ ├── 20221026185709_add_users_table │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── public ├── apple-touch-icon.png ├── favicon-16x16.png ├── favicon-32x32.png ├── favicon.ico ├── favicon.svg └── fonts │ ├── CalSans-SemiBold.woff │ └── CalSans-SemiBold.woff2 ├── styles └── globals.css ├── tailwind.config.js ├── tsconfig.json ├── typings ├── environment.d.ts └── index.ts └── utils ├── apollo.ts ├── constants.ts ├── errors.ts ├── github.ts ├── index.ts ├── linear.ts └── webhook ├── github.handler.ts └── linear.handler.ts /.do/deploy.template.yaml: -------------------------------------------------------------------------------- 1 | spec: 2 | name: linear-github-sync 3 | services: 4 | - name: app 5 | dockerfile_path: Dockerfile 6 | git: 7 | branch: main 8 | repo_clone_url: https://github.com/calcom/synclinear.com.git 9 | envs: 10 | - key: LINEAR_API_KEY 11 | type: SECRET 12 | - key: GITHUB_API_KEY 13 | type: SECRET 14 | - key: DATABASE_URL 15 | scope: RUN_TIME 16 | value: ${db.DATABASE_URL} 17 | databases: 18 | - name: db -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | npm-debug.log 5 | README.md 6 | .next 7 | .git -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_URL="postgresql://username:password@host.com:4321/database" 2 | 3 | # Run node > crypto.randomBytes(16).toString("hex") to generate this 4 | ENCRYPTION_KEY="abcdef0123456789" 5 | 6 | # These are only necessary for self-hosting (solo use) 7 | LINEAR_API_KEY="lin_abc123" 8 | GITHUB_API_KEY="gho_abc123" 9 | 10 | # These are only necessary for self-hosting (team use) 11 | LINEAR_OAUTH_SECRET="abc123" 12 | GITHUB_OAUTH_SECRET="abc123" 13 | NEXT_PUBLIC_GITHUB_OAUTH_ID="" 14 | NEXT_PUBLIC_LINEAR_OAUTH_ID="" 15 | ## Created from the Linear Application Settings page, under "Admin Actions: Create developer token" 16 | ## Used to post anonymous comments to Linear 17 | LINEAR_APPLICATION_ADMIN_KEY="" 18 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "airbnb-typescript", 4 | "plugin:@typescript-eslint/recommended", 5 | "plugin:prettier/recommended", 6 | "plugin:@next/next/recommended", 7 | "plugin:import/typescript" 8 | ], 9 | "parser": "@typescript-eslint/parser", 10 | "plugins": [ 11 | "import", 12 | "@typescript-eslint", 13 | "prettier", 14 | "react" 15 | ], 16 | "settings": { 17 | "import/parsers": { 18 | "@typescript-eslint/parser": [ 19 | ".ts", ".tsx", ".mjs" 20 | ] 21 | } 22 | }, 23 | "rules": { 24 | "prettier/prettier": [ 25 | "off" 26 | ], 27 | "func-names": "off", 28 | "no-unused-vars": "off", 29 | "@typescript-eslint/no-unused-vars": "off", 30 | "max-classes-per-file": "off", 31 | "no-bitwise": "off", 32 | "class-methods-use-this": "off", 33 | "no-new": "off", 34 | "no-plusplus": "off", 35 | "no-param-reassign": "off", 36 | "no-else-return": "off", 37 | "no-useless-return": "off", 38 | "no-return-assign": "off", 39 | "consistent-return": "off", 40 | "no-underscore-dangle": "off", 41 | "@typescript-eslint/return-await": "off", 42 | "no-console": "off", 43 | "no-restricted-syntax": "off", 44 | "@typescript-eslint/no-empty-function": "off", 45 | "import/no-unresolved": "off", 46 | "@typescript-eslint/no-use-before-define": "off" 47 | }, 48 | "parserOptions": { 49 | "project": "./tsconfig.json" 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | What have you changed? Provide context for those unfamiliar with what you're working on! 4 | 5 | ## Test Plan 6 | 7 | How did you test your changes? 8 | 9 | ## Related Issues 10 | 11 | Which issue does this PR resolve? 12 | 13 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build SyncLinear 2 | 3 | on: pull_request 4 | jobs: 5 | build: 6 | runs-on: ubuntu-latest 7 | steps: 8 | - name: Checkout 9 | uses: actions/checkout@v3 10 | 11 | - name: Install Node.js 12 | uses: actions/setup-node@v3 13 | with: 14 | node-version: 16 15 | 16 | - uses: pnpm/action-setup@v2 17 | name: Install pnpm 18 | id: pnpm-install 19 | with: 20 | version: 7 21 | run_install: false 22 | 23 | - name: Get pnpm store directory 24 | id: pnpm-cache 25 | shell: bash 26 | run: | 27 | echo "STORE_PATH=$(pnpm store path)" >> $GITHUB_OUTPUT 28 | 29 | - uses: actions/cache@v3 30 | name: Setup pnpm cache 31 | with: 32 | path: ${{ steps.pnpm-cache.outputs.STORE_PATH }} 33 | key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} 34 | restore-keys: | 35 | ${{ runner.os }}-pnpm-store- 36 | 37 | - name: Install dependencies 38 | run: pnpm install 39 | 40 | - name: Lint 41 | run: pnpm lint 42 | 43 | - name: Build 44 | run: pnpm build 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | /node_modules 3 | /.pnp 4 | .pnp.js 5 | bun.lockb 6 | 7 | # misc 8 | .DS_Store 9 | *.pem 10 | 11 | # next.js 12 | /.next/ 13 | /out/ 14 | /build 15 | 16 | # debug 17 | npm-debug.log* 18 | yarn-debug.log* 19 | yarn-error.log* 20 | .pnpm-debug.log* 21 | *.log 22 | 23 | # env files 24 | .env 25 | .env.local 26 | .env.development.local 27 | .env.test.local 28 | .env.production.local 29 | .idea 30 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": false, 3 | "endOfLine": "crlf", 4 | "tabWidth": 4, 5 | "useTabs": false, 6 | "printWidth": 80, 7 | "arrowParens": "avoid", 8 | "trailingComma": "none" 9 | } 10 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to SyncLinear.com 2 | 3 | Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. 4 | 5 | - Before jumping into a PR be sure to search [existing PRs](https://github.com/calcom/synclinear.com/pulls) or [issues](https://github.com/calcom/synclinear.com/issues) for an open or closed item that relates to your submission. 6 | 7 | ## Developing 8 | 9 | The development branch is `main`. This is the branch that all pull requests should be made against. The changes on the `main` branch are tagged into a release monthly. 10 | 11 | To develop locally: 12 | 13 | 1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your 14 | own GitHub account and then 15 | [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device. 16 | 2. Create a new branch: 17 | 18 | ```sh 19 | git checkout -b MY_BRANCH_NAME 20 | ``` 21 | 22 | 3. Install the dependencies: 23 | 24 | ```sh 25 | pnpm install 26 | ``` 27 | 28 | 4. Start developing and watch for code changes: 29 | 30 | ```sh 31 | pnpm dev 32 | ``` 33 | 34 | ## Building 35 | 36 | You can build the project with: 37 | 38 | ```bash 39 | pnpm build 40 | ``` 41 | 42 | Please be sure that you can make a full production build before pushing code. 43 | 44 | ## Testing 45 | 46 | More info on how to add new tests coming soon. 47 | 48 | ## Linting 49 | 50 | To check the formatting of your code: 51 | 52 | ```sh 53 | pnpm lint 54 | ``` 55 | 56 | If you get errors, be sure to fix them before committing. 57 | 58 | ## Making a Pull Request 59 | 60 | - Be sure to [check the "Allow edits from maintainers" option](https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/allowing-changes-to-a-pull-request-branch-created-from-a-fork) while creating you PR. 61 | - If your PR refers to or fixes an issue, be sure to add `refs #XXX` or `fixes #XXX` to the PR description. Replacing `XXX` with the respective issue number. Se more about [Linking a pull request to an issue 62 | ](https://docs.github.com/en/issues/tracking-your-work-with-issues/linking-a-pull-request-to-an-issue). 63 | - Be sure to fill the PR Template accordingly. 64 | 65 | ## Architecture 66 | 67 | This app has two major components: a "webhook consumer" and a web UI for auth. 68 | 69 | The [webhook consumer](/pages/api/index.ts) is a single endpoint that receives webhooks from Linear and GitHub then decides what to do. 70 | 71 | Data such as GitHub repos, synced issues, and usernames are persisted in PostgreSQL. This is the current data model, as specified in the [schema](/prisma/schema.prisma): 72 | 73 | ![image](https://user-images.githubusercontent.com/36117635/198146657-b37d3eee-c747-4aef-945f-b5ddac984063.png) 74 | 75 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1.4 2 | 3 | #################################################################################################### 4 | ## Build Packages 5 | 6 | FROM node:18-alpine AS builder 7 | WORKDIR /synclinear 8 | 9 | COPY package.json . 10 | RUN corepack enable && corepack prepare 11 | 12 | COPY pnpm-lock.yaml . 13 | RUN pnpm fetch 14 | COPY . . 15 | RUN pnpm install --recursive --offline --frozen-lockfile 16 | 17 | # https://github.com/vercel/next.js/discussions/17641 18 | ARG NEXT_PUBLIC_GITHUB_OAUTH_ID 19 | ARG NEXT_PUBLIC_LINEAR_OAUTH_ID 20 | ENV NEXT_PUBLIC_GITHUB_OAUTH_ID=$NEXT_PUBLIC_GITHUB_OAUTH_ID 21 | ENV NEXT_PUBLIC_LINEAR_OAUTH_ID=$NEXT_PUBLIC_LINEAR_OAUTH_ID 22 | 23 | RUN pnpm run build 24 | 25 | #################################################################################################### 26 | ## Create Production Image 27 | 28 | FROM node:18-alpine AS runtime 29 | WORKDIR /synclinear 30 | 31 | ENV NODE_ENV production 32 | 33 | RUN addgroup --system --gid 1001 nodejs 34 | RUN adduser --system --uid 1001 nextjs 35 | 36 | COPY --from=builder /synclinear/public ./public 37 | COPY --from=builder --chown=nextjs:nodejs /synclinear/.next/standalone ./ 38 | COPY --from=builder --chown=nextjs:nodejs /synclinear/.next/static ./.next/static 39 | 40 | USER nextjs 41 | 42 | EXPOSE 3000 43 | 44 | ENV PORT 3000 45 | 46 | CMD ["node", "server.js"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Cal.com, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Procfile: -------------------------------------------------------------------------------- 1 | # To start the web app 2 | 3 | web: pnpm start 4 | 5 | # To migrate the database during release. Runs after build. 6 | 7 | release: pnpm release 8 | 9 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![image](https://user-images.githubusercontent.com/36117635/228115207-e9392f16-5a5b-4a27-9219-9cb91e3adf7e.png) 2 | 3 | Initially created by [Haris Mehrzad](https://github.com/xPolar) from [Spacedrive](https://github.com/spacedriveapp/linear-github-sync), now extended and maintained by [Cal.com](https://cal.com/) and [Neat.run](https://neat.run/) 4 | 5 | # SyncLinear.com 6 | 7 | This is a system to synchronize Linear tickets and GitHub issues when a specific label is added. 8 | 9 | This allows contributors to work with open source projects without having to give them access to your internal Linear team. 10 | 11 | :wave: **Visit [SyncLinear.com](https://synclinear.com) to get started!** 12 | 13 | --- 14 | 15 | ## Contributing 16 | 17 | Contributions are what make the open source community such an amazing place to learn, inspire, and create. Any contributions you make are greatly appreciated. 18 | 19 | To get started, see the [contributor docs](CONTRIBUTING.md)! 20 | 21 | ## Self-hosting 22 | 23 | If you prefer to host your own database and webhook consumer, we offer one-click deployment on Railway and DigitalOcean: 24 | 25 | [![Deploy on Railway](https://railway.app/button.svg)](https://railway.app/new/template/L__0PR?referralCode=ted) 26 | 27 | [![Deploy to DO](https://www.deploytodo.com/do-btn-blue-ghost.svg)](https://cloud.digitalocean.com/apps/new?repo=https://github.com/calcom/synclinear.com/tree/main) 28 | 29 | For finer-grained control, please see the [self-hosting instructions](Setup.md). 30 | 31 | If you need any help, feel free to raise an [issue](https://github.com/calcom/synclinear.com/issues/new)! 32 | 33 | 34 | ## Troubleshooting 35 | 36 | Some common error scenarios and how to work through them can be found here in the [troubleshooting guide](TROUBLESHOOTING.md) 37 | -------------------------------------------------------------------------------- /Setup.md: -------------------------------------------------------------------------------- 1 | # Self-Hosting Setup 2 | 3 | Welcome to the self-hosting page for **SyncLinear.com**. If something doesn't seem right, please feel free to [open a PR](https://github.com/calcom/synclinear.com/pulls) or [raise an issue](https://github.com/calcom/linear-to-github/issues/new)! 4 | 5 | ## Getting Started 6 | 7 | ### Environment Variables 8 | 9 | 1. Copy the environment file with `cp .env.example .env` 10 | 2. If you'll be sharing your instance with teammates, you'll need to create OAuth apps for both GitHub (under your org > developer settings) and [Linear](https://linear.app/settings/api/applications/new). Replace `NEXT_PUBLIC_LINEAR_OAUTH_ID` and `NEXT_PUBLIC_GITHUB_OAUTH_ID` with your OAuth app IDs (safe to share publicly). Populate the `GITHUB_OAUTH_SECRET` and `LINEAR_OAUTH_SECRET` environment variables ([.env](/.env.example)) with your OAuth secrets. Keep these secret! 11 | 3. Generate an `ENCRYPTION_KEY` by running `node` in a terminal then `crypto.randomBytes(16).toString("hex")`. 12 | 13 | ### Database 14 | 15 | To persist IDs, you'll need to provision a simple SQL database. One easy option is [Railway](https://docs.railway.app/databases/postgresql): 16 | 17 | 1. Click "Start a New Project" → "Provision PostgreSQL" (no sign-up required yet) 18 | 2. Once the DB is ready, focus it → go to "Connect" → "Postgres Connection URL" → hover to copy this URL. It should look like `postgresql://postgres:pass@region.railway.app:1234/railway`. 19 | 20 | To point the app to your database, 21 | 22 | 1. Paste the connection URL (from step 3 above if you're using Railway) to the `DATABASE_URL` variable in `.env` 23 | 2. Run `npx prisma migrate dev` to generate tables with the necessary columns 24 | 3. Run `npx prisma generate` to generate the [ORM](https://www.prisma.io/) for your database 25 | 26 | ### Running the app 27 | 28 | 1. Install dependencies with `npm i` 29 | 2. To start the app locally, run `npm dev` 30 | 3. To receive webhooks locally, expose your local server with `ngrok http 3000` (or the port it's running on). This will give you a temporary public URL. 31 | 4. To start syncing repos to Linear teams, follow the auth flow at that URL 32 | 33 | --- 34 | 35 | That's it! Try creating a Linear issue with the `Public` tag to trigger the webhook and generate a GitHub issue. 36 | 37 | > **Warning** 38 | > Manually modifying a webhook may break the sync. -------------------------------------------------------------------------------- /TROUBLESHOOTING.md: -------------------------------------------------------------------------------- 1 | # Troubleshooting 2 | 3 | Having issues? Read through this guide before raising an issue on the repo to see if any of the following solutions work for you. 4 | 5 | - Also be sure to check [issues](https://github.com/calcom/synclinear.com/issues) for an open or closed item that relates to the issue you are having. 6 | 7 | ## Linear not syncing data to GitHub 8 | 9 | In order for data to sync from linear to GitHub, your Linear account must have both: 10 | - the SyncLinear application installed 11 | - the SyncLinear webhook 12 | 13 | ### Linear application 14 | 15 | To ensure the application is installed, see [Linear application settings](https://linear.app/settings/account/applications). You should see the app installed. 16 | 17 | ![Screenshot 2023-02-09 at 18 39 57](https://user-images.githubusercontent.com/11256663/217907001-09ebda00-bb55-40aa-b71d-ad99513f8328.png) 18 | 19 | ### Linear webhook 20 | 21 | For the webhook, you can see your existing webhooks under [webhook settings](https://linear.app/settings/api/webhooks). 22 | 23 | You should have a Linear webhook with the following configuration. If it's not there and you've already set SyncLinear up, you can add it manually. 24 | 25 | ![Screenshot 2023-02-09 at 18 39 10](https://user-images.githubusercontent.com/11256663/217906823-d8d958f6-eef7-42af-aea5-87c10677d75d.png) 26 | 27 | Your Linear data should now be syncing to GitHub! 28 | 29 | ## GitHub not syncing data to Linear 30 | 31 | If you are having issues with GitHub syncing to Linear, your GitHub account must have both: 32 | - The SyncLinear OAuth application installed 33 | - The SyncLinear webhook in GitHub 34 | 35 | ### GitHub application 36 | 37 | To ensure the application is installed, see [GitHub application settings](https://github.com/settings/applications). 38 | 39 | Under the `Authorized OAuth Apps` You should see the SyncLinear installed. 40 | 41 | ### GitHub webhook 42 | 43 | Finally, we can ensure that the webhook GitHub triggers when an event occurs is functioning correctly. See: 44 | 45 | `https://github.com///settings/hooks` 46 | 47 | You should see a webhook to `https://synclinear.com/api`. Have a look at the **Recent Deliveries** tab. 48 | 49 | Are there any webhooks failing? If your integration is not working and you are seeing errors, please [raise an issue](https://github.com/calcom/synclinear.com/issues/new) with the body/error message of the webhook request. 50 | 51 | Screenshot 2023-02-09 at 18 46 30 52 | -------------------------------------------------------------------------------- /components/ContextProvider.tsx: -------------------------------------------------------------------------------- 1 | import React, { createContext, useState } from "react"; 2 | import { GitHubContext, GitHubRepo, LinearContext, Sync } from "../typings"; 3 | 4 | interface IProps { 5 | syncs: Sync[]; 6 | setSyncs: (syncs: Sync[]) => void; 7 | gitHubToken: string; 8 | setGitHubToken: (token: string) => void; 9 | gitHubUser: GitHubRepo; 10 | setGitHubUser: (user: GitHubRepo) => void; 11 | linearContext: LinearContext; 12 | setLinearContext: (linearContext: LinearContext) => void; 13 | gitHubContext: GitHubContext; 14 | setGitHubContext: (context: GitHubContext) => void; 15 | } 16 | 17 | export const Context = createContext(null); 18 | 19 | const ContextProvider = ({ children }: { children: React.ReactNode }) => { 20 | const [syncs, setSyncs] = useState([]); 21 | const [gitHubToken, setGitHubToken] = useState(""); 22 | const [gitHubUser, setGitHubUser] = useState(); 23 | const [linearContext, setLinearContext] = useState({ 24 | userId: "", 25 | teamId: "", 26 | apiKey: "" 27 | }); 28 | const [gitHubContext, setGitHubContext] = useState({ 29 | userId: "", 30 | repoId: "", 31 | apiKey: "" 32 | }); 33 | 34 | return ( 35 | 49 | {children} 50 | 51 | ); 52 | }; 53 | 54 | export default ContextProvider; 55 | 56 | -------------------------------------------------------------------------------- /components/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { Cross1Icon, InfoCircledIcon, WidthIcon } from "@radix-ui/react-icons"; 2 | import React, { useContext, useState } from "react"; 3 | import { LINEAR } from "../utils/constants"; 4 | import { updateGitHubWebhook } from "../utils/github"; 5 | import { updateLinearWebhook } from "../utils/linear"; 6 | import { Context } from "./ContextProvider"; 7 | import Tooltip from "./Tooltip"; 8 | 9 | const Dashboard = () => { 10 | const { syncs, setSyncs, gitHubContext, linearContext } = 11 | useContext(Context); 12 | 13 | const [loading, setLoading] = useState(false); 14 | 15 | const removeSync = async (syncId: string) => { 16 | if (!syncId || !gitHubContext.apiKey) return; 17 | setLoading(true); 18 | const data = { syncId, accessToken: gitHubContext.apiKey }; 19 | 20 | await fetch("/api/syncs", { 21 | method: "DELETE", 22 | body: JSON.stringify(data) 23 | }) 24 | .then(response => { 25 | if (response.status === 200) { 26 | const newSyncs = syncs.filter(sync => sync.id !== syncId); 27 | setSyncs(newSyncs); 28 | } else { 29 | throw new Error("Error deleting sync"); 30 | } 31 | }) 32 | .catch(error => { 33 | alert(error); 34 | }) 35 | .finally(() => { 36 | setLoading(false); 37 | }); 38 | }; 39 | 40 | const handleMilestoneSyncChange = async ( 41 | e: React.ChangeEvent 42 | ) => { 43 | setLoading(true); 44 | 45 | const checked = e.target.checked || false; 46 | 47 | for (const sync of syncs) { 48 | await updateGitHubWebhook( 49 | gitHubContext.apiKey, 50 | sync.GitHubRepo.repoName, 51 | { 52 | ...(checked && { add_events: ["milestone"] }), 53 | ...(!checked && { remove_events: ["milestone"] }) 54 | } 55 | ); 56 | await updateLinearWebhook( 57 | linearContext.apiKey, 58 | sync.LinearTeam.teamName, 59 | { 60 | resourceTypes: [ 61 | ...LINEAR.WEBHOOK_EVENTS, 62 | ...(checked ? ["Cycle"] : []) 63 | ] 64 | } 65 | ); 66 | } 67 | 68 | setLoading(false); 69 | }; 70 | 71 | if (!syncs?.length) return <>; 72 | 73 | return ( 74 |
75 | {loading &&

Loading...

} 76 |
77 | 83 | 86 | 87 | 88 | 89 |
90 |

Your active syncs

91 | {syncs.map((sync, index) => ( 92 |
96 |
97 |
98 | {sync.LinearTeam?.teamName} 99 |
100 | 101 |
102 | 103 | {sync.GitHubRepo?.repoName?.split("/")?.[0]} 104 | 105 | / 106 | 107 | {sync.GitHubRepo?.repoName?.split("/")?.[1]} 108 | 109 |
110 |
111 | 112 |
removeSync(sync.id)} 114 | className="rounded-full p-3 group cursor-pointer" 115 | > 116 | 117 |
118 |
119 |
120 | ))} 121 |
122 | ); 123 | }; 124 | 125 | export default Dashboard; 126 | 127 | -------------------------------------------------------------------------------- /components/DeployButton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | CheckIcon, 3 | DotsHorizontalIcon, 4 | DoubleArrowUpIcon 5 | } from "@radix-ui/react-icons"; 6 | import React from "react"; 7 | 8 | interface IProps { 9 | loading: boolean; 10 | deployed: boolean; 11 | onDeploy: () => void; 12 | } 13 | 14 | const DeployButton = ({ loading, deployed, onDeploy }: IProps) => { 15 | return ( 16 | 39 | ); 40 | }; 41 | 42 | export default DeployButton; 43 | -------------------------------------------------------------------------------- /components/Footer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import GitHubLogo from "./icons/GitHubLogo"; 3 | 4 | const Footer = () => { 5 | return ( 6 | 41 | ); 42 | }; 43 | 44 | export default Footer; 45 | -------------------------------------------------------------------------------- /components/GitHubAuthButton.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"; 2 | import React, { useCallback, useContext, useEffect, useState } from "react"; 3 | import { GitHubContext, GitHubRepo } from "../typings"; 4 | import { clearURLParams } from "../utils"; 5 | import { v4 as uuid } from "uuid"; 6 | import { GITHUB } from "../utils/constants"; 7 | import DeployButton from "./DeployButton"; 8 | import { 9 | exchangeGitHubToken, 10 | getGitHubRepos, 11 | getGitHubUser, 12 | getRepoWebhook, 13 | getGitHubAuthURL, 14 | saveGitHubContext, 15 | setGitHubWebook 16 | } from "../utils/github"; 17 | import { Context } from "./ContextProvider"; 18 | 19 | interface IProps { 20 | onAuth: (apiKey: string) => void; 21 | onDeployWebhook: (context: GitHubContext) => void; 22 | restoredApiKey: string; 23 | restored: boolean; 24 | } 25 | 26 | const GitHubAuthButton = ({ 27 | onAuth, 28 | onDeployWebhook, 29 | restoredApiKey, 30 | restored 31 | }: IProps) => { 32 | const [repos, setRepos] = useState([]); 33 | const [chosenRepo, setChosenRepo] = useState(); 34 | const [deployed, setDeployed] = useState(false); 35 | const [loading, setLoading] = useState(false); 36 | 37 | const { gitHubToken, setGitHubToken, gitHubUser, setGitHubUser } = 38 | useContext(Context); 39 | 40 | // If present, exchange the temporary auth code for an access token 41 | useEffect(() => { 42 | if (gitHubToken) return; 43 | 44 | // If the URL params have an auth code, we're returning from the GitHub auth page 45 | const authResponse = new URLSearchParams(window.location.search); 46 | if (!authResponse.has("code")) return; 47 | 48 | // Ensure the verification code is unchanged 49 | const verificationCode = localStorage.getItem("github-verification"); 50 | if (!authResponse.get("state")?.includes("github")) return; 51 | if (authResponse.get("state") !== verificationCode) { 52 | alert("GitHub auth returned an invalid code. Please try again."); 53 | clearURLParams(); 54 | return; 55 | } 56 | 57 | setLoading(true); 58 | 59 | // Exchange auth code for access token 60 | const refreshToken = authResponse.get("code"); 61 | exchangeGitHubToken(refreshToken) 62 | .then(body => { 63 | if (body.access_token) setGitHubToken(body.access_token); 64 | else { 65 | alert("No access token returned. Please try again."); 66 | clearURLParams(); 67 | localStorage.removeItem(GITHUB.STORAGE_KEY); 68 | } 69 | setLoading(false); 70 | }) 71 | .catch(err => { 72 | alert(`Error fetching access token: ${err}`); 73 | setLoading(false); 74 | }); 75 | }, []); 76 | 77 | // Restore the GitHub context from local storage 78 | useEffect(() => { 79 | if (restoredApiKey) setGitHubToken(restoredApiKey); 80 | }, [restoredApiKey]); 81 | 82 | // Fetch the user's repos when a token is available 83 | useEffect(() => { 84 | if (!gitHubToken) return; 85 | if (gitHubUser?.id) return; 86 | 87 | onAuth(gitHubToken); 88 | 89 | getGitHubRepos(gitHubToken) 90 | .then(res => { 91 | if (!res?.length) throw new Error("No repos retrieved"); 92 | setRepos( 93 | res?.map(repo => { 94 | return { id: repo.id, name: repo.full_name }; 95 | }) ?? [] 96 | ); 97 | }) 98 | .catch(err => alert(`Error fetching repos: ${err}`)); 99 | 100 | getGitHubUser(gitHubToken) 101 | .then(res => setGitHubUser({ id: res.id, name: res.login })) 102 | .catch(err => alert(`Error fetching user profile: ${err}`)); 103 | }, [gitHubToken]); 104 | 105 | // Disable webhook deployment button if the repo already exists 106 | useEffect(() => { 107 | if (!chosenRepo || !gitHubUser || !gitHubToken) return; 108 | 109 | setLoading(true); 110 | 111 | getRepoWebhook(chosenRepo.name, gitHubToken) 112 | .then(res => { 113 | if (res?.exists) { 114 | setDeployed(true); 115 | onDeployWebhook({ 116 | userId: gitHubUser.id, 117 | repoId: chosenRepo.id, 118 | apiKey: gitHubToken 119 | }); 120 | } else { 121 | setDeployed(false); 122 | } 123 | setLoading(false); 124 | }) 125 | .catch(err => { 126 | alert(`Error checking for existing repo: ${err}`); 127 | setLoading(false); 128 | }); 129 | }, [chosenRepo]); 130 | 131 | const openAuthPage = () => { 132 | // Generate random code to validate against CSRF attack 133 | const verificationCode = `github-${uuid()}`; 134 | localStorage.setItem("github-verification", verificationCode); 135 | 136 | const authURL = getGitHubAuthURL(verificationCode); 137 | window.location.replace(authURL); 138 | }; 139 | 140 | const deployWebhook = useCallback(() => { 141 | if (!chosenRepo || deployed) return; 142 | 143 | const webhookSecret = `${uuid()}`; 144 | saveGitHubContext(chosenRepo, webhookSecret).catch(err => 145 | alert(`Error saving repo to DB: ${err}`) 146 | ); 147 | 148 | setGitHubWebook(gitHubToken, chosenRepo, webhookSecret) 149 | .then(res => { 150 | if (res.errors) { 151 | alert(res.errors[0].message); 152 | return; 153 | } 154 | setDeployed(true); 155 | onDeployWebhook({ 156 | userId: gitHubUser.id, 157 | repoId: chosenRepo.id, 158 | apiKey: gitHubToken 159 | }); 160 | }) 161 | .catch(err => alert(`Error deploying webhook: ${err}`)); 162 | }, [gitHubToken, chosenRepo, deployed, gitHubUser]); 163 | 164 | return ( 165 |
166 | 182 | {repos?.length > 0 && gitHubUser && restored && ( 183 |
184 | 202 | {chosenRepo && ( 203 | 208 | )} 209 |
210 | )} 211 |
212 | ); 213 | }; 214 | 215 | export default GitHubAuthButton; 216 | 217 | -------------------------------------------------------------------------------- /components/Header.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import LoginButton from "./LoginButton"; 3 | 4 | const Header = () => { 5 | return ( 6 |
7 |
8 |
9 | SyncLinear.com 10 |
11 | 12 |
13 |
14 | ); 15 | }; 16 | 17 | export default Header; 18 | 19 | -------------------------------------------------------------------------------- /components/Landing.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | ArrowLeftIcon, 3 | ArrowRightIcon, 4 | ArrowUpIcon, 5 | QuestionMarkCircledIcon 6 | } from "@radix-ui/react-icons"; 7 | import React, { Fragment } from "react"; 8 | import { GENERAL } from "../utils/constants"; 9 | import GitHubLogo from "./icons/GitHubLogo"; 10 | import Tooltip from "./Tooltip"; 11 | 12 | const Landing = () => { 13 | return ( 14 |
15 |
16 |
17 |

What does it do?

18 |

19 | This app lets you mirror Linear and GitHub issues. 20 |

21 |

22 | This way, open-source teams can chat with contributors 23 | without giving access to an internal Linear team. 24 |

25 |
26 |
27 |
28 |

What gets synced?

29 |

30 | Full two-way sync means titles, descriptions, and labels are 31 | magically kept in sync. 32 |

33 |
34 | <> 35 |

Linear

36 |
37 |

GitHub

38 | 39 | {GENERAL.SYNCED_ITEMS.map( 40 | ({ 41 | linearField, 42 | githubField, 43 | toGithub, 44 | toLinear, 45 | notes 46 | }) => ( 47 | 48 | {linearField} 49 |
50 | {!toLinear && !toGithub ? ( 51 | 52 | Coming soon 53 | 54 | ) : ( 55 | <> 56 | {toLinear ? ( 57 | 58 | ) : ( 59 |
60 | )} 61 | {toGithub ? ( 62 | 63 | ) : ( 64 |
65 | )} 66 | 67 | )} 68 |
69 |
70 | {githubField} 71 | {notes && ( 72 | 73 | 74 | 75 | )} 76 |
77 | 78 | ) 79 | )} 80 |
81 |
82 |
83 |

How does it work?

84 |

85 | Under the hood, a webhook pings the app with new issues and 86 | comments. 87 |

88 |

89 | Access tokens are encrypted at rest and in transit, 90 | accessible only by your team's webhook. 91 |

92 |
93 |
94 |

How do I set it up?

95 |
    96 |
  • 97 | 1. If you're setting this up for your team, simply pick 98 | your Linear team and a GitHub repo 99 |
  • 100 |
  • 101 | 2. If you're joining a team, simply authorize the app to 102 | open issues as you 103 |
  • 104 |
  • 105 | 3. Label a Linear ticket as Public (or 106 | label a GitHub issue as linear) to mirror 107 | it 108 |
  • 109 |
  • 4. Comments on that issue will sync back!
  • 110 |
111 | 120 |
121 |
122 |

Missing something?

123 |

124 | This app is completely open-source (even this sentence). If 125 | you're facing a problem or want to add a feature, please 126 | open a pull request! 127 |

128 | 135 |
136 |
137 |

Pricing

138 |

139 | SyncLinear.com is completely free. If you want to donate, 140 | subscribe to a Cal.com{" "} 141 | or Neat plan to support 142 | the development. 143 |

144 |
145 |
146 | ); 147 | }; 148 | 149 | export default Landing; 150 | -------------------------------------------------------------------------------- /components/LinearAuthButton.tsx: -------------------------------------------------------------------------------- 1 | import { CheckIcon, DotsHorizontalIcon } from "@radix-ui/react-icons"; 2 | import React, { useCallback, useEffect, useState } from "react"; 3 | import { LinearContext, LinearObject, LinearTeam } from "../typings"; 4 | import { clearURLParams } from "../utils"; 5 | import { v4 as uuid } from "uuid"; 6 | import { LINEAR } from "../utils/constants"; 7 | import DeployButton from "./DeployButton"; 8 | import { 9 | exchangeLinearToken, 10 | getLinearContext, 11 | checkForExistingTeam, 12 | getLinearAuthURL, 13 | saveLinearContext, 14 | setLinearWebhook 15 | } from "../utils/linear"; 16 | 17 | interface IProps { 18 | onAuth: (apiKey: string) => void; 19 | onDeployWebhook: (context: LinearContext) => void; 20 | restoredApiKey: string; 21 | restored: boolean; 22 | } 23 | 24 | const LinearAuthButton = ({ 25 | onAuth, 26 | onDeployWebhook, 27 | restoredApiKey, 28 | restored 29 | }: IProps) => { 30 | const [accessToken, setAccessToken] = useState(""); 31 | const [teams, setTeams] = useState>([]); 32 | const [chosenTeam, setChosenTeam] = useState(); 33 | const [user, setUser] = useState(); 34 | const [deployed, setDeployed] = useState(false); 35 | const [loading, setLoading] = useState(false); 36 | 37 | // If present, exchange the temporary auth code for an access token 38 | useEffect(() => { 39 | if (accessToken) return; 40 | 41 | // If the URL params have an auth code, we're returning from the Linear auth page 42 | const authResponse = new URLSearchParams(window.location.search); 43 | if (!authResponse.has("code")) return; 44 | 45 | // Ensure the verification code is unchanged 46 | const verificationCode = localStorage.getItem("linear-verification"); 47 | if (!authResponse.get("state")?.includes("linear")) return; 48 | if (authResponse.get("state") !== verificationCode) { 49 | alert("Linear auth returned an invalid code. Please try again."); 50 | return; 51 | } 52 | 53 | setLoading(true); 54 | 55 | // Exchange auth code for access token 56 | const refreshToken = authResponse.get("code"); 57 | exchangeLinearToken(refreshToken) 58 | .then(body => { 59 | if (body.access_token) setAccessToken(body.access_token); 60 | else { 61 | alert("No Linear access token returned. Please try again."); 62 | clearURLParams(); 63 | localStorage.removeItem(LINEAR.STORAGE_KEY); 64 | } 65 | setLoading(false); 66 | }) 67 | .catch(err => { 68 | alert(`Error fetching access token: ${err}`); 69 | setLoading(false); 70 | }); 71 | }, []); 72 | 73 | // Restore the Linear context from local storage 74 | useEffect(() => { 75 | if (restoredApiKey) setAccessToken(restoredApiKey); 76 | }, [restoredApiKey]); 77 | 78 | // Fetch the user ID and available teams when the token is available 79 | useEffect(() => { 80 | if (!accessToken) return; 81 | if (user?.id) return; 82 | 83 | onAuth(accessToken); 84 | 85 | getLinearContext(accessToken) 86 | .then(res => { 87 | if (!res?.data?.teams || !res.data?.viewer) 88 | alert("No Linear user or teams found"); 89 | 90 | setTeams(res.data.teams.nodes); 91 | setUser(res.data.viewer); 92 | }) 93 | .catch(err => alert(`Error fetching labels: ${err}`)); 94 | }, [accessToken]); 95 | 96 | // Disable webhook deployment button if the team already exists 97 | useEffect(() => { 98 | if (!chosenTeam) return; 99 | 100 | setLoading(true); 101 | 102 | checkForExistingTeam(chosenTeam.id) 103 | .then(res => { 104 | if (res?.exists) { 105 | setDeployed(true); 106 | onDeployWebhook({ 107 | userId: user.id, 108 | teamId: chosenTeam.id, 109 | apiKey: accessToken 110 | }); 111 | } else { 112 | setDeployed(false); 113 | } 114 | setLoading(false); 115 | }) 116 | .catch(err => { 117 | alert(`Error checking for existing labels: ${err}`); 118 | setLoading(false); 119 | }); 120 | }, [chosenTeam]); 121 | 122 | const openLinearAuth = () => { 123 | // Generate random code to validate against CSRF attack 124 | const verificationCode = `linear-${uuid()}`; 125 | localStorage.setItem("linear-verification", verificationCode); 126 | 127 | const authURL = getLinearAuthURL(verificationCode); 128 | window.location.replace(authURL); 129 | }; 130 | 131 | const deployWebhook = useCallback(() => { 132 | if (!chosenTeam || deployed) return; 133 | 134 | saveLinearContext(accessToken, chosenTeam).catch(err => 135 | alert(`Error saving labels to DB: ${err}`) 136 | ); 137 | 138 | setLinearWebhook(accessToken, chosenTeam.id) 139 | .then(() => { 140 | setDeployed(true); 141 | onDeployWebhook({ 142 | userId: user.id, 143 | teamId: chosenTeam.id, 144 | apiKey: accessToken 145 | }); 146 | }) 147 | .catch(err => alert(`Error deploying webhook: ${err}`)); 148 | 149 | setDeployed(true); 150 | }, [accessToken, chosenTeam, deployed, user]); 151 | 152 | return ( 153 |
154 | 170 | {teams.length > 0 && restored && ( 171 |
172 | 190 | {chosenTeam && ( 191 | 196 | )} 197 |
198 | )} 199 |
200 | ); 201 | }; 202 | 203 | export default LinearAuthButton; 204 | 205 | -------------------------------------------------------------------------------- /components/LoginButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useState } from "react"; 2 | import { exchangeGitHubToken, getGitHubAuthURL } from "../utils/github"; 3 | import GitHubLogo from "./icons/GitHubLogo"; 4 | import { v4 as uuid } from "uuid"; 5 | import { clearURLParams } from "../utils"; 6 | import { GENERAL, GITHUB, LINEAR } from "../utils/constants"; 7 | import { Cross1Icon } from "@radix-ui/react-icons"; 8 | import { Context } from "./ContextProvider"; 9 | 10 | const LoginButton = () => { 11 | const [loading, setLoading] = useState(false); 12 | 13 | const { gitHubToken, setGitHubToken, gitHubUser, setGitHubUser, setSyncs } = 14 | useContext(Context); 15 | 16 | // If present, exchange the temporary auth code for an access token 17 | useEffect(() => { 18 | if (gitHubToken) return; 19 | 20 | // If the URL params have an auth code, we're returning from the GitHub auth page 21 | const authResponse = new URLSearchParams(window.location.search); 22 | if (!authResponse.has("code")) return; 23 | 24 | // Ensure the verification code is unchanged 25 | const verificationCode = localStorage.getItem( 26 | `${GENERAL.LOGIN_KEY}-verification` 27 | ); 28 | if (!authResponse.get("state")?.includes(GENERAL.LOGIN_KEY)) return; 29 | if (authResponse.get("state") !== verificationCode) { 30 | alert("GitHub auth returned an invalid code. Please try again."); 31 | clearURLParams(); 32 | return; 33 | } 34 | 35 | setLoading(true); 36 | 37 | // Exchange auth code for access token 38 | const refreshToken = authResponse.get("code"); 39 | exchangeGitHubToken(refreshToken) 40 | .then(body => { 41 | if (body.access_token) setGitHubToken(body.access_token); 42 | else { 43 | alert("No access token returned. Please try again."); 44 | clearURLParams(); 45 | } 46 | setLoading(false); 47 | }) 48 | .catch(err => { 49 | alert(`Error fetching access token: ${err}`); 50 | setLoading(false); 51 | }); 52 | }, []); 53 | 54 | const getSyncs = async () => { 55 | const data = { accessToken: gitHubToken }; 56 | 57 | const response = await fetch("/api/syncs", { 58 | method: "POST", 59 | body: JSON.stringify(data) 60 | }); 61 | 62 | return await response.json(); 63 | }; 64 | 65 | // Fetch user's active syncs after auth 66 | useEffect(() => { 67 | if (!gitHubToken) return; 68 | 69 | setLoading(true); 70 | 71 | getSyncs() 72 | .then(res => { 73 | setGitHubUser(res.user); 74 | setSyncs(res.syncs); 75 | }) 76 | .catch(err => { 77 | alert(err); 78 | }) 79 | .finally(() => setLoading(false)); 80 | }, [gitHubToken]); 81 | 82 | const openAuthPage = () => { 83 | // Generate random code to validate against CSRF attack 84 | const verificationCode = `${GENERAL.LOGIN_KEY}-${uuid()}`; 85 | localStorage.setItem( 86 | `${GENERAL.LOGIN_KEY}-verification`, 87 | verificationCode 88 | ); 89 | 90 | const authURL = getGitHubAuthURL(verificationCode); 91 | window.location.replace(authURL); 92 | }; 93 | 94 | const logOut = () => { 95 | setGitHubToken(""); 96 | setGitHubUser(undefined); 97 | setSyncs([]); 98 | clearURLParams(); 99 | localStorage.removeItem(`${GENERAL.LOGIN_KEY}-verification`); 100 | localStorage.removeItem(`${GENERAL.LOGIN_KEY}-token`); 101 | localStorage.removeItem(LINEAR.STORAGE_KEY); 102 | localStorage.removeItem(GITHUB.STORAGE_KEY); 103 | }; 104 | 105 | return ( 106 | 123 | ); 124 | }; 125 | 126 | export default LoginButton; 127 | 128 | -------------------------------------------------------------------------------- /components/LogoShelf.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import AmieLogo from "./logos/AmieLogo"; 3 | import CalLogo from "./logos/Cal"; 4 | import NovuLogo from "./logos/Novu"; 5 | import PostHogLogo from "./logos/PostHog"; 6 | import VercelLogo from "./logos/Vercel"; 7 | 8 | function LogoShelf() { 9 | const LOGOS = [ 10 | { url: "https://cal.com", Logo: CalLogo }, 11 | { url: "https://posthog.com", Logo: PostHogLogo }, 12 | { url: "https://amie.so", Logo: AmieLogo }, 13 | { 14 | url: "https://vercel.com", 15 | Logo: VercelLogo 16 | }, 17 | { url: "https://novu.co", Logo: NovuLogo } 18 | ]; 19 | 20 | return ( 21 |
22 |

Used by

23 |
24 | {LOGOS.map(({ url, Logo }, index) => ( 25 | 26 | 27 | 28 | ))} 29 |
30 |
31 | ); 32 | } 33 | 34 | export default LogoShelf; 35 | 36 | -------------------------------------------------------------------------------- /components/PageHead.tsx: -------------------------------------------------------------------------------- 1 | import Head from "next/head"; 2 | import React from "react"; 3 | 4 | /** 5 | * Common page headers: viewport and favicons for now. 6 | * @param {string} title of the page. 7 | * @param {string} description of the page. Aim for <150 characters for SEO. 8 | * @returns Next.js with title, meta tags, and link tags 9 | */ 10 | function PageHead({ 11 | title = "Linear-GitHub Sync", 12 | description = "Full end-to-end sync of Linear tickets and GitHub issues. An open-source project by Cal.com and Neat.run.", 13 | linkPreview = "https://user-images.githubusercontent.com/8019099/188273531-5ce9fa14-b8cf-4c9b-994b-2e00e3e5d537.png" 14 | }) { 15 | return ( 16 | 17 | {title} 18 | 19 | {/* Meta */} 20 | 24 | 25 | 26 | 27 | {/* OG */} 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | {/* Twitter */} 38 | 39 | 40 | 41 | 42 | 43 | 44 | {/* Links */} 45 | 52 | 53 | 54 | 60 | 66 | 71 | 72 | ); 73 | } 74 | 75 | export default PageHead; 76 | 77 | -------------------------------------------------------------------------------- /components/SyncArrow.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | interface IProps { 4 | direction: "left" | "right"; 5 | active: boolean; 6 | } 7 | 8 | const SyncArrow = ({ direction, active }: IProps) => { 9 | return ( 10 |
15 |
20 |
25 |
26 | ); 27 | }; 28 | 29 | export default SyncArrow; 30 | 31 | -------------------------------------------------------------------------------- /components/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import * as RadixTooltip from "@radix-ui/react-tooltip"; 2 | 3 | export default function Tooltip({ children, content }) { 4 | return ( 5 | 6 | 7 | {children} 8 | 9 | 15 | {content} 16 | 22 | 23 | 24 | 25 | 26 | ); 27 | } 28 | 29 | -------------------------------------------------------------------------------- /components/icons/GitHubLogo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const GitHubLogo = ({ className = "" }: { className?: string }) => { 4 | return ( 5 | 13 | 19 | 20 | ); 21 | }; 22 | 23 | export default GitHubLogo; 24 | 25 | -------------------------------------------------------------------------------- /components/logos/AmieLogo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const AmieLogo = ({ className = "" }: { className?: string }) => { 4 | return ( 5 | 10 | Amie Logo 11 | 15 | 16 | ); 17 | }; 18 | 19 | export default AmieLogo; 20 | 21 | -------------------------------------------------------------------------------- /components/logos/Cal.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const CalLogo = ({ className = "" }: { className?: string }) => { 4 | return ( 5 | 11 | Cal.com logo 12 | 16 | 17 | ); 18 | }; 19 | 20 | export default CalLogo; 21 | 22 | -------------------------------------------------------------------------------- /components/logos/CommonKnowledgeLogo.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const CommonKnowledgeLogo = ({ className = "" }: { className?: string }) => { 4 | return ( 5 | 6 | 10 | 11 | ); 12 | }; 13 | 14 | export default CommonKnowledgeLogo; 15 | 16 | -------------------------------------------------------------------------------- /components/logos/Livepeer.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | function LivepeerLogo({ className }: { className?: string }) { 4 | return ( 5 | 11 | Livepeer Logo 12 | 18 | 24 | 30 | 36 | 42 | 46 | 50 | 51 | 57 | 58 | ); 59 | } 60 | 61 | export default LivepeerLogo; 62 | 63 | -------------------------------------------------------------------------------- /components/logos/Novu.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const NovuLogo = ({ className = "" }: { className?: string }) => { 4 | return ( 5 | 11 | Novu logo 12 | 16 | 22 | 28 | 34 | 35 | ); 36 | }; 37 | 38 | export default NovuLogo; 39 | 40 | -------------------------------------------------------------------------------- /components/logos/PostHog.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const PostHogLogo = ({ className = "" }: { className?: string }) => { 4 | return ( 5 | 11 | PostHog Logo 12 | 16 | 20 | 24 | 28 | 32 | 36 | 40 | 44 | 48 | 52 | 56 | 60 | 64 | 68 | 72 | 76 | 80 | 81 | ); 82 | }; 83 | 84 | export default PostHogLogo; 85 | 86 | -------------------------------------------------------------------------------- /components/logos/Vercel.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | const VercelLogo = ({ className = "" }: { className?: string }) => { 4 | return ( 5 | 11 | Vercel Logo 12 | 16 | 17 | ); 18 | }; 19 | 20 | export default VercelLogo; 21 | 22 | -------------------------------------------------------------------------------- /next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /next.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | module.exports = { 3 | reactStrictMode: true, 4 | productionBrowserSourceMaps: true, 5 | output: "standalone" 6 | }; 7 | 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "linear-github-sync", 3 | "private": true, 4 | "scripts": { 5 | "dev": "next dev", 6 | "build": "npx prisma generate && next build", 7 | "release": "npx prisma migrate deploy", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "devDependencies": { 12 | "@next/eslint-plugin-next": "^13.0.6", 13 | "@octokit/openapi-types": "^12.4.0", 14 | "@octokit/webhooks-types": "^5.8.0", 15 | "@types/node": "^17.0.45", 16 | "@types/react": "18.0.19", 17 | "@typescript-eslint/eslint-plugin": "^5.46.0", 18 | "@typescript-eslint/parser": "^5.46.0", 19 | "autoprefixer": "^10.4.9", 20 | "eslint": "8.16.0", 21 | "eslint-config-airbnb": "^19.0.4", 22 | "eslint-config-airbnb-typescript": "^17.0.0", 23 | "eslint-config-prettier": "^8.5.0", 24 | "eslint-plugin-import": "^2.26.0", 25 | "eslint-plugin-prettier": "^4.2.1", 26 | "eslint-plugin-react": "^7.31.11", 27 | "postcss": "^8.4.16", 28 | "prettier": "^2.8.0", 29 | "prisma": "^4.12.0", 30 | "tailwindcss": "^3.1.8", 31 | "typescript": "4.7.2" 32 | }, 33 | "dependencies": { 34 | "@apollo/client": "^3.6.9", 35 | "@linear/sdk": "^1.22.0", 36 | "@prisma/client": "^4.12.0", 37 | "@radix-ui/react-icons": "^1.1.1", 38 | "@radix-ui/react-tooltip": "^1.0.2", 39 | "canvas-confetti": "^1.5.1", 40 | "got": "^12.5.3", 41 | "graphql": "^16.6.0", 42 | "next": "^12.3.0", 43 | "ngrok": "^4.3.3", 44 | "react": "^18.2.0", 45 | "react-dom": "^18.2.0", 46 | "uuid": "^9.0.0" 47 | }, 48 | "packageManager": "pnpm@8.1.0" 49 | } 50 | 51 | -------------------------------------------------------------------------------- /pages/_app.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ContextProvider from "../components/ContextProvider"; 3 | import "../styles/globals.css"; 4 | 5 | export default function App({ Component, pageProps }) { 6 | return ( 7 | 8 | 9 | 10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /pages/api/github/save.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import prisma from "../../../prisma"; 3 | 4 | // POST /api/github/save 5 | export default async function handle( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ) { 9 | if (!req.body) 10 | return res.status(400).send({ error: "Request is missing body" }); 11 | if (req.method !== "POST") 12 | return res.status(405).send({ 13 | message: "Only POST requests are accepted." 14 | }); 15 | 16 | const { repoId, repoName, webhookSecret } = JSON.parse(req.body); 17 | 18 | try { 19 | const result = await prisma.gitHubRepo.upsert({ 20 | where: { repoId: repoId }, 21 | update: { repoName, webhookSecret }, 22 | create: { 23 | repoId, 24 | repoName, 25 | webhookSecret 26 | } 27 | }); 28 | 29 | return res.status(200).json(result); 30 | } catch (err) { 31 | return res.status(404).send({ error: err }); 32 | } 33 | } 34 | 35 | -------------------------------------------------------------------------------- /pages/api/github/token.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { GITHUB } from "../../../utils/constants"; 3 | 4 | // POST /api/github/token 5 | export default async function handle( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ) { 9 | if (!req.body) { 10 | return res.status(400).send({ error: "Request is missing body" }); 11 | } 12 | if (req.method !== "POST") { 13 | return res.status(405).send({ 14 | message: "Only POST requests are accepted." 15 | }); 16 | } 17 | 18 | const { refreshToken, redirectURI } = req.body; 19 | 20 | if (!refreshToken || !redirectURI) { 21 | return res.status(400).send({ error: "Missing token or redirect URI" }); 22 | } 23 | 24 | // Exchange auth code for access token 25 | const params = { 26 | code: refreshToken, 27 | redirect_uri: redirectURI, 28 | client_id: GITHUB.OAUTH_ID, 29 | client_secret: process.env.GITHUB_OAUTH_SECRET 30 | }; 31 | try { 32 | const payload = await fetch(GITHUB.TOKEN_URL, { 33 | method: "POST", 34 | body: JSON.stringify(params), 35 | headers: { 36 | "Content-Type": "application/json", 37 | Accept: "application/json" 38 | } 39 | }); 40 | 41 | const body = await payload.json(); 42 | return res.status(200).json(body); 43 | } catch (err) { 44 | console.error(err); 45 | return res.status(500).send({ error: err }); 46 | } 47 | } 48 | 49 | -------------------------------------------------------------------------------- /pages/api/github/webhook.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | 3 | // POST /api/github/webhook 4 | export default async function handle( 5 | req: NextApiRequest, 6 | res: NextApiResponse 7 | ) { 8 | if (req.method !== "POST") { 9 | return res.status(405).send({ 10 | error: "Only POST requests are accepted" 11 | }); 12 | } 13 | 14 | const { repoName, webhookUrl } = req.body; 15 | if (!repoName || !webhookUrl) { 16 | return res 17 | .status(400) 18 | .send({ error: "Request is missing repo name or webhook URL" }); 19 | } 20 | 21 | const token = req.headers.authorization; 22 | if (!token) { 23 | return res.status(401).send({ error: "Request is missing auth token" }); 24 | } 25 | 26 | try { 27 | const repoHooksResponse = await fetch( 28 | `https://api.github.com/repos/${repoName}/hooks`, 29 | { 30 | headers: { 31 | "Content-Type": "application/json", 32 | Authorization: `${token}` 33 | } 34 | } 35 | ); 36 | const repoHooks = await repoHooksResponse.json(); 37 | 38 | const repoHook = repoHooks.find( 39 | hook => 40 | hook.config?.url === webhookUrl && 41 | hook.config?.insecure_ssl === "0" && 42 | hook.active === true 43 | ); 44 | 45 | return res.status(200).json({ exists: !!repoHook, id: repoHook?.id }); 46 | } catch (err) { 47 | return res.status(404).send({ error: err }); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /pages/api/health-check.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiResponse } from "next"; 2 | import prisma from "../../prisma"; 3 | 4 | // GET /api/health-check 5 | export default async function handle(_, res: NextApiResponse) { 6 | if (!process.env.DATABASE_URL) { 7 | return res 8 | .status(404) 9 | .send( 10 | "No database URL found. Check the DATABASE_URL environment variable." 11 | ); 12 | } 13 | 14 | try { 15 | await prisma.$connect(); 16 | return res.status(200).send("Successfully connected to the database!"); 17 | } catch (e) { 18 | console.error(e); 19 | return res 20 | .status(503) 21 | .send( 22 | "Unable to connect to the database. Check your connection string." 23 | ); 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /pages/api/index.ts: -------------------------------------------------------------------------------- 1 | import { NextApiRequest, NextApiResponse } from "next"; 2 | import { linearWebhookHandler } from "../../utils/webhook/linear.handler"; 3 | import { githubWebhookHandler } from "../../utils/webhook/github.handler"; 4 | 5 | export default async (req: NextApiRequest, res: NextApiResponse) => { 6 | if (req.method !== "POST") { 7 | return res.status(405).send({ 8 | success: false, 9 | message: "Only POST requests are accepted." 10 | }); 11 | } 12 | 13 | try { 14 | /** 15 | * Linear webhook consumer 16 | */ 17 | if (req.headers["user-agent"] === "Linear-Webhook") { 18 | let originIp = req.headers["x-forwarded-for"]; 19 | 20 | if (Array.isArray(originIp)) { 21 | originIp = originIp[0]; 22 | } 23 | 24 | if (originIp.includes(",")) { 25 | originIp = originIp.split(",")[0].trim(); 26 | } 27 | 28 | const result = await linearWebhookHandler(req.body, originIp); 29 | 30 | if (result) { 31 | return res.status(200).send({ 32 | success: true, 33 | message: result 34 | }); 35 | } 36 | } else { 37 | /** 38 | * GitHub webhook consumer 39 | */ 40 | const result = await githubWebhookHandler( 41 | req.body, 42 | req.headers["x-hub-signature-256"] as string, 43 | req.headers["x-github-event"] as string 44 | ); 45 | 46 | if (result) { 47 | return res.status(200).send({ 48 | success: true, 49 | message: result 50 | }); 51 | } 52 | } 53 | } catch (e) { 54 | return res.status(e.statusCode || 500).send({ 55 | success: false, 56 | message: e.message 57 | }); 58 | } 59 | 60 | console.log("Webhook received."); 61 | return res.status(200).send({ 62 | success: true, 63 | message: "Webhook received." 64 | }); 65 | }; 66 | -------------------------------------------------------------------------------- /pages/api/linear/save.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import prisma from "../../../prisma"; 3 | 4 | // POST /api/linear/save 5 | export default async function handle( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ) { 9 | if (!req.body) 10 | return res.status(400).send({ error: "Request is missing body" }); 11 | if (req.method !== "POST") 12 | return res.status(405).send({ 13 | message: "Only POST requests are accepted." 14 | }); 15 | 16 | const { 17 | teamId, 18 | teamName, 19 | publicLabelId, 20 | canceledStateId, 21 | doneStateId, 22 | toDoStateId 23 | } = JSON.parse(req.body); 24 | 25 | if (!teamId) { 26 | return res 27 | .status(400) 28 | .send({ error: "Failed to save team: missing team ID" }); 29 | } else if (!teamName) { 30 | return res 31 | .status(400) 32 | .send({ error: "Failed to save team: missing team name" }); 33 | } else if ( 34 | [publicLabelId, canceledStateId, doneStateId, toDoStateId].some( 35 | id => id === undefined 36 | ) 37 | ) { 38 | return res 39 | .status(400) 40 | .send({ error: "Failed to save team: missing label or state" }); 41 | } 42 | 43 | try { 44 | const result = await prisma.linearTeam.upsert({ 45 | where: { teamId: teamId }, 46 | update: { 47 | teamName, 48 | publicLabelId, 49 | canceledStateId, 50 | doneStateId, 51 | toDoStateId 52 | }, 53 | create: { 54 | teamId, 55 | teamName, 56 | publicLabelId, 57 | canceledStateId, 58 | doneStateId, 59 | toDoStateId 60 | } 61 | }); 62 | 63 | return res.status(200).json(result); 64 | } catch (err) { 65 | return res.status(400).send({ 66 | error: `Failed to save team with error: ${err.message || ""}` 67 | }); 68 | } 69 | } 70 | 71 | -------------------------------------------------------------------------------- /pages/api/linear/team/[id].ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import prisma from "../../../../prisma"; 3 | 4 | // GET /api/linear/team/:id 5 | export default async function handle( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ) { 9 | if (req.method !== "GET") { 10 | return res.status(405).send({ 11 | error: "Only GET requests are accepted" 12 | }); 13 | } 14 | 15 | const { id } = req.query; 16 | 17 | if (!id) { 18 | return res.status(400).send({ error: "Request is missing team ID" }); 19 | } 20 | 21 | try { 22 | const count: number = await prisma.linearTeam.count({ 23 | where: { teamId: `${id}` } 24 | }); 25 | 26 | return res.status(200).json({ exists: count > 0 }); 27 | } catch (err) { 28 | return res.status(404).send({ error: err }); 29 | } 30 | } 31 | 32 | -------------------------------------------------------------------------------- /pages/api/linear/token.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import { LINEAR } from "../../../utils/constants"; 3 | 4 | // POST /api/linear/token 5 | export default async function handle( 6 | req: NextApiRequest, 7 | res: NextApiResponse 8 | ) { 9 | if (!req.body) { 10 | return res.status(400).send({ error: "Request is missing body" }); 11 | } 12 | if (req.method !== "POST") { 13 | return res.status(405).send({ 14 | message: "Only POST requests are accepted." 15 | }); 16 | } 17 | 18 | const { refreshToken, redirectURI } = req.body; 19 | 20 | if (!refreshToken || !redirectURI) { 21 | return res.status(400).send({ error: "Missing token or redirect URI" }); 22 | } 23 | 24 | // Exchange auth code for access token 25 | const tokenParams = new URLSearchParams({ 26 | code: refreshToken, 27 | redirect_uri: redirectURI, 28 | client_id: LINEAR.OAUTH_ID, 29 | client_secret: process.env.LINEAR_OAUTH_SECRET, 30 | grant_type: "authorization_code" 31 | }); 32 | try { 33 | const payload = await fetch(LINEAR.TOKEN_URL, { 34 | method: "POST", 35 | body: tokenParams, 36 | headers: { "Content-Type": "application/x-www-form-urlencoded" } 37 | }); 38 | 39 | const body = await payload.json(); 40 | return res.status(200).json(body); 41 | } catch (err) { 42 | console.error(err); 43 | return res.status(500).send({ error: err }); 44 | } 45 | } 46 | 47 | -------------------------------------------------------------------------------- /pages/api/save.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import prisma from "../../prisma"; 3 | import { encrypt } from "../../utils"; 4 | 5 | // POST /api/save 6 | export default async function handle( 7 | req: NextApiRequest, 8 | res: NextApiResponse 9 | ) { 10 | if (!req.body) 11 | return res.status(400).send({ message: "Request is missing body" }); 12 | if (req.method !== "POST") { 13 | return res.status(405).send({ 14 | message: "Only POST requests are accepted." 15 | }); 16 | } 17 | 18 | const { github, linear } = JSON.parse(req.body); 19 | 20 | // Check for each required field 21 | if (!github?.userId) { 22 | return res 23 | .status(404) 24 | .send({ error: "Failed to save sync: missing GH user ID" }); 25 | } else if (!github?.repoId) { 26 | return res 27 | .status(404) 28 | .send({ error: "Failed to save sync: missing GH repo ID" }); 29 | } else if (!linear?.userId) { 30 | return res 31 | .status(404) 32 | .send({ error: "Failed to save sync: missing Linear user ID" }); 33 | } else if (!linear?.teamId) { 34 | return res 35 | .status(404) 36 | .send({ error: "Failed to save sync: missing Linear team ID" }); 37 | } else if (!linear?.apiKey || !github?.apiKey) { 38 | return res 39 | .status(404) 40 | .send({ error: "Failed to save sync: missing API key" }); 41 | } 42 | 43 | // Encrypt the API keys 44 | const { hash: linearApiKey, initVector: linearApiKeyIV } = encrypt( 45 | linear.apiKey 46 | ); 47 | const { hash: githubApiKey, initVector: githubApiKeyIV } = encrypt( 48 | github.apiKey 49 | ); 50 | 51 | try { 52 | await prisma.sync.upsert({ 53 | where: { 54 | githubUserId_linearUserId_githubRepoId_linearTeamId: { 55 | githubUserId: github.userId, 56 | githubRepoId: github.repoId, 57 | linearUserId: linear.userId, 58 | linearTeamId: linear.teamId 59 | } 60 | }, 61 | update: { 62 | githubApiKey, 63 | githubApiKeyIV, 64 | linearApiKey, 65 | linearApiKeyIV 66 | }, 67 | create: { 68 | // GitHub 69 | githubUserId: github.userId, 70 | githubRepoId: github.repoId, 71 | githubApiKey, 72 | githubApiKeyIV, 73 | 74 | // Linear 75 | linearUserId: linear.userId, 76 | linearTeamId: linear.teamId, 77 | linearApiKey, 78 | linearApiKeyIV 79 | } 80 | }); 81 | 82 | return res.status(200).send({ message: "Saved successfully" }); 83 | } catch (err) { 84 | console.log("Error saving sync:", err.message); 85 | return res.status(404).send({ 86 | error: `Failed to save sync with error: ${err.message || ""}` 87 | }); 88 | } 89 | } 90 | 91 | -------------------------------------------------------------------------------- /pages/api/syncs.ts: -------------------------------------------------------------------------------- 1 | import type { NextApiRequest, NextApiResponse } from "next"; 2 | import prisma from "../../prisma"; 3 | import { Sync } from "../../typings"; 4 | import { GITHUB } from "../../utils/constants"; 5 | 6 | // POST/DELETE /api/syncs 7 | export default async function handle( 8 | req: NextApiRequest, 9 | res: NextApiResponse 10 | ) { 11 | if (!req.body) 12 | return res.status(400).send({ message: "Request is missing body" }); 13 | 14 | const body = JSON.parse(req.body); 15 | 16 | if (req.method === "POST") { 17 | if (!body.accessToken) { 18 | return res 19 | .status(400) 20 | .send({ message: "Request is missing access token" }); 21 | } 22 | 23 | // The security of this endpoint lies in the fact that user details can only be retrieved 24 | // with a valid GitHub access token, not a user ID. 25 | const response = await fetch(GITHUB.USER_ENDPOINT, { 26 | headers: { Authorization: `Bearer ${body.accessToken}` } 27 | }); 28 | const user = await response.json(); 29 | if (!user?.id) { 30 | return res.status(404).send({ message: "GitHub user not found" }); 31 | } 32 | 33 | try { 34 | const syncs: Sync[] = await prisma.sync.findMany({ 35 | where: { 36 | githubUserId: user.id 37 | }, 38 | // Only return fields that are needed to identify a repo or team 39 | select: { 40 | id: true, 41 | LinearTeam: { 42 | select: { 43 | id: true, 44 | teamName: true 45 | } 46 | }, 47 | GitHubRepo: { 48 | select: { 49 | id: true, 50 | repoName: true 51 | } 52 | } 53 | } 54 | }); 55 | 56 | return res.status(200).json({ 57 | syncs, 58 | user: { name: user.login, id: user.id } 59 | }); 60 | } catch (error) { 61 | console.log("Error fetching syncs: ", error.message); 62 | return res.status(404).send({ message: "Failed to fetch syncs" }); 63 | } 64 | } else if (req.method === "DELETE") { 65 | // Delete a sync 66 | if (!body.syncId) { 67 | return res 68 | .status(400) 69 | .send({ message: "Request is missing sync ID" }); 70 | } 71 | 72 | // The security of this endpoint lies in the fact that user has a valid GH token 73 | const response = await fetch(GITHUB.USER_ENDPOINT, { 74 | headers: { Authorization: `Bearer ${body.accessToken}` } 75 | }); 76 | const user = await response.json(); 77 | if (!user?.id) { 78 | return res 79 | .status(403) 80 | .send({ message: "Must be logged in to delete" }); 81 | } 82 | 83 | try { 84 | console.log("Deleting sync: ", body.syncId); 85 | await prisma.sync.delete({ 86 | where: { id: body.syncId } 87 | }); 88 | return res.status(200).send({ message: "Sync deleted" }); 89 | } catch (error) { 90 | console.log("Error deleting sync: ", error.message); 91 | return res.status(404).send({ error: "Failed to delete sync" }); 92 | } 93 | } else { 94 | return res.status(405).send({ 95 | message: "Request type not supported." 96 | }); 97 | } 98 | } 99 | 100 | -------------------------------------------------------------------------------- /pages/api/utils.ts: -------------------------------------------------------------------------------- 1 | import { LinearClient } from "@linear/sdk"; 2 | import got from "got"; 3 | import type { NextApiResponse } from "next/types"; 4 | import prisma from "../../prisma"; 5 | import { GitHubIssueLabel } from "../../typings"; 6 | import { GITHUB } from "../../utils/constants"; 7 | 8 | /** 9 | * Server-only utility functions 10 | */ 11 | export default (_, res: NextApiResponse) => { 12 | return res.status(200).send({ message: "Nothing to see here!" }); 13 | }; 14 | 15 | /** 16 | * Map a Linear username to a GitHub username in the database if not already mapped 17 | * 18 | * @param {LinearClient} linearClient to get the authenticated Linear user's info 19 | * @param {number} githubUserId 20 | * @param {string} linearUserId 21 | * @param {string} userAgentHeader to respect GitHub API's policies 22 | * @param {string} githubAuthHeader to get the authenticated GitHub user's info 23 | */ 24 | export const upsertUser = async ( 25 | linearClient: LinearClient, 26 | githubUserId: number, 27 | linearUserId: string, 28 | userAgentHeader: string, 29 | githubAuthHeader: string 30 | ): Promise => { 31 | const existingUser = await prisma.user.findFirst({ 32 | where: { 33 | AND: { 34 | githubUserId: githubUserId, 35 | linearUserId: linearUserId 36 | } 37 | } 38 | }); 39 | 40 | if (!existingUser) { 41 | console.log("Adding user to users table"); 42 | 43 | const linearUser = await linearClient.viewer; 44 | 45 | const githubUserResponse = await got.get( 46 | `https://api.github.com/user`, 47 | { 48 | headers: { 49 | "User-Agent": userAgentHeader, 50 | Authorization: githubAuthHeader 51 | } 52 | } 53 | ); 54 | const githubUserBody = JSON.parse(githubUserResponse.body); 55 | 56 | await prisma.user.upsert({ 57 | where: { 58 | githubUserId_linearUserId: { 59 | githubUserId: githubUserId, 60 | linearUserId: linearUserId 61 | } 62 | }, 63 | update: { 64 | githubUsername: githubUserBody.login, 65 | githubEmail: githubUserBody.email ?? "", 66 | linearUsername: linearUser.displayName, 67 | linearEmail: linearUser.email ?? "" 68 | }, 69 | create: { 70 | githubUserId: githubUserId, 71 | linearUserId: linearUserId, 72 | githubUsername: githubUserBody.login, 73 | githubEmail: githubUserBody.email ?? "", 74 | linearUsername: linearUser.displayName, 75 | linearEmail: linearUser.email ?? "" 76 | } 77 | }); 78 | } 79 | 80 | return; 81 | }; 82 | 83 | /** 84 | * Translate users' usernames from one platform to the other 85 | * @param {string[]} usernames of Linear or GitHub users 86 | * @returns {string[]} Linear and GitHub usernames corresponding to the provided usernames 87 | */ 88 | export const mapUsernames = async ( 89 | usernames: string[], 90 | platform: "linear" | "github" 91 | ): Promise> => { 92 | console.log(`Mapping ${platform} usernames`); 93 | 94 | const filters = usernames.map((username: string) => { 95 | return { [`${platform}Username`]: username }; 96 | }); 97 | 98 | const existingUsers = await prisma.user.findMany({ 99 | where: { 100 | OR: filters 101 | }, 102 | select: { 103 | githubUsername: true, 104 | linearUsername: true 105 | } 106 | }); 107 | 108 | if (!existingUsers?.length) return []; 109 | 110 | return existingUsers; 111 | }; 112 | 113 | /** 114 | * Replace all mentions of users with their username in the corresponding platform 115 | * @param {string} body the message to be sent 116 | * @returns {string} the message with all mentions replaced 117 | */ 118 | export const replaceMentions = async ( 119 | body: string, 120 | platform: "linear" | "github" 121 | ) => { 122 | if (!body?.match(/(?<=@)\w+/g)) return body; 123 | 124 | console.log(`Replacing ${platform} mentions`); 125 | 126 | let sanitizedBody = body; 127 | 128 | const mentionMatches = sanitizedBody.matchAll(/(?<=@)\w+/g) ?? []; 129 | const userMentions = 130 | Array.from(mentionMatches)?.map(mention => mention?.[0]) ?? []; 131 | 132 | const userMentionReplacements = await mapUsernames(userMentions, platform); 133 | 134 | userMentionReplacements.forEach(mention => { 135 | sanitizedBody = sanitizedBody.replace( 136 | new RegExp(`@${mention[`${platform}Username`]}`, "g"), 137 | `@${ 138 | mention[ 139 | `${platform === "linear" ? "github" : "linear"}Username` 140 | ] 141 | }` 142 | ); 143 | }); 144 | 145 | return sanitizedBody; 146 | }; 147 | 148 | export const createLabel = async ({ 149 | repoFullName, 150 | label, 151 | githubAuthHeader, 152 | userAgentHeader 153 | }: { 154 | repoFullName: string; 155 | label: GitHubIssueLabel; 156 | githubAuthHeader: string; 157 | userAgentHeader: string; 158 | }): Promise<{ 159 | createdLabel?: { name: string } | undefined; 160 | error?: boolean; 161 | }> => { 162 | let error = false; 163 | 164 | const createdLabelResponse = await got.post( 165 | `${GITHUB.REPO_ENDPOINT}/${repoFullName}/labels`, 166 | { 167 | json: { 168 | name: label.name, 169 | color: label.color?.replace("#", ""), 170 | description: "Created by Linear-GitHub Sync" 171 | }, 172 | headers: { 173 | Authorization: githubAuthHeader, 174 | "User-Agent": userAgentHeader 175 | }, 176 | throwHttpErrors: false 177 | } 178 | ); 179 | 180 | const createdLabel = JSON.parse(createdLabelResponse.body); 181 | 182 | if ( 183 | createdLabelResponse.statusCode > 201 && 184 | createdLabel.errors?.[0]?.code !== "already_exists" 185 | ) { 186 | error = true; 187 | } else if (createdLabel.errors?.[0]?.code === "already_exists") { 188 | return { error: false }; 189 | } 190 | 191 | return { createdLabel, error }; 192 | }; 193 | 194 | export const applyLabel = async ({ 195 | repoFullName, 196 | issueNumber, 197 | labelNames, 198 | githubAuthHeader, 199 | userAgentHeader 200 | }: { 201 | repoFullName: string; 202 | issueNumber: number; 203 | labelNames: string[]; 204 | githubAuthHeader: string; 205 | userAgentHeader: string; 206 | }): Promise<{ error: boolean }> => { 207 | let error = false; 208 | 209 | const appliedLabelResponse = await got.post( 210 | `${GITHUB.REPO_ENDPOINT}/${repoFullName}/issues/${issueNumber}/labels`, 211 | { 212 | json: { 213 | labels: labelNames 214 | }, 215 | headers: { 216 | Authorization: githubAuthHeader, 217 | "User-Agent": userAgentHeader 218 | } 219 | } 220 | ); 221 | 222 | if (appliedLabelResponse.statusCode > 201) { 223 | error = true; 224 | } 225 | 226 | return { error }; 227 | }; 228 | 229 | export const createComment = async ({ 230 | repoFullName, 231 | issueNumber, 232 | body, 233 | githubAuthHeader, 234 | userAgentHeader 235 | }: { 236 | repoFullName: string; 237 | issueNumber: number; 238 | body: string; 239 | githubAuthHeader: string; 240 | userAgentHeader: string; 241 | }): Promise<{ error: boolean }> => { 242 | let error = false; 243 | 244 | const commentResponse = await got.post( 245 | `${GITHUB.REPO_ENDPOINT}/${repoFullName}/issues/${issueNumber}/comments`, 246 | { 247 | json: { 248 | body 249 | }, 250 | headers: { 251 | Authorization: githubAuthHeader, 252 | "User-Agent": userAgentHeader 253 | } 254 | } 255 | ); 256 | 257 | if (commentResponse.statusCode > 201) { 258 | error = true; 259 | } 260 | 261 | return { error }; 262 | }; 263 | -------------------------------------------------------------------------------- /pages/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useEffect, useState } from "react"; 2 | import Footer from "../components/Footer"; 3 | import GitHubAuthButton from "../components/GitHubAuthButton"; 4 | import Landing from "../components/Landing"; 5 | import LinearAuthButton from "../components/LinearAuthButton"; 6 | import PageHead from "../components/PageHead"; 7 | import SyncArrow from "../components/SyncArrow"; 8 | import { saveSync } from "../utils"; 9 | import confetti from "canvas-confetti"; 10 | import { GITHUB, LINEAR } from "../utils/constants"; 11 | import { ExternalLinkIcon } from "@radix-ui/react-icons"; 12 | import Header from "../components/Header"; 13 | import { Context } from "../components/ContextProvider"; 14 | import Dashboard from "../components/Dashboard"; 15 | import LogoShelf from "../components/LogoShelf"; 16 | 17 | const index = () => { 18 | const { linearContext, setLinearContext, gitHubContext, setGitHubContext } = 19 | useContext(Context); 20 | const [synced, setSynced] = useState(false); 21 | const [restored, setRestored] = useState(false); 22 | 23 | // Load the saved context from localStorage 24 | useEffect(() => { 25 | if (localStorage.getItem(LINEAR.STORAGE_KEY)) { 26 | setLinearContext( 27 | JSON.parse(localStorage.getItem(LINEAR.STORAGE_KEY)) 28 | ); 29 | setRestored(true); 30 | } 31 | if (localStorage.getItem(GITHUB.STORAGE_KEY)) { 32 | setGitHubContext( 33 | JSON.parse(localStorage.getItem(GITHUB.STORAGE_KEY)) 34 | ); 35 | setRestored(true); 36 | } 37 | }, []); 38 | 39 | // Save the context to localStorage or server 40 | useEffect(() => { 41 | if (linearContext.apiKey) { 42 | localStorage.setItem( 43 | LINEAR.STORAGE_KEY, 44 | JSON.stringify(linearContext) 45 | ); 46 | } 47 | if (gitHubContext.apiKey) { 48 | localStorage.setItem( 49 | GITHUB.STORAGE_KEY, 50 | JSON.stringify(gitHubContext) 51 | ); 52 | } 53 | 54 | if (linearContext.teamId && gitHubContext.repoId) { 55 | saveSync(linearContext, gitHubContext) 56 | .then(res => { 57 | if (res.error) { 58 | alert(res.error); 59 | return; 60 | } 61 | 62 | setSynced(true); 63 | 64 | confetti({ 65 | disableForReducedMotion: true, 66 | particleCount: 250, 67 | spread: 360, 68 | ticks: 500, 69 | decay: 0.95 70 | }); 71 | 72 | localStorage.clear(); 73 | }) 74 | .catch(err => { 75 | alert(err); 76 | setSynced(false); 77 | }); 78 | } 79 | }, [gitHubContext, linearContext]); 80 | 81 | return ( 82 |
83 | 84 |
85 |
86 |
87 | 88 | Beta 89 | 90 |

Linear-GitHub Sync

91 |

92 | End-to-end sync of Linear tickets and GitHub issues 93 |

94 |
95 | 96 |
97 | 101 | setLinearContext({ 102 | ...linearContext, 103 | apiKey 104 | }) 105 | } 106 | onDeployWebhook={setLinearContext} 107 | /> 108 |
109 | 115 | 121 |
122 | 126 | setGitHubContext({ 127 | ...gitHubContext, 128 | apiKey 129 | }) 130 | } 131 | onDeployWebhook={setGitHubContext} 132 | /> 133 |
134 |
139 |

Synced!

140 |

141 | To test your connection, tag a Linear issue as{" "} 142 | Public: 143 |

144 | 148 |
149 | 150 |
151 | 152 |
153 |
154 | ); 155 | }; 156 | 157 | export default index; 158 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /prisma/index.ts: -------------------------------------------------------------------------------- 1 | import { PrismaClient } from "@prisma/client"; 2 | 3 | // PrismaClient is attached to the `global` object in development to prevent 4 | // exhausting your database connection limit. 5 | // 6 | // Learn more: 7 | // https://pris.ly/d/help/next-js-best-practices 8 | 9 | let prisma: PrismaClient; 10 | 11 | if (process.env.NODE_ENV === "production") { 12 | prisma = new PrismaClient(); 13 | } else { 14 | if (!global.prisma) { 15 | global.prisma = new PrismaClient(); 16 | } 17 | prisma = global.prisma; 18 | } 19 | export default prisma; 20 | 21 | -------------------------------------------------------------------------------- /prisma/migrations/20220921033234_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "synced_issues" ( 3 | "id" TEXT NOT NULL, 4 | "githubIssueNumber" INTEGER NOT NULL, 5 | "linearIssueNumber" INTEGER NOT NULL, 6 | "githubIssueId" INTEGER NOT NULL, 7 | "linearIssueId" TEXT NOT NULL, 8 | "linearTeamId" TEXT NOT NULL, 9 | "githubRepoId" INTEGER NOT NULL, 10 | 11 | CONSTRAINT "synced_issues_pkey" PRIMARY KEY ("id") 12 | ); 13 | 14 | -- CreateTable 15 | CREATE TABLE "linear_teams" ( 16 | "id" TEXT NOT NULL, 17 | "teamId" TEXT NOT NULL, 18 | "teamName" TEXT NOT NULL, 19 | "publicLabelId" TEXT NOT NULL, 20 | "canceledStateId" TEXT NOT NULL, 21 | "doneStateId" TEXT NOT NULL, 22 | "toDoStateId" TEXT NOT NULL, 23 | 24 | CONSTRAINT "linear_teams_pkey" PRIMARY KEY ("id") 25 | ); 26 | 27 | -- CreateTable 28 | CREATE TABLE "github_repos" ( 29 | "id" TEXT NOT NULL, 30 | "repoId" INTEGER NOT NULL, 31 | "repoName" TEXT NOT NULL, 32 | "webhookSecret" TEXT NOT NULL, 33 | 34 | CONSTRAINT "github_repos_pkey" PRIMARY KEY ("id") 35 | ); 36 | 37 | -- CreateTable 38 | CREATE TABLE "syncs" ( 39 | "id" TEXT NOT NULL, 40 | "githubUserId" INTEGER NOT NULL, 41 | "linearUserId" TEXT NOT NULL, 42 | "githubRepoId" INTEGER NOT NULL, 43 | "githubApiKey" TEXT NOT NULL, 44 | "githubApiKeyIV" TEXT NOT NULL, 45 | "linearTeamId" TEXT NOT NULL, 46 | "linearApiKey" TEXT NOT NULL, 47 | "linearApiKeyIV" TEXT NOT NULL, 48 | 49 | CONSTRAINT "syncs_pkey" PRIMARY KEY ("id") 50 | ); 51 | 52 | -- CreateIndex 53 | CREATE UNIQUE INDEX "linear_teams_teamId_key" ON "linear_teams"("teamId"); 54 | 55 | -- CreateIndex 56 | CREATE UNIQUE INDEX "github_repos_repoId_key" ON "github_repos"("repoId"); 57 | 58 | -- AddForeignKey 59 | ALTER TABLE "synced_issues" ADD CONSTRAINT "synced_issues_linearTeamId_fkey" FOREIGN KEY ("linearTeamId") REFERENCES "linear_teams"("teamId") ON DELETE RESTRICT ON UPDATE CASCADE; 60 | 61 | -- AddForeignKey 62 | ALTER TABLE "synced_issues" ADD CONSTRAINT "synced_issues_githubRepoId_fkey" FOREIGN KEY ("githubRepoId") REFERENCES "github_repos"("repoId") ON DELETE RESTRICT ON UPDATE CASCADE; 63 | 64 | -- AddForeignKey 65 | ALTER TABLE "syncs" ADD CONSTRAINT "syncs_linearTeamId_fkey" FOREIGN KEY ("linearTeamId") REFERENCES "linear_teams"("teamId") ON DELETE RESTRICT ON UPDATE CASCADE; 66 | 67 | -- AddForeignKey 68 | ALTER TABLE "syncs" ADD CONSTRAINT "syncs_githubRepoId_fkey" FOREIGN KEY ("githubRepoId") REFERENCES "github_repos"("repoId") ON DELETE RESTRICT ON UPDATE CASCADE; 69 | -------------------------------------------------------------------------------- /prisma/migrations/20221026185709_add_users_table/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "users" ( 3 | "id" TEXT NOT NULL, 4 | "githubUserId" INTEGER NOT NULL, 5 | "githubUsername" TEXT NOT NULL, 6 | "githubEmail" TEXT, 7 | "linearUserId" TEXT NOT NULL, 8 | "linearUsername" TEXT NOT NULL, 9 | "linearEmail" TEXT, 10 | 11 | CONSTRAINT "users_pkey" PRIMARY KEY ("id") 12 | ); 13 | 14 | -- CreateIndex 15 | CREATE UNIQUE INDEX "users_githubUserId_linearUserId_key" ON "users"("githubUserId", "linearUserId"); 16 | -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "postgresql" -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | datasource db { 2 | provider = "postgres" 3 | url = env("DATABASE_URL") 4 | } 5 | 6 | generator client { 7 | provider = "prisma-client-js" 8 | binaryTargets = ["native", "linux-musl"] 9 | } 10 | 11 | model SyncedIssue { 12 | id String @id @default(cuid()) 13 | 14 | githubIssueNumber Int 15 | linearIssueNumber Int 16 | 17 | githubIssueId Int 18 | linearIssueId String 19 | 20 | linearTeamId String 21 | LinearTeam LinearTeam @relation(fields: [linearTeamId], references: [teamId]) 22 | 23 | githubRepoId Int 24 | GitHubRepo GitHubRepo @relation(fields: [githubRepoId], references: [repoId]) 25 | 26 | @@map("synced_issues") 27 | } 28 | 29 | model LinearTeam { 30 | id String @id @default(cuid()) 31 | 32 | teamId String @unique 33 | teamName String 34 | 35 | publicLabelId String 36 | 37 | canceledStateId String 38 | doneStateId String 39 | toDoStateId String 40 | 41 | Sync Sync[] 42 | 43 | SyncedIssue SyncedIssue[] 44 | Milestone Milestone[] 45 | 46 | @@map("linear_teams") 47 | } 48 | 49 | model GitHubRepo { 50 | id String @id @default(cuid()) 51 | 52 | repoId Int @unique 53 | repoName String 54 | 55 | webhookSecret String 56 | 57 | Sync Sync[] 58 | 59 | SyncedIssue SyncedIssue[] 60 | Milestone Milestone[] 61 | 62 | @@map("github_repos") 63 | } 64 | 65 | model Sync { 66 | id String @id @default(cuid()) 67 | 68 | githubUserId Int 69 | linearUserId String 70 | 71 | GitHubRepo GitHubRepo @relation(fields: [githubRepoId], references: [repoId]) 72 | githubRepoId Int 73 | githubApiKey String 74 | githubApiKeyIV String 75 | 76 | LinearTeam LinearTeam @relation(fields: [linearTeamId], references: [teamId]) 77 | linearTeamId String 78 | linearApiKey String 79 | linearApiKeyIV String 80 | 81 | @@unique([githubUserId, linearUserId, githubRepoId, linearTeamId]) 82 | @@map("syncs") 83 | } 84 | 85 | model User { 86 | id String @id @default(cuid()) 87 | 88 | githubUserId Int 89 | githubUsername String 90 | githubEmail String? 91 | 92 | linearUserId String 93 | linearUsername String 94 | linearEmail String? 95 | 96 | @@unique([githubUserId, linearUserId]) 97 | @@map("users") 98 | } 99 | 100 | model Milestone { 101 | id String @id @default(cuid()) 102 | 103 | milestoneId Int 104 | cycleId String 105 | 106 | GitHubRepo GitHubRepo @relation(fields: [githubRepoId], references: [repoId]) 107 | githubRepoId Int 108 | 109 | LinearTeam LinearTeam @relation(fields: [linearTeamId], references: [teamId]) 110 | linearTeamId String 111 | 112 | @@unique([milestoneId, githubRepoId, cycleId, linearTeamId]) 113 | @@map("milestones") 114 | } 115 | -------------------------------------------------------------------------------- /public/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacedriveapp/linear-sync/7b7e9e638eca0ff2313733dabf56da0e9f6e3e1d/public/apple-touch-icon.png -------------------------------------------------------------------------------- /public/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacedriveapp/linear-sync/7b7e9e638eca0ff2313733dabf56da0e9f6e3e1d/public/favicon-16x16.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacedriveapp/linear-sync/7b7e9e638eca0ff2313733dabf56da0e9f6e3e1d/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacedriveapp/linear-sync/7b7e9e638eca0ff2313733dabf56da0e9f6e3e1d/public/favicon.ico -------------------------------------------------------------------------------- /public/favicon.svg: -------------------------------------------------------------------------------- 1 | 8 | 16 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /public/fonts/CalSans-SemiBold.woff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacedriveapp/linear-sync/7b7e9e638eca0ff2313733dabf56da0e9f6e3e1d/public/fonts/CalSans-SemiBold.woff -------------------------------------------------------------------------------- /public/fonts/CalSans-SemiBold.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spacedriveapp/linear-sync/7b7e9e638eca0ff2313733dabf56da0e9f6e3e1d/public/fonts/CalSans-SemiBold.woff2 -------------------------------------------------------------------------------- /styles/globals.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @font-face { 6 | font-family: "cal-sans"; 7 | src: url("/fonts/CalSans-SemiBold.woff2") format("woff2"); 8 | font-display: swap; 9 | } 10 | 11 | @layer base { 12 | html { 13 | @apply font-secondary bg-cal-gray; 14 | } 15 | h1 { 16 | @apply text-7xl font-bold font-primary font-cal-sans; 17 | } 18 | h2 { 19 | @apply text-5xl font-bold font-secondary font-cal-sans; 20 | } 21 | h3 { 22 | @apply text-2xl font-tertiary; 23 | } 24 | button:not([data-state]) { 25 | @apply border-2 border-gray-900 disabled:border-gray-400 font-primary disabled:font-tertiary font-medium text-xl p-3 h-14 w-full max-w-md rounded-[2rem] enabled:hover:rounded-2xl transition-rounded flex items-center justify-center; 26 | } 27 | button > span { 28 | @apply grow; 29 | } 30 | select { 31 | @apply rounded-[2rem] enabled:hover:rounded-2xl text-center bg-gray-300 enabled:hover:bg-gray-400 transition-all p-3 max-w-xs w-full font-secondary text-ellipsis text-xl; 32 | } 33 | input { 34 | @apply w-full bg-transparent grow p-3 text-ellipsis; 35 | } 36 | code { 37 | @apply bg-gray-300 py-0.5 px-1 rounded-md max-w-min; 38 | } 39 | a { 40 | @apply hover:font-tertiary font-semibold transition-colors; 41 | } 42 | ul { 43 | @apply space-y-6; 44 | } 45 | .center { 46 | @apply flex flex-col justify-center items-center; 47 | } 48 | .font-primary { 49 | @apply text-gray-900; 50 | } 51 | .font-secondary { 52 | @apply text-gray-700; 53 | } 54 | .font-tertiary { 55 | @apply text-gray-500; 56 | } 57 | .font-negative { 58 | @apply text-gray-300; 59 | } 60 | .font-cal-sans { 61 | font-family: "cal-sans", Arial, Helvetica, sans-serif; 62 | } 63 | } 64 | 65 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | 3 | const colors = require("tailwindcss/colors"); 4 | 5 | module.exports = { 6 | content: [ 7 | "./pages/**/*.{js,ts,jsx,tsx}", 8 | "./components/**/*.{js,ts,jsx,tsx}" 9 | ], 10 | theme: { 11 | extend: { 12 | transitionProperty: { 13 | rounded: "border-radius" 14 | } 15 | }, 16 | colors: { 17 | transparent: "transparent", 18 | white: colors.white, 19 | gray: colors.neutral, 20 | black: colors.black, 21 | yellow: colors.amber, 22 | orange: colors.orange, 23 | red: colors.red, 24 | purple: colors.purple, 25 | indigo: colors.indigo, 26 | blue: colors.blue, 27 | green: colors.green, 28 | "cal-gray": "#f2f2f2", 29 | danger: colors.red[600] 30 | } 31 | }, 32 | plugins: [] 33 | }; 34 | 35 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "strict": false, 8 | "forceConsistentCasingInFileNames": true, 9 | "noEmit": true, 10 | "incremental": true, 11 | "esModuleInterop": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "jsx": "preserve", 17 | "typeRoots": ["node_modules/@types", "typings/"] 18 | }, 19 | "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"], 20 | "exclude": ["node_modules"] 21 | } 22 | -------------------------------------------------------------------------------- /typings/environment.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | LINEAR_API_KEY: string; 5 | GITHUB_API_KEY: string; 6 | 7 | LINEAR_USER_ID: string; 8 | LINEAR_TEAM_ID: string; 9 | 10 | LINEAR_PUBLIC_LABEL_ID: string; 11 | LINEAR_CANCELED_STATE_ID: string; 12 | LINEAR_DONE_STATE_ID: string; 13 | LINEAR_TODO_STATE_ID: string; 14 | 15 | GITHUB_OWNER: string; 16 | GITHUB_REPO: string; 17 | 18 | DATABASE_URL: string; 19 | } 20 | } 21 | } 22 | 23 | export {}; 24 | 25 | -------------------------------------------------------------------------------- /typings/index.ts: -------------------------------------------------------------------------------- 1 | export interface LinearWebhookPayload { 2 | action: "create" | "update" | "remove"; 3 | type: string; 4 | createdAt: string; 5 | data: LinearData; 6 | url: string; 7 | updatedFrom?: Partial; 8 | } 9 | 10 | interface LinearData { 11 | id: string; 12 | createdAt: string; 13 | updatedAt: string; 14 | number: number; 15 | title: string; 16 | description: string; 17 | priority: number; 18 | boardOrder: number; 19 | sortOrder: number; 20 | startedAt: string; 21 | teamId: string; 22 | projectId: string; 23 | cycleId?: string; 24 | // previousIdentifiers: string[]; 25 | creatorId: string; 26 | userId?: string; 27 | assigneeId: string; 28 | stateId: string; 29 | priorityLabel: string; 30 | subscriberIds: string[]; 31 | labelIds: string[]; 32 | assignee: LinearObject; 33 | project: LinearObject; 34 | state: LinearState; 35 | team: LinearTeam; 36 | user?: LinearObject; 37 | body?: string; 38 | issueId?: string; 39 | issue?: { 40 | id: string; 41 | title: string; 42 | }; 43 | } 44 | 45 | export interface LinearObject { 46 | id: string; 47 | name: string; 48 | } 49 | 50 | interface ColoredLinearObject extends LinearObject { 51 | color: string; 52 | } 53 | 54 | export interface LinearState extends ColoredLinearObject { 55 | type: string; 56 | } 57 | 58 | export interface LinearTeam extends LinearObject { 59 | key: string; 60 | labels: { nodes: LinearObject[] }; 61 | states: { nodes: LinearState[] }; 62 | } 63 | 64 | export interface GitHubRepo { 65 | id: string; 66 | name: string; 67 | } 68 | 69 | export interface LinearContext { 70 | userId: string; 71 | teamId: string; 72 | apiKey: string; 73 | } 74 | 75 | export interface GitHubContext { 76 | userId: string; 77 | repoId: string; 78 | apiKey: string; 79 | } 80 | 81 | export interface Sync { 82 | id: string; 83 | LinearTeam: { id: string; teamName: string }; 84 | GitHubRepo: { id: string; repoName: string }; 85 | } 86 | 87 | export type MilestoneState = "open" | "closed"; 88 | 89 | export type GitHubIssueLabel = { 90 | name: string; 91 | color: string; 92 | }; 93 | 94 | -------------------------------------------------------------------------------- /utils/apollo.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloClient, 3 | InMemoryCache, 4 | gql, 5 | ApolloQueryResult 6 | } from "@apollo/client"; 7 | import { LINEAR } from "./constants"; 8 | 9 | /** 10 | * Initialize Apollo instance for Linear with cache 11 | */ 12 | const apolloLinear = new ApolloClient({ 13 | uri: LINEAR.GRAPHQL_ENDPOINT, 14 | cache: new InMemoryCache(), 15 | defaultOptions: { 16 | query: { 17 | fetchPolicy: "no-cache" 18 | } 19 | } 20 | }); 21 | 22 | /** 23 | * Make a request to the Linear GraphQL API 24 | * @param {string} query the GraphQL query eg. `query YourQuery { teams { nodes { name } } }` 25 | * @param {string} token to authenticate the request 26 | * @param variables to pass to the query eg. `query WithVars($first: Number) { issues(first: $first) { nodes { name } } }` 27 | * @returns {Promise>} result 28 | */ 29 | export async function linearQuery( 30 | query: string, 31 | token: string, 32 | variables = {} 33 | ): Promise> { 34 | // Is this a query or mutation? This allows us to use a single method. 35 | const operation = query.split(" ")[0]; 36 | if (!["query", "mutation"].includes(operation)) return; 37 | 38 | const QUERY = gql` 39 | ${query} 40 | `; 41 | 42 | // Make the request 43 | const payload = await apolloLinear[ 44 | operation === "mutation" ? "mutate" : operation 45 | ]({ 46 | [operation]: QUERY, 47 | variables, 48 | context: { 49 | headers: { 50 | authorization: `Bearer ${token}` 51 | } 52 | } 53 | }); 54 | 55 | if (payload.error) throw new Error(payload.error); 56 | if (payload.errors) 57 | throw new Error(payload.errors[0].extensions.userPresentableMessage); 58 | if (!payload.data) throw new Error("No data returned from query"); 59 | 60 | return payload; 61 | } 62 | 63 | -------------------------------------------------------------------------------- /utils/constants.ts: -------------------------------------------------------------------------------- 1 | import colors from "tailwindcss/colors"; 2 | 3 | export const LINEAR = { 4 | OAUTH_ID: process.env.NEXT_PUBLIC_LINEAR_OAUTH_ID, 5 | OAUTH_URL: "https://linear.app/oauth/authorize", 6 | TOKEN_URL: "https://api.linear.app/oauth/token", 7 | SCOPES: ["write"], 8 | NEW_TOKEN_URL: "https://linear.app/settings/api", 9 | TOKEN_SECTION_HEADER: "Personal API keys", 10 | GRAPHQL_ENDPOINT: "https://api.linear.app/graphql", 11 | IP_ORIGINS: ["35.231.147.226", "35.243.134.228"], 12 | STORAGE_KEY: "linear-context", 13 | APP_URL: "https://linear.app", 14 | GITHUB_LABEL: "linear", 15 | WEBHOOK_EVENTS: ["Issue", "Comment", "IssueLabel"] 16 | }; 17 | 18 | export const SHARED = { 19 | PRIORITY_LABELS: { 20 | 0: { name: "No priority", color: colors.gray["500"] }, 21 | 1: { name: "Urgent", color: colors.red["600"] }, 22 | 2: { name: "High priority", color: colors.orange["500"] }, 23 | 3: { name: "Medium priority", color: colors.yellow["500"] }, 24 | 4: { name: "Low priority", color: colors.green["600"] } 25 | } 26 | }; 27 | 28 | export const GITHUB = { 29 | OAUTH_ID: process.env.NEXT_PUBLIC_GITHUB_OAUTH_ID, 30 | OAUTH_URL: "https://github.com/login/oauth/authorize", 31 | TOKEN_URL: "https://github.com/login/oauth/access_token", 32 | SCOPES: ["repo", "write:repo_hook", "read:user", "user:email"], 33 | NEW_TOKEN_URL: "https://github.com/settings/tokens/new", 34 | TOKEN_NOTE: "Linear-GitHub Sync", 35 | WEBHOOK_EVENTS: ["issues", "issue_comment", "label"], 36 | LIST_REPOS_ENDPOINT: 37 | "https://api.github.com/user/repos?per_page=100&sort=updated", 38 | USER_ENDPOINT: "https://api.github.com/user", 39 | REPO_ENDPOINT: "https://api.github.com/repos", 40 | ICON_URL: 41 | "https://cdn.discordapp.com/attachments/937628023497297930/988735284504043520/github.png", 42 | STORAGE_KEY: "github-context", 43 | UUID_SUFFIX: "decafbad" 44 | }; 45 | 46 | export const TIMEOUTS = { 47 | DEFAULT: 3000 48 | }; 49 | 50 | export const GENERAL = { 51 | APP_NAME: "Linear-GitHub Sync", 52 | APP_URL: "https://synclinear.com", 53 | CONTRIBUTE_URL: "https://github.com/calcom/linear-to-github", 54 | IMG_TAG_REGEX: //g, 55 | LOGIN_KEY: "login", 56 | SYNCED_ITEMS: [ 57 | { 58 | linearField: "Title", 59 | githubField: "Title", 60 | toGithub: true, 61 | toLinear: true 62 | }, 63 | { 64 | linearField: "Description", 65 | githubField: "Description", 66 | toGithub: true, 67 | toLinear: true 68 | }, 69 | { 70 | linearField: "Labels", 71 | githubField: "Labels", 72 | toGithub: true, 73 | notes: "GitHub labels will be created if they don't yet exist" 74 | }, 75 | { 76 | linearField: "Assignee", 77 | githubField: "Assignee", 78 | toGithub: true, 79 | toLinear: true, 80 | notes: "For authenticated users only. Silently ignored otherwise." 81 | }, 82 | { 83 | linearField: "Status", 84 | githubField: "State", 85 | toGithub: true, 86 | toLinear: true, 87 | notes: "eg. Closed issue in GitHub will be marked as Done in Linear" 88 | }, 89 | { 90 | linearField: "Comments", 91 | githubField: "Comments", 92 | toGithub: true, 93 | toLinear: true, 94 | notes: "GitHub comments by non-members are ignored" 95 | }, 96 | { 97 | linearField: "Priority", 98 | toGithub: true, 99 | githubField: "Label" 100 | }, 101 | { 102 | linearField: "Cycle", 103 | githubField: "Milestone", 104 | toGithub: true, 105 | toLinear: true, 106 | notes: "Optional. Milestone due date syncs to cycle end date." 107 | } 108 | ] 109 | }; 110 | 111 | -------------------------------------------------------------------------------- /utils/errors.ts: -------------------------------------------------------------------------------- 1 | import { formatJSON } from "."; 2 | 3 | export const getIssueUpdateError = ( 4 | resource: "state" | "description" | "title" | "assignee", 5 | data: { number: number; id: string; team: { key: string } }, 6 | syncedIssue: { githubIssueNumber: number; githubIssueId: number }, 7 | updatedIssueResponse: any 8 | ): string => { 9 | return `Failed to update GitHub issue ${resource} for ${data.team.key}-${ 10 | data.number 11 | } [${data.id}] on GitHub issue #${syncedIssue.githubIssueNumber} [${ 12 | syncedIssue.githubIssueId 13 | }], received status code ${ 14 | updatedIssueResponse.statusCode 15 | }, body of ${formatJSON(JSON.parse(updatedIssueResponse.body))}.`; 16 | }; 17 | 18 | export class ApiError extends Error { 19 | constructor(public message: string, public statusCode: number) { 20 | super(message); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /utils/github.ts: -------------------------------------------------------------------------------- 1 | import { GitHubRepo, MilestoneState } from "../typings"; 2 | import { getWebhookURL } from "."; 3 | import { GITHUB } from "./constants"; 4 | 5 | export const getGitHubFooter = (userName: string): string => { 6 | // To avoid exposing a user email if their username is an email address 7 | const sanitizedUsername = userName.split("@")?.[0]; 8 | 9 | return `\n\n`; 10 | }; 11 | 12 | export const getGitHubTokenURL = (): string => { 13 | const scopes = GITHUB.SCOPES.join(","); 14 | const description = GITHUB.TOKEN_NOTE.split(" ").join("%20"); 15 | const tokenURL = `${GITHUB.NEW_TOKEN_URL}?scopes=${scopes}&description=${description}`; 16 | 17 | return tokenURL; 18 | }; 19 | 20 | export const getGitHubAuthURL = (verificationCode: string): string => { 21 | // Specify OAuth app and scopes 22 | const params = { 23 | client_id: GITHUB.OAUTH_ID, 24 | redirect_uri: window.location.origin, 25 | scope: GITHUB.SCOPES.join(" "), 26 | state: verificationCode 27 | }; 28 | 29 | // Combine params in a URL-friendly string 30 | const authURL = Object.keys(params).reduce( 31 | (url, param, i) => 32 | `${url}${i == 0 ? "?" : "&"}${param}=${params[param]}`, 33 | GITHUB.OAUTH_URL 34 | ); 35 | 36 | return authURL; 37 | }; 38 | 39 | export const saveGitHubContext = async ( 40 | repo: GitHubRepo, 41 | webhookSecret: string 42 | ) => { 43 | const data = { 44 | repoId: repo.id, 45 | repoName: repo.name, 46 | webhookSecret 47 | }; 48 | 49 | const response = await fetch("/api/github/save", { 50 | method: "POST", 51 | body: JSON.stringify(data) 52 | }); 53 | 54 | return response.json(); 55 | }; 56 | 57 | export const getRepoWebhook = async ( 58 | repoName: string, 59 | token: string 60 | ): Promise => { 61 | const webhookUrl = getWebhookURL(); 62 | 63 | const response = await fetch(`/api/github/webhook`, { 64 | method: "POST", 65 | headers: { 66 | "Content-Type": "application/json", 67 | Authorization: `Bearer ${token}` 68 | }, 69 | body: JSON.stringify({ 70 | repoName, 71 | webhookUrl 72 | }) 73 | }); 74 | 75 | return await response.json(); 76 | }; 77 | 78 | export const setGitHubWebook = async ( 79 | token: string, 80 | repo: GitHubRepo, 81 | webhookSecret: string 82 | ): Promise => { 83 | const webhookURL = getWebhookURL(); 84 | const webhookData = { 85 | name: "web", 86 | active: true, 87 | events: GITHUB.WEBHOOK_EVENTS, 88 | config: { 89 | url: webhookURL, 90 | content_type: "json", 91 | insecure_ssl: "0", 92 | secret: webhookSecret 93 | } 94 | }; 95 | 96 | const response = await fetch( 97 | `https://api.github.com/repos/${repo.name}/hooks`, 98 | { 99 | method: "POST", 100 | headers: { 101 | Authorization: `Bearer ${token}`, 102 | Accept: "application/vnd.github+json" 103 | }, 104 | body: JSON.stringify(webhookData) 105 | } 106 | ); 107 | 108 | return await response.json(); 109 | }; 110 | 111 | export const updateGitHubWebhook = async ( 112 | token: string, 113 | repoName: string, 114 | updates: { add_events?: string[]; remove_events?: string[] } 115 | ): Promise => { 116 | const webhook = await getRepoWebhook(repoName, token); 117 | if (!webhook.id) { 118 | console.error(`Could not find webhook for ${repoName}.`); 119 | return; 120 | } 121 | 122 | const response = await fetch( 123 | `https://api.github.com/repos/${repoName}/hooks/${webhook.id}`, 124 | { 125 | method: "PATCH", 126 | headers: { 127 | Authorization: `Bearer ${token}`, 128 | Accept: "application/vnd.github+json" 129 | }, 130 | body: JSON.stringify(updates) 131 | } 132 | ); 133 | 134 | return await response.json(); 135 | }; 136 | 137 | export const exchangeGitHubToken = async ( 138 | refreshToken: string 139 | ): Promise => { 140 | const redirectURI = window.location.origin; 141 | 142 | const response = await fetch("/api/github/token", { 143 | method: "POST", 144 | body: JSON.stringify({ refreshToken, redirectURI }), 145 | headers: { "Content-Type": "application/json" } 146 | }); 147 | 148 | return await response.json(); 149 | }; 150 | 151 | export const getGitHubRepos = async (token: string): Promise => { 152 | const response = await fetch(GITHUB.LIST_REPOS_ENDPOINT, { 153 | headers: { Authorization: `Bearer ${token}` } 154 | }); 155 | 156 | return await response.json(); 157 | }; 158 | 159 | export const getGitHubUser = async (token: string): Promise => { 160 | const response = await fetch(GITHUB.USER_ENDPOINT, { 161 | headers: { Authorization: `Bearer ${token}` } 162 | }); 163 | 164 | return await response.json(); 165 | }; 166 | 167 | export const createMilestone = async ( 168 | token: string, 169 | repoName: string, 170 | title: string, 171 | description?: string, 172 | state?: MilestoneState 173 | ): Promise<{ milestoneId: number }> => { 174 | const milestoneData = { 175 | title, 176 | state: state || "open", 177 | ...(description && { description }) 178 | }; 179 | 180 | const response = await fetch( 181 | `https://api.github.com/repos/${repoName}/milestones`, 182 | { 183 | method: "POST", 184 | headers: { 185 | Authorization: `Bearer ${token}`, 186 | Accept: "application/vnd.github+json" 187 | }, 188 | body: JSON.stringify(milestoneData) 189 | } 190 | ); 191 | 192 | const responseBody = await response.json(); 193 | 194 | return { milestoneId: responseBody?.number }; 195 | }; 196 | 197 | export const updateMilestone = async ( 198 | token: string, 199 | repoName: string, 200 | milestoneId: number, 201 | title?: string, 202 | state?: MilestoneState, 203 | description?: string 204 | ): Promise => { 205 | const milestoneData = { 206 | ...(title && { title }), 207 | ...(state && { state }), 208 | ...(description && { description }) 209 | }; 210 | 211 | const response = await fetch( 212 | `https://api.github.com/repos/${repoName}/milestones/${milestoneId}`, 213 | { 214 | method: "PATCH", 215 | headers: { 216 | Authorization: `Bearer ${token}`, 217 | Accept: "application/vnd.github+json" 218 | }, 219 | body: JSON.stringify(milestoneData) 220 | } 221 | ); 222 | 223 | return response; 224 | }; 225 | 226 | export const setIssueMilestone = async ( 227 | token: string, 228 | repoName: string, 229 | issueNumber: number, 230 | milestoneId: number | null 231 | ): Promise => { 232 | const response = await fetch( 233 | `https://api.github.com/repos/${repoName}/issues/${issueNumber}`, 234 | { 235 | headers: { 236 | Authorization: `Bearer ${token}`, 237 | Accept: "application/vnd.github+json" 238 | }, 239 | method: "PATCH", 240 | body: JSON.stringify({ milestone: milestoneId }) 241 | } 242 | ); 243 | 244 | return response; 245 | }; 246 | 247 | -------------------------------------------------------------------------------- /utils/index.ts: -------------------------------------------------------------------------------- 1 | import { createCipheriv, createDecipheriv, randomBytes } from "crypto"; 2 | import { GitHubContext, LinearContext } from "../typings"; 3 | import { GENERAL, GITHUB } from "./constants"; 4 | 5 | export const isDev = (): boolean => { 6 | return process.env.NODE_ENV === "development"; 7 | }; 8 | 9 | export const getWebhookURL = (): string => { 10 | if (window.location.hostname === "localhost") return "https://example.com"; 11 | return `${window.location.origin}/api`; 12 | }; 13 | 14 | export const copyToClipboard = (text: string) => { 15 | if (!window?.navigator) alert("Cannot copy to clipboard"); 16 | 17 | navigator?.clipboard?.writeText(text); 18 | }; 19 | 20 | export const formatJSON = (body: Object): string => { 21 | return JSON.stringify(body, null, 4); 22 | }; 23 | 24 | export const clearURLParams = () => { 25 | window.history.replaceState(null, null, window.location.pathname); 26 | }; 27 | 28 | export const encrypt = (text: string): { hash: string; initVector: string } => { 29 | const algorithm = "aes-256-ctr"; 30 | const secret = process.env.ENCRYPTION_KEY; 31 | 32 | const initVector = randomBytes(16); 33 | const cipher = createCipheriv(algorithm, secret, initVector); 34 | const encrypted = Buffer.concat([cipher.update(text), cipher.final()]); 35 | 36 | return { 37 | hash: encrypted.toString("hex"), 38 | initVector: initVector.toString("hex") 39 | }; 40 | }; 41 | 42 | export const decrypt = (content: string, initVector: string): string => { 43 | const algorithm = "aes-256-ctr"; 44 | const secret = process.env.ENCRYPTION_KEY; 45 | 46 | const decipher = createDecipheriv( 47 | algorithm, 48 | secret, 49 | Buffer.from(initVector, "hex") 50 | ); 51 | const decrypted = Buffer.concat([ 52 | decipher.update(Buffer.from(content, "hex")), 53 | decipher.final() 54 | ]); 55 | 56 | return decrypted.toString(); 57 | }; 58 | 59 | export const replaceImgTags = (text: string): string => { 60 | if (!text) return ""; 61 | return text.replace( 62 | GENERAL.IMG_TAG_REGEX, 63 | (_, args) => `![image](https://${args})` 64 | ); 65 | }; 66 | 67 | export const replaceStrikethroughTags = (text: string): string => { 68 | // To avoid unforeseen infinite loops, only replace the first 10 occurrences 69 | const tildes = text?.match(/~+/g); 70 | if (tildes?.length > 10) return text; 71 | 72 | return text?.replace(/(? { 76 | return `From [SyncLinear.com](https://synclinear.com)`; 77 | }; 78 | 79 | export const legacySyncFooter = `From [Linear-GitHub Sync](https://synclinear.com)`; 80 | 81 | export const saveSync = async ( 82 | linearContext: LinearContext, 83 | githubContext: GitHubContext 84 | ) => { 85 | const data = { 86 | github: { ...githubContext }, 87 | linear: { ...linearContext } 88 | }; 89 | 90 | const response = await fetch("/api/save", { 91 | method: "POST", 92 | body: JSON.stringify(data) 93 | }); 94 | 95 | return await response.json(); 96 | }; 97 | 98 | export const getAttachmentQuery = ( 99 | issueId: string, 100 | issueNumber: number, 101 | repoFullName: string 102 | ): string => { 103 | return `mutation { 104 | attachmentCreate(input: { 105 | issueId: "${issueId}" 106 | title: "GitHub Issue #${issueNumber}" 107 | subtitle: "Synchronized" 108 | url: "https://github.com/${repoFullName}/issues/${issueNumber}" 109 | iconUrl: "${GITHUB.ICON_URL}" 110 | }) { 111 | success 112 | } 113 | }`; 114 | }; 115 | 116 | export const skipReason = ( 117 | event: 118 | | "issue" 119 | | "edit" 120 | | "milestone" 121 | | "comment" 122 | | "state change" 123 | | "label" 124 | | "assignee", 125 | issueNumber: number | string, 126 | causedBySync: boolean = false 127 | ): string => { 128 | return `Skipping over ${event} for issue #${issueNumber} as it is ${ 129 | causedBySync ? "caused by sync" : "not synced" 130 | }.`; 131 | }; 132 | 133 | export const isNumber = (value: string | number): boolean => { 134 | return !isNaN(Number(value)); 135 | }; 136 | 137 | -------------------------------------------------------------------------------- /utils/linear.ts: -------------------------------------------------------------------------------- 1 | import { LinearClient } from "@linear/sdk"; 2 | import { getWebhookURL, getSyncFooter } from "."; 3 | import { linearQuery } from "./apollo"; 4 | import { LINEAR, GENERAL, GITHUB } from "./constants"; 5 | import { v4 as uuid } from "uuid"; 6 | import { LinearTeam } from "../typings"; 7 | import { WebhookUpdateInput } from "@linear/sdk/dist/_generated_documents"; 8 | 9 | export const getLinearTokenURL = (): string => { 10 | const baseURL = LINEAR.NEW_TOKEN_URL; 11 | const sectionSelector = `#:~:text=${LINEAR.TOKEN_SECTION_HEADER.split( 12 | " " 13 | ).join("%20")}`; 14 | const tokenURL = `${baseURL}${sectionSelector}`; 15 | 16 | return tokenURL; 17 | }; 18 | 19 | export const getLinearAuthURL = (verificationCode: string): string => { 20 | // Specify OAuth app and scopes 21 | const params = { 22 | client_id: LINEAR.OAUTH_ID, 23 | redirect_uri: window.location.origin, 24 | scope: LINEAR.SCOPES.join(","), 25 | state: verificationCode, 26 | response_type: "code", 27 | prompt: "consent" 28 | }; 29 | 30 | // Combine params in a URL-friendly string 31 | const authURL = Object.keys(params).reduce( 32 | (url, param, i) => 33 | `${url}${i == 0 ? "?" : "&"}${param}=${params[param]}`, 34 | LINEAR.OAUTH_URL 35 | ); 36 | 37 | return authURL; 38 | }; 39 | 40 | export const getLinearContext = async (token: string) => { 41 | const query = `query { 42 | teams { 43 | nodes { 44 | name 45 | id 46 | labels { 47 | nodes { 48 | id 49 | name 50 | } 51 | } 52 | states { 53 | nodes { 54 | id 55 | name 56 | } 57 | } 58 | } 59 | } 60 | viewer { 61 | name 62 | id 63 | } 64 | }`; 65 | 66 | return await linearQuery(query, token); 67 | }; 68 | 69 | export const getLinearWebhook = async (token: string, teamName: string) => { 70 | const callbackURL = getWebhookURL(); 71 | 72 | const query = `query GetWebhook { 73 | webhooks { 74 | nodes { 75 | url 76 | id 77 | team { 78 | name 79 | } 80 | } 81 | } 82 | }`; 83 | 84 | const response = await linearQuery(query, token); 85 | if (!response?.data) { 86 | console.error("No webhook response from Linear"); 87 | return null; 88 | } 89 | 90 | const webhook = response.data.webhooks?.nodes?.find( 91 | webhook => 92 | webhook.url === callbackURL && webhook.team?.name === teamName 93 | ); 94 | 95 | return webhook; 96 | }; 97 | 98 | export const setLinearWebhook = async (token: string, teamID: string) => { 99 | const callbackURL = getWebhookURL(); 100 | 101 | const mutation = `mutation CreateWebhook($callbackURL: String!, $teamID: String) { 102 | webhookCreate( 103 | input: { 104 | url: $callbackURL 105 | teamId: $teamID 106 | label: "GitHub Sync" 107 | resourceTypes: ["Issue", "Comment", "IssueLabel"] 108 | } 109 | ) { 110 | success 111 | webhook { 112 | id 113 | enabled 114 | } 115 | } 116 | }`; 117 | 118 | return await linearQuery(mutation, token, { callbackURL, teamID }); 119 | }; 120 | 121 | export const updateLinearWebhook = async ( 122 | token: string, 123 | teamId: string, 124 | updates: WebhookUpdateInput 125 | ) => { 126 | const webhook = await getLinearWebhook(token, teamId); 127 | if (!webhook?.id) { 128 | console.error(`Could not find webhook for Linear team ${teamId}`); 129 | return; 130 | } 131 | 132 | const mutation = `mutation UpdateWebhook($input: WebhookUpdateInput!, $webhookId: String!) { 133 | webhookUpdate( 134 | id: $webhookId, 135 | input: $input 136 | ) { 137 | success 138 | } 139 | }`; 140 | 141 | return await linearQuery(mutation, token, { 142 | webhookId: webhook.id, 143 | input: updates 144 | }); 145 | }; 146 | 147 | export const createLinearPublicLabel = async ( 148 | token: string, 149 | teamID: string 150 | ) => { 151 | const mutation = `mutation CreateLabel($teamID: String!) { 152 | issueLabelCreate( 153 | input: { 154 | name: "Public" 155 | color: "#2DA54E" 156 | teamId: $teamID 157 | } 158 | ) { 159 | success 160 | issueLabel { 161 | id 162 | name 163 | } 164 | } 165 | }`; 166 | 167 | return await linearQuery(mutation, token, { teamID }); 168 | }; 169 | 170 | export const getLinearCycle = async ( 171 | token: string, 172 | cycleId: string 173 | ): Promise<{ 174 | data: { 175 | cycle: { 176 | name: string; 177 | description: string; 178 | number: number; 179 | endsAt: string; 180 | }; 181 | }; 182 | }> => { 183 | const query = `query GetCycle($cycleId: String!) { 184 | cycle(id: $cycleId) { 185 | name 186 | description 187 | number 188 | endsAt 189 | } 190 | }`; 191 | 192 | return await linearQuery(query, token, { cycleId }); 193 | }; 194 | 195 | export const createLinearCycle = async ( 196 | token: string, 197 | teamId: string, 198 | title: string, 199 | description?: string, 200 | endDate?: Date 201 | ): Promise<{ 202 | data: { cycleCreate: { success: boolean; cycle: { id: string } } }; 203 | }> => { 204 | const mutation = `mutation CreateCycle( 205 | $teamId: String!, 206 | $title: String!, 207 | $description: String, 208 | $startsAt: DateTime!, 209 | $endsAt: DateTime! 210 | ) { 211 | cycleCreate( 212 | input: { 213 | name: $title, 214 | description: $description, 215 | teamId: $teamId, 216 | startsAt: $startsAt, 217 | endsAt: $endsAt 218 | } 219 | ) { 220 | success 221 | cycle { 222 | id 223 | } 224 | } 225 | }`; 226 | 227 | return await linearQuery(mutation, token, { 228 | teamId, 229 | title, 230 | ...(description && { description }), 231 | ...(endDate && { endsAt: endDate }), 232 | startsAt: new Date() 233 | }); 234 | }; 235 | 236 | export const updateLinearCycle = async ( 237 | token: string, 238 | cycleId: string, 239 | name?: string, 240 | description?: string, 241 | endDate?: Date 242 | ): Promise<{ 243 | data: { cycleUpdate: { success: boolean } }; 244 | }> => { 245 | const mutation = `mutation UpdateCycle( 246 | $cycleId: String!, 247 | $name: String, 248 | $description: String, 249 | $endsAt: DateTime 250 | ) { 251 | cycleUpdate( 252 | id: $cycleId, 253 | input: { 254 | name: $name, 255 | description: $description, 256 | endsAt: $endsAt 257 | } 258 | ) { 259 | success 260 | } 261 | }`; 262 | 263 | return await linearQuery(mutation, token, { 264 | cycleId, 265 | // Only include the fields that are defined to avoid server error 266 | ...(name && { name }), 267 | ...(description && { description }), 268 | ...(endDate && { endsAt: endDate }) 269 | }); 270 | }; 271 | 272 | export const saveLinearContext = async (token: string, team: LinearTeam) => { 273 | const labels = [ 274 | ...(team.states?.nodes ?? []), 275 | ...(team.labels?.nodes ?? []) 276 | ]; 277 | 278 | if (!labels.find(n => n.name === "Public")) { 279 | const { data } = await createLinearPublicLabel(token, team.id); 280 | 281 | if (!data?.issueLabelCreate?.issueLabel) 282 | alert('Please create a Linear label called "Public"'); 283 | 284 | labels.push(data?.issueLabelCreate?.issueLabel); 285 | } 286 | 287 | const data = { 288 | teamId: team.id, 289 | teamName: team.name, 290 | publicLabelId: labels.find(n => n.name === "Public")?.id, 291 | canceledStateId: labels.find(n => n.name === "Canceled")?.id, 292 | doneStateId: labels.find(n => n.name === "Done")?.id, 293 | toDoStateId: labels.find(n => n.name === "Todo")?.id 294 | }; 295 | 296 | const response = await fetch("/api/linear/save", { 297 | method: "POST", 298 | body: JSON.stringify(data) 299 | }); 300 | 301 | return response.json(); 302 | }; 303 | 304 | export const exchangeLinearToken = async ( 305 | refreshToken: string 306 | ): Promise => { 307 | const redirectURI = window.location.origin; 308 | 309 | const response = await fetch("/api/linear/token", { 310 | method: "POST", 311 | body: JSON.stringify({ refreshToken, redirectURI }), 312 | headers: { "Content-Type": "application/json" } 313 | }); 314 | 315 | return await response.json(); 316 | }; 317 | 318 | export const checkForExistingTeam = async (teamId: string): Promise => { 319 | const response = await fetch(`/api/linear/team/${teamId}`, { 320 | method: "GET" 321 | }); 322 | 323 | return await response.json(); 324 | }; 325 | 326 | // Open a Linear ticket for the creator to authenticate with this app 327 | export const inviteMember = async ( 328 | memberId: string, 329 | teamId: string, 330 | repoName, 331 | linearClient: LinearClient 332 | ) => { 333 | const issueCreator = await linearClient.user(memberId); 334 | const message = [ 335 | `Hey @${issueCreator.displayName}!`, 336 | `Someone on your team signed up for [Linear-GitHub Sync](${GENERAL.APP_URL}).`, 337 | `To mirror issues you tag as Public in ${repoName}, simply follow the auth flow [here](${GENERAL.APP_URL}).`, 338 | `If you'd like to stop seeing these messages, please ask your workspace admin to let us know!`, 339 | getSyncFooter() 340 | ].join("\n"); 341 | 342 | linearClient.issueCreate({ 343 | title: `GitHub Sync — ${issueCreator.name}, please join our workspace`, 344 | description: message, 345 | teamId: teamId, 346 | assigneeId: memberId 347 | }); 348 | }; 349 | 350 | export const generateLinearUUID = (): string => { 351 | return `${uuid().substring(0, 28)}${GITHUB.UUID_SUFFIX}`; 352 | }; 353 | 354 | -------------------------------------------------------------------------------- /utils/webhook/github.handler.ts: -------------------------------------------------------------------------------- 1 | import prisma from "../../prisma"; 2 | import { createHmac, timingSafeEqual } from "crypto"; 3 | import { 4 | decrypt, 5 | formatJSON, 6 | getAttachmentQuery, 7 | getSyncFooter, 8 | replaceImgTags, 9 | skipReason 10 | } from "../index"; 11 | import { LinearClient } from "@linear/sdk"; 12 | import { replaceMentions, upsertUser } from "../../pages/api/utils"; 13 | import { 14 | Issue, 15 | IssueCommentCreatedEvent, 16 | IssuesEvent, 17 | MilestoneEvent, 18 | Repository, 19 | User 20 | } from "@octokit/webhooks-types"; 21 | import { 22 | createLinearCycle, 23 | generateLinearUUID, 24 | updateLinearCycle 25 | } from "../linear"; 26 | import { LINEAR } from "../constants"; 27 | import got from "got"; 28 | import { linearQuery } from "../apollo"; 29 | import { ApiError } from "../errors"; 30 | 31 | export async function githubWebhookHandler( 32 | body: IssuesEvent | IssueCommentCreatedEvent | MilestoneEvent, 33 | signature: string, 34 | githubEvent: string 35 | ) { 36 | const { repository, sender, action } = body; 37 | 38 | let sync = 39 | !!repository?.id && !!sender?.id 40 | ? await prisma.sync.findFirst({ 41 | where: { 42 | githubRepoId: repository.id, 43 | githubUserId: sender.id 44 | }, 45 | include: { 46 | GitHubRepo: true, 47 | LinearTeam: true 48 | } 49 | }) 50 | : null; 51 | 52 | if ( 53 | (!sync?.LinearTeam || !sync?.GitHubRepo) && 54 | !process.env.LINEAR_APPLICATION_ADMIN_KEY 55 | ) { 56 | console.log("Could not find issue's corresponding team."); 57 | throw new ApiError("Could not find issue's corresponding team.", 404); 58 | } 59 | 60 | const { issue }: IssuesEvent = body as unknown as IssuesEvent; 61 | 62 | let anonymousUser = false; 63 | if (!sync) { 64 | anonymousUser = true; 65 | sync = !!repository?.id 66 | ? await prisma.sync.findFirst({ 67 | where: { 68 | githubRepoId: repository.id 69 | }, 70 | include: { 71 | GitHubRepo: true, 72 | LinearTeam: true 73 | } 74 | }) 75 | : null; 76 | 77 | if (!sync) { 78 | console.log("Could not find issue's corresponding sync."); 79 | throw new ApiError( 80 | "Could not find issue's corresponding sync.", 81 | 404 82 | ); 83 | } 84 | } 85 | 86 | const HMAC = createHmac("sha256", sync.GitHubRepo?.webhookSecret ?? ""); 87 | const digest = Buffer.from( 88 | `sha256=${HMAC.update(JSON.stringify(body)).digest("hex")}`, 89 | "utf-8" 90 | ); 91 | const sig = Buffer.from(signature, "utf-8"); 92 | 93 | if (sig.length !== digest.length || !timingSafeEqual(digest, sig)) { 94 | console.log("Failed to verify signature for webhook."); 95 | 96 | throw new ApiError("GitHub webhook secret doesn't match up.", 403); 97 | } 98 | 99 | const { 100 | linearUserId, 101 | linearApiKey, 102 | linearApiKeyIV, 103 | githubUserId, 104 | githubApiKey, 105 | githubApiKeyIV, 106 | LinearTeam: { 107 | publicLabelId, 108 | doneStateId, 109 | toDoStateId, 110 | canceledStateId, 111 | teamId: linearTeamId 112 | }, 113 | GitHubRepo: { repoName } 114 | } = sync; 115 | 116 | let linearKey = process.env.LINEAR_API_KEY 117 | ? process.env.LINEAR_API_KEY 118 | : decrypt(linearApiKey, linearApiKeyIV); 119 | 120 | if (anonymousUser) { 121 | linearKey = process.env.LINEAR_APPLICATION_ADMIN_KEY; 122 | } 123 | 124 | const linear = new LinearClient({ 125 | apiKey: linearKey 126 | }); 127 | 128 | const githubKey = process.env.GITHUB_API_KEY 129 | ? process.env.GITHUB_API_KEY 130 | : decrypt(githubApiKey, githubApiKeyIV); 131 | 132 | const githubAuthHeader = `token ${githubKey}`; 133 | const userAgentHeader = `${repoName}, linear-github-sync`; 134 | const issuesEndpoint = `https://api.github.com/repos/${repoName}/issues`; 135 | 136 | if (!anonymousUser) { 137 | // Map the user's GitHub username to their Linear username if not yet mapped 138 | await upsertUser( 139 | linear, 140 | githubUserId, 141 | linearUserId, 142 | userAgentHeader, 143 | githubAuthHeader 144 | ); 145 | } 146 | 147 | const syncedIssue = !!repository?.id 148 | ? await prisma.syncedIssue.findFirst({ 149 | where: { 150 | githubIssueNumber: issue?.number, 151 | githubRepoId: repository.id 152 | } 153 | }) 154 | : null; 155 | 156 | if (githubEvent === "issue_comment" && action === "created") { 157 | // Comment created 158 | 159 | if (anonymousUser) { 160 | await createAnonymousUserComment( 161 | body as IssueCommentCreatedEvent, 162 | repository, 163 | sender 164 | ); 165 | } else { 166 | const { comment } = body as IssueCommentCreatedEvent; 167 | 168 | if (comment.body.includes("on Linear")) { 169 | console.log(skipReason("comment", issue.number, true)); 170 | 171 | return skipReason("comment", issue.number, true); 172 | } 173 | 174 | if (!syncedIssue) { 175 | const reason = skipReason("comment", issue.number); 176 | console.log(reason); 177 | return reason; 178 | } 179 | 180 | const modifiedComment = await prepareCommentContent(comment.body); 181 | await createLinearComment( 182 | linear, 183 | syncedIssue, 184 | modifiedComment, 185 | issue 186 | ); 187 | } 188 | } 189 | 190 | if (githubEvent === "milestone") { 191 | const { milestone } = body as MilestoneEvent; 192 | if (!milestone) throw new ApiError("No milestone found", 404); 193 | 194 | const syncedMilestone = await prisma.milestone.findFirst({ 195 | where: { 196 | milestoneId: milestone.id, 197 | githubRepoId: repository.id 198 | } 199 | }); 200 | 201 | if (action === "edited") { 202 | if (!syncedMilestone?.cycleId) { 203 | const reason = `Skipping over update for milestone "${milestone.title}" because it is not synced`; 204 | console.log(reason); 205 | return reason; 206 | } 207 | 208 | if (milestone.description?.includes(getSyncFooter())) { 209 | const reason = `Skipping over update for milestone "${milestone.title}" because it is caused by sync`; 210 | console.log(reason); 211 | return reason; 212 | } 213 | 214 | const cycleResponse = await updateLinearCycle( 215 | linearKey, 216 | syncedMilestone.cycleId, 217 | milestone.title, 218 | `${milestone.description}\n\n> ${getSyncFooter()}`, 219 | milestone.due_on ? new Date(milestone.due_on) : null 220 | ); 221 | 222 | if (!cycleResponse?.data?.cycleUpdate?.success) { 223 | const error = `Could not update cycle "${milestone.title}" for ${repoName}`; 224 | console.log(error); 225 | throw new ApiError(error, 500); 226 | } else { 227 | const result = `Updated cycle "${milestone.title}" for ${repoName}`; 228 | console.log(result); 229 | return result; 230 | } 231 | } 232 | } 233 | 234 | // Ensure the event is for an issue 235 | if (githubEvent !== "issues") { 236 | console.log("Not an issue event."); 237 | return "Not an issue event."; 238 | } 239 | 240 | if (action === "edited") { 241 | // Issue edited 242 | 243 | if (!syncedIssue) { 244 | const reason = skipReason("edit", issue.number); 245 | console.log(reason); 246 | return reason; 247 | } 248 | 249 | const title = issue.title.split(`${syncedIssue.linearIssueNumber}]`); 250 | if (title.length > 1) title.shift(); 251 | 252 | const description = issue.body?.split(""); 253 | if ((description?.length || 0) > 1) description?.pop(); 254 | 255 | let modifiedDescription = await replaceMentions( 256 | description?.join(""), 257 | "github" 258 | ); 259 | modifiedDescription = replaceImgTags(modifiedDescription); 260 | 261 | await linear 262 | .issueUpdate(syncedIssue.linearIssueId, { 263 | title: title.join(`${syncedIssue.linearIssueNumber}]`), 264 | description: modifiedDescription 265 | }) 266 | .then(updatedIssue => { 267 | updatedIssue.issue?.then(updatedIssueData => { 268 | updatedIssueData.team?.then(teamData => { 269 | if (!updatedIssue.success) 270 | console.log( 271 | `Failed to edit issue for ${syncedIssue.linearIssueNumber} [${syncedIssue.linearIssueNumber}] for GitHub issue #${issue.number} [${issue.id}].` 272 | ); 273 | else 274 | console.log( 275 | `Edited issue ${teamData.key}-${syncedIssue.linearIssueNumber} [${syncedIssue.linearIssueId}] for GitHub issue #${issue.number} [${issue.id}].` 276 | ); 277 | }); 278 | }); 279 | }); 280 | } else if (["closed", "reopened"].includes(action)) { 281 | // Issue closed or reopened 282 | 283 | if (!syncedIssue) { 284 | const reason = skipReason("edit", issue.number); 285 | console.log(reason); 286 | return reason; 287 | } 288 | 289 | await linear 290 | .issueUpdate(syncedIssue.linearIssueId, { 291 | stateId: 292 | issue.state_reason === "not_planned" 293 | ? canceledStateId 294 | : issue.state_reason === "completed" 295 | ? doneStateId 296 | : toDoStateId 297 | }) 298 | .then(updatedIssue => { 299 | updatedIssue.issue?.then(updatedIssueData => { 300 | updatedIssueData.team?.then(teamData => { 301 | if (!updatedIssue.success) 302 | console.log( 303 | `Failed to change state for ${syncedIssue.linearIssueNumber} [${syncedIssue.linearIssueNumber}] for GitHub issue #${issue.number} [${issue.id}].` 304 | ); 305 | else 306 | console.log( 307 | `Changed state ${teamData.key}-${syncedIssue.linearIssueNumber} [${syncedIssue.linearIssueId}] for GitHub issue #${issue.number} [${issue.id}].` 308 | ); 309 | }); 310 | }); 311 | }); 312 | } else if ( 313 | action === "opened" || 314 | (action === "labeled" && 315 | body.label?.name?.toLowerCase() === LINEAR.GITHUB_LABEL) 316 | ) { 317 | // Issue opened or special "linear" label added 318 | 319 | if (syncedIssue) { 320 | const reason = `Not creating ticket as issue ${issue.number} already exists on Linear as ${syncedIssue.linearIssueNumber}.`; 321 | console.log(reason); 322 | return reason; 323 | } 324 | 325 | let modifiedDescription = await replaceMentions(issue.body, "github"); 326 | modifiedDescription = replaceImgTags(modifiedDescription); 327 | 328 | if (anonymousUser) { 329 | modifiedDescription = `${modifiedDescription}\n\n [${sender.login} on GitHub](${sender.html_url})`; 330 | } 331 | 332 | const assignee = await prisma.user.findFirst({ 333 | where: { githubUserId: issue.assignee?.id }, 334 | select: { linearUserId: true } 335 | }); 336 | 337 | const createdIssueData = await linear.issueCreate({ 338 | id: generateLinearUUID(), 339 | title: issue.title, 340 | description: `${modifiedDescription ?? ""}`, 341 | teamId: linearTeamId, 342 | labelIds: [publicLabelId], 343 | assigneeId: 344 | issue.assignee?.id && assignee ? assignee.linearUserId : null 345 | }); 346 | 347 | if (!createdIssueData.success) { 348 | const reason = `Failed to create ticket for GitHub issue #${issue.number}.`; 349 | console.log(reason); 350 | throw new ApiError(reason, 500); 351 | } 352 | 353 | const createdIssue = await createdIssueData.issue; 354 | 355 | if (!createdIssue) 356 | console.log( 357 | `Failed to fetch ticket I just created for GitHub issue #${issue.number}.` 358 | ); 359 | else { 360 | const team = await createdIssue.team; 361 | 362 | if (!team) { 363 | console.log( 364 | `Failed to fetch team for ticket, ${createdIssue.id} for GitHub issue #${issue.number}.` 365 | ); 366 | } else { 367 | const ticketName = `${team.key}-${createdIssue.number}`; 368 | const attachmentQuery = getAttachmentQuery( 369 | createdIssue.id, 370 | issue.number, 371 | repoName 372 | ); 373 | 374 | await Promise.all([ 375 | got 376 | .patch(`${issuesEndpoint}/${issue.number}`, { 377 | json: { 378 | title: `[${ticketName}] ${issue.title}`, 379 | body: `${issue.body}\n\n[${ticketName}](${createdIssue.url})` 380 | }, 381 | headers: { 382 | "User-Agent": userAgentHeader, 383 | Authorization: githubAuthHeader 384 | } 385 | }) 386 | .then(titleRenameResponse => { 387 | if (titleRenameResponse.statusCode > 201) 388 | console.log( 389 | `Failed to update GitHub issue title for ${ticketName} on GitHub issue #${ 390 | issue.number 391 | }, received status code ${ 392 | titleRenameResponse.statusCode 393 | }, body of ${formatJSON( 394 | JSON.parse(titleRenameResponse.body) 395 | )}.` 396 | ); 397 | else 398 | console.log( 399 | `Created comment on GitHub issue #${issue.number} for Linear issue ${ticketName}.` 400 | ); 401 | }), 402 | linearQuery(attachmentQuery, linearKey).then(response => { 403 | if (!response?.data?.attachmentCreate?.success) { 404 | console.log( 405 | `Failed to create attachment on ${ticketName} for GitHub issue #${ 406 | issue.number 407 | }, received response ${ 408 | response?.error ?? response?.data ?? "" 409 | }.` 410 | ); 411 | } else { 412 | console.log( 413 | `Created attachment on ${ticketName} for GitHub issue #${issue.number}.` 414 | ); 415 | } 416 | }), 417 | prisma.syncedIssue.create({ 418 | data: { 419 | githubIssueNumber: issue.number, 420 | githubIssueId: issue.id, 421 | linearIssueId: createdIssue.id, 422 | linearIssueNumber: createdIssue.number, 423 | linearTeamId: team.id, 424 | githubRepoId: repository.id 425 | } 426 | }) 427 | ]); 428 | } 429 | } 430 | 431 | // Add issue comment history to newly-created Linear ticket 432 | if (action === "labeled") { 433 | const issueCommentsPayload = await got.get( 434 | `${issuesEndpoint}/${issue.number}/comments`, 435 | { 436 | headers: { 437 | "User-Agent": userAgentHeader, 438 | Authorization: githubAuthHeader 439 | } 440 | } 441 | ); 442 | 443 | if (issueCommentsPayload.statusCode > 201) { 444 | console.log( 445 | `Failed to fetch comments for GitHub issue #${ 446 | issue.number 447 | } [${issue.id}], received status code ${ 448 | issueCommentsPayload.statusCode 449 | }, body of ${formatJSON( 450 | JSON.parse(issueCommentsPayload.body) 451 | )}.` 452 | ); 453 | 454 | throw new ApiError( 455 | `Could not fetch comments for GitHub issue #${issue.number} [${issue.id}]`, 456 | 403 457 | ); 458 | } 459 | 460 | const comments = JSON.parse(issueCommentsPayload.body); 461 | 462 | for (const comment of comments) { 463 | const modifiedComment = await prepareCommentContent( 464 | comment.body 465 | ); 466 | 467 | await createLinearComment( 468 | linear, 469 | syncedIssue, 470 | modifiedComment, 471 | issue 472 | ); 473 | } 474 | } 475 | } else if (["assigned", "unassigned"].includes(action)) { 476 | // Assignee changed 477 | 478 | if (!syncedIssue) { 479 | const reason = skipReason("assignee", issue.number); 480 | console.log(reason); 481 | return reason; 482 | } 483 | 484 | const { assignee } = issue; 485 | 486 | if (!assignee?.id) { 487 | // Remove assignee 488 | 489 | const response = await linear.issueUpdate( 490 | syncedIssue.linearIssueId, 491 | { assigneeId: null } 492 | ); 493 | 494 | if (!response?.success) { 495 | const reason = `Failed to remove assignee on Linear ticket for GitHub issue #${issue.number}.`; 496 | console.log(reason); 497 | throw new ApiError(reason, 500); 498 | } else { 499 | const reason = `Removed assignee from Linear ticket for GitHub issue #${issue.number}.`; 500 | console.log(reason); 501 | return reason; 502 | } 503 | } else { 504 | // Add assignee 505 | 506 | const user = await prisma.user.findFirst({ 507 | where: { githubUserId: assignee?.id }, 508 | select: { linearUserId: true } 509 | }); 510 | 511 | if (!user) { 512 | const reason = `Skipping assignee change for issue #${issue.number} as no Linear username was found for GitHub user ${assignee?.login}.`; 513 | console.log(reason); 514 | return reason; 515 | } 516 | 517 | const response = await linear.issueUpdate( 518 | syncedIssue.linearIssueId, 519 | { assigneeId: user.linearUserId } 520 | ); 521 | 522 | if (!response?.success) { 523 | const reason = `Failed to add assignee on Linear ticket for GitHub issue #${issue.number}.`; 524 | console.log(reason); 525 | throw new ApiError(reason, 500); 526 | } else { 527 | const reason = `Added assignee to Linear ticket for GitHub issue #${issue.number}.`; 528 | console.log(reason); 529 | return reason; 530 | } 531 | } 532 | } else if (["milestoned", "demilestoned"].includes(action)) { 533 | // Milestone added or removed from issue 534 | 535 | if (!syncedIssue) { 536 | const reason = skipReason("milestone", issue.number); 537 | console.log(reason); 538 | return reason; 539 | } 540 | 541 | const { milestone } = issue; 542 | if (milestone === null) { 543 | const response = await linear.issueUpdate( 544 | syncedIssue.linearIssueId, 545 | { 546 | cycleId: null 547 | } 548 | ); 549 | 550 | if (!response?.success) { 551 | const reason = `Failed to remove Linear ticket from cycle for GitHub issue #${issue.number}.`; 552 | console.log(reason); 553 | throw new ApiError(reason, 500); 554 | } else { 555 | const reason = `Removed Linear ticket from cycle for GitHub issue #${issue.number}.`; 556 | console.log(reason); 557 | return reason; 558 | } 559 | } 560 | 561 | let syncedMilestone = await prisma.milestone.findFirst({ 562 | where: { 563 | milestoneId: milestone.number, 564 | githubRepoId: repository.id 565 | } 566 | }); 567 | 568 | if (!syncedMilestone) { 569 | if (milestone.description?.includes(getSyncFooter())) { 570 | const reason = `Skipping over milestone "${milestone.title}" because it is caused by sync`; 571 | console.log(reason); 572 | return reason; 573 | } 574 | 575 | const createdCycle = await createLinearCycle( 576 | linearKey, 577 | linearTeamId, 578 | milestone.title, 579 | `${milestone.description}\n\n> ${getSyncFooter()}`, 580 | milestone.due_on ? new Date(milestone.due_on) : null 581 | ); 582 | 583 | if (!createdCycle?.data?.cycleCreate?.cycle?.id) { 584 | const reason = `Failed to create Linear cycle for GitHub milestone #${milestone.number}.`; 585 | console.log(reason); 586 | throw new ApiError(reason, 500); 587 | } 588 | 589 | syncedMilestone = await prisma.milestone.create({ 590 | data: { 591 | milestoneId: milestone.number, 592 | githubRepoId: repository.id, 593 | cycleId: createdCycle.data.cycleCreate.cycle.id, 594 | linearTeamId: linearTeamId 595 | } 596 | }); 597 | } 598 | 599 | const response = await linear.issueUpdate(syncedIssue.linearIssueId, { 600 | cycleId: syncedMilestone.cycleId 601 | }); 602 | 603 | if (!response?.success) { 604 | const reason = `Failed to add Linear ticket to cycle for GitHub issue #${issue.number}.`; 605 | console.log(reason); 606 | throw new ApiError(reason, 500); 607 | } else { 608 | const reason = `Added Linear ticket to cycle for GitHub issue #${issue.number}.`; 609 | console.log(reason); 610 | return reason; 611 | } 612 | } 613 | } 614 | 615 | async function prepareCommentContent( 616 | comment: string, 617 | sender?: User, 618 | anonymous?: boolean 619 | ) { 620 | let modifiedComment = await replaceMentions(comment, "github"); 621 | modifiedComment = replaceImgTags(modifiedComment); 622 | 623 | if (!anonymous) return modifiedComment; 624 | 625 | return `>${modifiedComment}\n\n—[${sender.login} on GitHub](${sender.html_url})`; 626 | } 627 | 628 | async function createLinearComment( 629 | linear: LinearClient, 630 | syncedIssue, 631 | modifiedComment: string, 632 | issue: Issue 633 | ) { 634 | const comment = await linear.commentCreate({ 635 | id: generateLinearUUID(), 636 | issueId: syncedIssue.linearIssueId, 637 | body: modifiedComment ?? "" 638 | }); 639 | const commentData = await comment.comment; 640 | const issueData = await commentData.issue; 641 | const teamData = await issueData.team; 642 | 643 | if (!comment.success) { 644 | throw new ApiError( 645 | `Failed to create comment on Linear issue ${syncedIssue.linearIssueId} for GitHub issue ${issue.number}`, 646 | 500 647 | ); 648 | } else { 649 | console.log( 650 | `Created comment for ${teamData.key}-${syncedIssue.linearIssueNumber} [${syncedIssue.linearIssueId}] for GitHub issue #${issue.number} [${issue.id}].` 651 | ); 652 | } 653 | } 654 | 655 | async function createAnonymousUserComment( 656 | body: IssueCommentCreatedEvent, 657 | repository: Repository, 658 | sender: User 659 | ) { 660 | const { issue }: IssuesEvent = body as unknown as IssuesEvent; 661 | 662 | const syncedIssue = !!repository?.id 663 | ? await prisma.syncedIssue.findFirst({ 664 | where: { 665 | githubIssueNumber: issue?.number, 666 | githubRepoId: repository.id 667 | } 668 | }) 669 | : null; 670 | 671 | if (!syncedIssue) { 672 | console.log("Could not find issue's corresponding team."); 673 | throw new ApiError("Could not find issue's corresponding team.", 404); 674 | } 675 | 676 | const linearKey = process.env.LINEAR_APPLICATION_ADMIN_KEY; 677 | const linear = new LinearClient({ 678 | apiKey: linearKey 679 | }); 680 | 681 | const { comment: githubComment }: IssueCommentCreatedEvent = body; 682 | const modifiedComment = await prepareCommentContent( 683 | githubComment.body, 684 | sender, 685 | true 686 | ); 687 | 688 | await createLinearComment(linear, syncedIssue, modifiedComment, issue); 689 | } 690 | --------------------------------------------------------------------------------